refactor(ui): establish modular Compose foundation

This commit is contained in:
2026-07-10 18:19:24 +02:00
parent 382a015688
commit a15be5787d
68 changed files with 3103 additions and 2167 deletions

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>

View File

@@ -5,65 +5,71 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import android.os.Environment
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService
import java.net.NetworkInterface
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
override val defaultCoreDataDir: String =
AndroidPlatformContextHolder.context?.filesDir?.resolve("vnidrop")?.absolutePath
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop")
override val defaultReceiveDir: String =
AndroidPlatformContextHolder.context
?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?.absolutePath
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive")
override val deviceInfo: DeviceInfo = DeviceInfo(
@Composable
fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies {
val context = activity.applicationContext
val fileSystemService = rememberFileSystemService()
val notificationService = rememberAndroidLocalNotificationService(activity)
return remember(context, fileSystemService, notificationService) {
AppDependencies(
environment = PlatformEnvironment(
name = "Android ${Build.VERSION.SDK_INT}",
appVersion = context.appVersion(),
defaultCoreDataDir = context.filesDir.resolve("vnidrop").absolutePath,
defaultUsername = Build.DEVICE.takeIf(String::isNotBlank) ?: "Receiver",
),
deviceInfoProvider = AndroidDeviceInfoProvider(context),
fileSystemService = fileSystemService,
localNotificationService = notificationService,
)
}
}
private class AndroidDeviceInfoProvider(
private val context: Context,
) : DeviceInfoProvider {
override suspend fun load(): DeviceInfo = DeviceInfo(
deviceName = Build.DEVICE,
deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)
.filter { it.isNotBlank() }
.filter(String::isNotBlank)
.joinToString(" ")
.ifBlank { null },
operatingSystem = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
network = activeNetworkSummary(),
batteryLevel = batteryLevel(),
network = context.activeNetworkSummary(),
batteryLevel = context.batteryLevel(),
)
}
actual fun getPlatform(): Platform = AndroidPlatform()
private fun Context.appVersion(): String = runCatching {
packageManager.getPackageInfo(packageName, 0).versionName
}.getOrNull()?.takeIf(String::isNotBlank) ?: "0.1.0"
fun attachAndroidPlatformContext(context: Context) {
AndroidPlatformContextHolder.context = context.applicationContext
}
private fun Context.activeNetworkSummary(): String? = runCatching {
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return@runCatching networkInterfaceName()
val activeNetwork = manager.activeNetwork ?: return@runCatching networkInterfaceName()
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return@runCatching networkInterfaceName()
when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN"
else -> networkInterfaceName()
}
}.getOrNull()
private object AndroidPlatformContextHolder {
var context: Context? = null
}
private fun activeNetworkSummary(): String? =
runCatching {
val context = AndroidPlatformContextHolder.context ?: return@runCatching networkInterfaceName()
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return@runCatching networkInterfaceName()
val activeNetwork = manager.activeNetwork ?: return@runCatching networkInterfaceName()
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return@runCatching networkInterfaceName()
when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN"
else -> networkInterfaceName()
}
}.getOrNull()
private fun batteryLevel(): String? =
runCatching {
val context = AndroidPlatformContextHolder.context ?: return@runCatching null
val manager = context.getSystemService(BatteryManager::class.java)
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
level.takeIf { it >= 0 }?.let { "$it%" }
}.getOrNull()
private fun Context.batteryLevel(): String? = runCatching {
val manager = getSystemService(BatteryManager::class.java)
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
level.takeIf { it >= 0 }?.let { "$it%" }
}.getOrNull()
private fun networkInterfaceName(): String? =
NetworkInterface.getNetworkInterfaces()

View File

@@ -62,35 +62,6 @@ actual fun rememberReceiveFolderPicker(
}
}
actual suspend fun sharePickedFile(
repository: CoreRepository,
file: PickedShareFile,
transferName: String,
senderName: String,
) {
val context = AndroidContextHolder.context
errorIfNull(context, "Android context has not been attached")
.contentResolver
.openFileDescriptor(Uri.parse(file.value), "r")
.use { descriptor ->
checkNotNull(descriptor) { "Could not open selected file descriptor" }
repository.shareFileDescriptor(
fd = descriptor.fd,
displayName = file.displayName,
transferName = transferName,
senderName = senderName,
)
}
}
private object AndroidContextHolder {
var context: Context? = null
}
fun attachAndroidFilePickerContext(context: Context) {
AndroidContextHolder.context = context.applicationContext
}
private fun Context.displayName(uri: Uri): String {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
@@ -100,6 +71,3 @@ private fun Context.displayName(uri: Uri): String {
}
return uri.lastPathSegment ?: "transfer"
}
private fun <T : Any> errorIfNull(value: T?, message: String): T =
value ?: error(message)

View File

@@ -45,6 +45,23 @@ private class AndroidFileSystemService(
return AndroidTreeReceiveOutputSink(context, folder.value.toUri())
}
override suspend fun sharePickedFile(
repository: CoreGateway,
file: PickedShareFile,
transferName: String,
senderName: String,
): Result<Share> = runCatching {
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor ->
checkNotNull(descriptor) { "Could not open selected file descriptor" }
repository.shareFileDescriptor(
fd = descriptor.fd,
displayName = file.displayName,
transferName = transferName,
senderName = senderName,
).getOrThrow()
}
}
private fun validatePath(path: String): FolderAccessStatus =
runCatching {
val directory = java.io.File(path)

View File

@@ -0,0 +1,168 @@
package com.vnidrop.app.notifications
import android.annotation.SuppressLint
import android.Manifest
import android.app.NotificationChannel
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@Composable
fun rememberAndroidLocalNotificationService(activity: ComponentActivity): LocalNotificationService {
val holder = viewModel { AndroidNotificationServiceHolder(activity.applicationContext) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
holder.service.completePermissionRequest(granted)
}
SideEffect {
holder.service.attachPermissionLauncher {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
return holder.service
}
private class AndroidNotificationServiceHolder(context: Context) : ViewModel() {
val service = AndroidLocalNotificationService(context)
}
private class AndroidLocalNotificationService(
private val context: Context,
) : LocalNotificationService {
private val _permission = MutableStateFlow(currentPermission())
override val permission: StateFlow<NotificationPermission> = _permission.asStateFlow()
private var permissionContinuation: CancellableContinuation<NotificationPermission>? = null
private var launchPermissionRequest: (() -> Unit)? = null
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(ChannelId, "Connection requests", NotificationManager.IMPORTANCE_HIGH),
)
}
}
override suspend fun refreshPermission(): NotificationPermission = currentPermission().also { _permission.value = it }
override suspend fun requestPermission(): NotificationPermission {
val current = refreshPermission()
if (current != NotificationPermission.NotDetermined) return current
return suspendCancellableCoroutine { continuation ->
permissionContinuation?.cancel()
permissionContinuation = continuation
continuation.invokeOnCancellation { permissionContinuation = null }
val launcher = launchPermissionRequest
if (launcher == null) {
permissionContinuation = null
continuation.resume(NotificationPermission.Denied)
} else {
markPermissionRequested()
launcher()
}
}
}
override suspend fun openSettings(): Result<Unit> = runCatching {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
}
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
@SuppressLint("MissingPermission")
override suspend fun publish(notification: LocalNotification): Result<Unit> = runCatching {
check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" }
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
val pendingIntent = launchIntent?.let {
PendingIntent.getActivity(
context,
notification.id.hashCode(),
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(context, ChannelId)
} else {
@Suppress("DEPRECATION")
Notification.Builder(context)
}
val built = builder
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(notification.title)
.setContentText(notification.body)
.setStyle(Notification.BigTextStyle().bigText(notification.body))
.setAutoCancel(true)
.setPriority(Notification.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.build()
context.getSystemService(NotificationManager::class.java).notify(notification.id.hashCode(), built)
}
override suspend fun cancel(id: String) {
context.getSystemService(NotificationManager::class.java).cancel(id.hashCode())
}
override suspend fun cancelAll() {
context.getSystemService(NotificationManager::class.java).cancelAll()
}
fun completePermissionRequest(granted: Boolean) {
markPermissionRequested()
val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied
_permission.value = result
permissionContinuation?.takeIf { it.isActive }?.resume(result)
permissionContinuation = null
}
fun attachPermissionLauncher(launcher: () -> Unit) {
launchPermissionRequest = launcher
}
private fun currentPermission(): NotificationPermission {
val manager = context.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return if (manager.areNotificationsEnabled()) NotificationPermission.Granted else NotificationPermission.Denied
}
val granted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
return when {
granted && manager.areNotificationsEnabled() -> NotificationPermission.Granted
wasPermissionRequested() -> NotificationPermission.Denied
else -> NotificationPermission.NotDetermined
}
}
private fun markPermissionRequested() {
context.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE).edit().putBoolean(PermissionRequestedKey, true).apply()
}
private fun wasPermissionRequested(): Boolean =
context.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE).getBoolean(PermissionRequestedKey, false)
private companion object {
const val ChannelId = "vnidrop-connection-requests"
const val PreferencesName = "vnidrop-notifications"
const val PermissionRequestedKey = "permission-requested"
}
}