mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 18:39:55 +02:00
refactor(ui): establish modular Compose foundation
This commit is contained in:
5
shared/src/androidMain/AndroidManifest.xml
Normal file
5
shared/src/androidMain/AndroidManifest.xml
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user