diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fe21232..fd8a58e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,8 @@ gradle.taskGraph.whenReady { dependencies { implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.permissions) implementation(libs.androidx.material3) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7212e08..447ebb0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,18 +42,16 @@ android:theme="@style/Theme.Transparent"> + android:launchMode="singleInstance"> - - - + + @@ -75,11 +73,6 @@ - - diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt new file mode 100644 index 0000000..62c6b2a --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt @@ -0,0 +1,305 @@ +package com.bintianqi.owndroid + +import android.app.Application +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage +import com.bintianqi.owndroid.ui.RadioButtonItem +import com.bintianqi.owndroid.ui.theme.OwnDroidTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.URLDecoder + +class AppInstallerActivity:ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + val myVm by viewModels() + val vm by viewModels() + vm.initialize(intent) + setContent { + OwnDroidTheme(myVm) { + val installing by vm.installing.collectAsStateWithLifecycle() + val sessionMode by vm.sessionMode.collectAsStateWithLifecycle() + val packages by vm.packages.collectAsStateWithLifecycle() + val writtenPackages by vm.writtenPackages.collectAsStateWithLifecycle() + val writingPackage by vm.writingPackage.collectAsStateWithLifecycle() + val result by vm.result.collectAsStateWithLifecycle() + AppInstaller( + installing, sessionMode, { vm.sessionMode.value = it }, + packages, { uri -> vm.packages.update { it.minus(uri) } }, + { uris -> vm.packages.update { it.plus(uris) } }, + vm::startInstallationProcess, writtenPackages, writingPackage, + result, { vm.result.value = null } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun AppInstaller( + installing: Boolean = false, + sessionMode: Int = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING, + onSessionModeChoose: (Int) -> Unit = {}, + packages: Set = setOf(Uri.parse("https://example.com")), + onPackageRemove: (Uri) -> Unit = {}, + onPackageChoose: (List) -> Unit = {}, + onFabPressed: () -> Unit = {}, + writtenPackages: Set = setOf(Uri.parse("https://example.com")), + writingPackage: Uri? = null, + result: Intent? = null, + onResultDialogClose: () -> Unit = {} +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_installer)) } + ) + }, + floatingActionButton = { + if(packages.isNotEmpty()) ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.start)) }, + icon = { + if(installing) CircularProgressIndicator(modifier = Modifier.size(24.dp)) + else Icon(Icons.Default.PlayArrow, null) + }, + onClick = onFabPressed, + expanded = !installing + ) + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + SessionMode(sessionMode, onSessionModeChoose) + Packages(installing, packages, onPackageRemove, onPackageChoose, writtenPackages, writingPackage) + ResultDialog(result, onResultDialogClose) + } + } +} + +@Composable +private fun SessionMode(mode: Int, onChoose: (Int) -> Unit) { + Text( + stringResource(R.string.mode), modifier = Modifier.padding(top = 10.dp, start = 8.dp), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary + ) + RadioButtonItem(R.string.full_install, mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL) { + onChoose(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + } + RadioButtonItem(R.string.inherit_existing, mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) { + onChoose(PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) + } +} + +@Composable +private fun Packages( + installing: Boolean, + packages: Set, onRemove: (Uri) -> Unit, onChoose: (List) -> Unit, + writtenPackages: Set, writingPackage: Uri? +) { + val chooseSplitPackage = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onChoose) + Text( + stringResource(R.string.packages), modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 4.dp), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary + ) + packages.forEach { + PackageItem( + it, installing, + { onRemove(it) }, it in writtenPackages, it == writingPackage + ) + } + if(!installing) Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { + chooseSplitPackage.launch(APK_MIME) + }.padding(vertical = 12.dp) + ) { + Icon(Icons.Default.Add, null, modifier = Modifier.padding(horizontal = 10.dp)) + Text(stringResource(R.string.add_packages), style = MaterialTheme.typography.titleMedium) + } +} + + +@Composable +private fun PackageItem(uri: Uri, installing: Boolean, onRemove: () -> Unit, isWritten: Boolean, isWriting: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 6.dp, bottom = 6.dp).heightIn(min = 40.dp) + ) { + Text( + URLDecoder.decode(URLDecoder.decode(uri.path ?: uri.toString())), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(0.85F) + ) + if(!installing) IconButton(onRemove) { + Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove)) + } + if(isWritten) Icon(Icons.Default.Check, null, Modifier.padding(end = 8.dp), MaterialTheme.colorScheme.secondary) + if(isWriting) CircularProgressIndicator(Modifier.padding(end = 8.dp).size(24.dp)) + } +} + +@Composable +private fun ResultDialog(result: Intent?, onDialogClose: () -> Unit) { + if(result != null) { + val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + AlertDialog( + title = { + val text = if(status == PackageInstaller.STATUS_SUCCESS) R.string.success else R.string.failure + Text(stringResource(text)) + }, + text = { + val context = LocalContext.current + Text(parsePackageInstallerMessage(context, result)) + }, + confirmButton = { + TextButton(onDialogClose) { + Text(stringResource(R.string.confirm)) + } + }, + onDismissRequest = onDialogClose + ) + } +} + +class AppInstallerViewModel(application: Application): AndroidViewModel(application) { + fun initialize(intent: Intent) { + intent.data?.let { uri -> packages.update { it + uri } } + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uri -> packages.update { it + uri } } + intent.getParcelableArrayExtra(Intent.EXTRA_STREAM)?.forEach { uri -> packages.update { it + (uri as Uri) } } + intent.clipData?.let { clipData -> + for(i in 0..(clipData.itemCount - 1)) { + packages.update { it + clipData.getItemAt(i).uri } + } + } + } + val installing = MutableStateFlow(false) + val result = MutableStateFlow(null) + val packages = MutableStateFlow(setOf()) + + val sessionMode = MutableStateFlow(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + + val writtenPackages = MutableStateFlow(setOf()) + val writingPackage = MutableStateFlow(null) + fun startInstallationProcess() { + if(installing.value) return + installing.value = true + viewModelScope.launch(Dispatchers.IO) { + val context = getApplication() + val packageInstaller = context.packageManager.packageInstaller + val sessionId = packageInstaller.createSession(PackageInstaller.SessionParams(sessionMode.value)) + val session = packageInstaller.openSession(sessionId) + try { + packages.value.forEach { splitPackageUri -> + withContext(Dispatchers.Main) { writingPackage.value = splitPackageUri } + session.openWrite(splitPackageUri.hashCode().toString(), 0, -1).use { splitPackageOut -> + context.contentResolver.openInputStream(splitPackageUri)!!.use { splitPackageIn -> + splitPackageIn.copyTo(splitPackageOut) + } + session.fsync(splitPackageOut) + } + withContext(Dispatchers.Main) { writtenPackages.update { it.plus(splitPackageUri) } } + } + withContext(Dispatchers.Main) { writingPackage.value = null } + } catch(e: Exception) { + e.printStackTrace() + session.abandon() + return@launch + } + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { + @SuppressWarnings("UnsafeIntentLaunch") + context.startActivity(intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) + } else { + result.value = intent + writtenPackages.value = setOf() + if(statusExtra == PackageInstaller.STATUS_SUCCESS) { + packages.value = setOf() + } + installing.value = false + context.unregisterReceiver(this) + } + } + } + ContextCompat.registerReceiver( + context, receiver, IntentFilter(ACTION), null, + null, ContextCompat.RECEIVER_EXPORTED + ) + val pi = if(Build.VERSION.SDK_INT >= 34) { + PendingIntent.getBroadcast( + context, sessionId, Intent(ACTION), + PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE + ).intentSender + } else { + PendingIntent.getBroadcast(context, sessionId, Intent(ACTION), PendingIntent.FLAG_MUTABLE).intentSender + } + session.commit(pi) + } + } + + override fun onCleared() { + super.onCleared() + viewModelScope.cancel() + } + companion object { + const val ACTION = "com.bintianqi.owndroid.action.PACKAGE_INSTALLER_SESSION_STATUS_CHANGED" + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/InstallAppActivity.kt b/app/src/main/java/com/bintianqi/owndroid/InstallAppActivity.kt deleted file mode 100644 index 86ecea4..0000000 --- a/app/src/main/java/com/bintianqi/owndroid/InstallAppActivity.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.bintianqi.owndroid - -import android.content.Intent -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import androidx.core.view.WindowCompat -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope -import com.bintianqi.owndroid.dpm.installPackage -import com.bintianqi.owndroid.ui.theme.OwnDroidTheme -import com.github.fishb1.apkinfo.ApkInfo -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.FileInputStream - -class InstallAppActivity: FragmentActivity() { - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - this.intent = intent - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - WindowCompat.setDecorFitsSystemWindows(window, false) - val context = applicationContext - window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - val uri = this.intent.data!! - var apkInfoText by mutableStateOf(context.getString(R.string.parsing_apk_info)) - var status by mutableStateOf("parsing") - this.lifecycleScope.launch(Dispatchers.IO) { - val fd = applicationContext.contentResolver.openFileDescriptor(uri, "r") - val apkInfo = ApkInfo.fromInputStream( - FileInputStream(fd?.fileDescriptor) - ) - fd?.close() - withContext(Dispatchers.Main) { - status = "pending" - apkInfoText = "${context.getString(R.string.package_name)}: ${apkInfo.packageName}\n" - apkInfoText += "${context.getString(R.string.version_name)}: ${apkInfo.versionName}\n" - apkInfoText += "${context.getString(R.string.version_code)}: ${apkInfo.versionCode}" - } - } - val vm by viewModels() - if(!vm.initialized) vm.initialize(applicationContext) - setContent { - OwnDroidTheme(vm) { - AlertDialog( - properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), - title = { - Text(stringResource(R.string.install_app)) - }, - onDismissRequest = { - if(status != "installing") finish() - }, - text = { - Column { - AnimatedVisibility(status != "pending") { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - Text(text = apkInfoText, modifier = Modifier.padding(top = 4.dp)) - } - }, - dismissButton = { - TextButton( - onClick = { finish() }, - enabled = status != "installing" - ) { - Text(stringResource(R.string.cancel)) - } - }, - confirmButton = { - TextButton( - onClick = { - status = "installing" - intent.data?.let { - uriToStream(applicationContext, it) { stream -> installPackage(applicationContext, stream) } - } - }, - enabled = status != "installing" - ) { - Text(stringResource(R.string.install)) - } - }, - modifier = Modifier.fillMaxWidth() - ) - } - val installDone by installAppDone.collectAsState() - LaunchedEffect(installDone) { - if(installDone) { - installAppDone.value = false - finish() - } - } - } - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 10b697d..36e6304 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -137,7 +137,6 @@ import com.bintianqi.owndroid.dpm.isDeviceAdmin import com.bintianqi.owndroid.dpm.isDeviceOwner import com.bintianqi.owndroid.dpm.isProfileOwner import com.bintianqi.owndroid.dpm.setDefaultAffiliationID -import com.bintianqi.owndroid.dpm.toggleInstallAppActivity import com.bintianqi.owndroid.ui.Animations import com.bintianqi.owndroid.ui.MyScaffold import com.bintianqi.owndroid.ui.theme.OwnDroidTheme @@ -160,9 +159,7 @@ class MainActivity : FragmentActivity() { if (VERSION.SDK_INT >= 28) HiddenApiBypass.setHiddenApiExemptions("") val locale = context.resources?.configuration?.locale zhCN = locale == Locale.SIMPLIFIED_CHINESE || locale == Locale.CHINESE || locale == Locale.CHINA - toggleInstallAppActivity() val vm by viewModels() - if(!vm.initialized) vm.initialize(context) lifecycleScope.launch { delay(5000); setDefaultAffiliationID(context) } setContent { OwnDroidTheme(vm) { @@ -462,8 +459,6 @@ private fun DhizukuErrorDialog() { val context = LocalContext.current val sharedPref = context.getSharedPreferences("data", Context.MODE_PRIVATE) LaunchedEffect(Unit) { - context.toggleInstallAppActivity() - delay(200) sharedPref.edit().putBoolean("dhizuku", false).apply() } AlertDialog( diff --git a/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt b/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt index c9e9f14..a67a4f3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt @@ -28,7 +28,6 @@ class ManageSpaceActivity: FragmentActivity() { val sharedPref = applicationContext.getSharedPreferences("data", MODE_PRIVATE) val authenticate = sharedPref.getBoolean("auth", false) val vm by viewModels() - if(!vm.initialized) vm.initialize(applicationContext) fun clearStorage() { filesDir.deleteRecursively() cacheDir.deleteRecursively() diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 8fbe61c..c5c93fb 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -1,22 +1,22 @@ package com.bintianqi.owndroid +import android.app.Application import android.content.Context import android.os.Build import android.os.Bundle -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -class MyViewModel: ViewModel() { +class MyViewModel(application: Application): AndroidViewModel(application) { val theme = MutableStateFlow(ThemeSettings()) val installedPackages = mutableListOf() val selectedPackage = MutableStateFlow("") val userRestrictions = MutableStateFlow(Bundle()) - var initialized = false - fun initialize(context: Context) { - val sharedPrefs = context.getSharedPreferences("data", Context.MODE_PRIVATE) + init { + val sharedPrefs = application.getSharedPreferences("data", Context.MODE_PRIVATE) theme.value = ThemeSettings( materialYou = sharedPrefs.getBoolean("material_you", Build.VERSION.SDK_INT >= 31), darkTheme = if(sharedPrefs.contains("dark_theme")) sharedPrefs.getBoolean("dark_theme", false) else null, diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index c0d8e4c..321e2d9 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -3,24 +3,11 @@ package com.bintianqi.owndroid import android.app.NotificationManager import android.app.PendingIntent import android.app.admin.DeviceAdminReceiver -import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.pm.PackageInstaller.EXTRA_STATUS -import android.content.pm.PackageInstaller.STATUS_FAILURE -import android.content.pm.PackageInstaller.STATUS_FAILURE_ABORTED -import android.content.pm.PackageInstaller.STATUS_FAILURE_BLOCKED -import android.content.pm.PackageInstaller.STATUS_FAILURE_CONFLICT -import android.content.pm.PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -import android.content.pm.PackageInstaller.STATUS_FAILURE_INVALID -import android.content.pm.PackageInstaller.STATUS_FAILURE_STORAGE -import android.content.pm.PackageInstaller.STATUS_FAILURE_TIMEOUT -import android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION -import android.content.pm.PackageInstaller.STATUS_SUCCESS import android.os.Build.VERSION import android.os.PersistableBundle -import android.util.Log import android.widget.Toast import androidx.core.app.NotificationCompat import com.bintianqi.owndroid.dpm.handleNetworkLogs @@ -28,10 +15,8 @@ import com.bintianqi.owndroid.dpm.isDeviceAdmin import com.bintianqi.owndroid.dpm.isDeviceOwner import com.bintianqi.owndroid.dpm.isProfileOwner import com.bintianqi.owndroid.dpm.processSecurityLogs -import com.bintianqi.owndroid.dpm.toggleInstallAppActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch class Receiver : DeviceAdminReceiver() { @@ -48,7 +33,6 @@ class Receiver : DeviceAdminReceiver() { override fun onEnabled(context: Context, intent: Intent) { super.onEnabled(context, intent) - context.toggleInstallAppActivity() if(context.isDeviceAdmin || context.isProfileOwner || context.isDeviceOwner){ Toast.makeText(context, context.getString(R.string.onEnabled), Toast.LENGTH_SHORT).show() } @@ -56,7 +40,6 @@ class Receiver : DeviceAdminReceiver() { override fun onDisabled(context: Context, intent: Intent) { super.onDisabled(context, intent) - context.toggleInstallAppActivity() Toast.makeText(context, R.string.onDisabled, Toast.LENGTH_SHORT).show() } @@ -93,7 +76,6 @@ class Receiver : DeviceAdminReceiver() { super.onTransferOwnershipComplete(context, bundle) val sp = context.getSharedPreferences("data", Context.MODE_PRIVATE) sp.edit().putBoolean("dhizuku", false).apply() - context.toggleInstallAppActivity() } override fun onLockTaskModeEntering(context: Context, intent: Intent, pkg: String) { @@ -116,29 +98,3 @@ class Receiver : DeviceAdminReceiver() { nm.cancel(1) } } - -val installAppDone = MutableStateFlow(false) - -class PackageInstallerReceiver: BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val toastText = when(intent.getIntExtra(EXTRA_STATUS, 999)){ - STATUS_PENDING_USER_ACTION -> R.string.status_pending_action - STATUS_SUCCESS -> R.string.success - STATUS_FAILURE -> R.string.failed - STATUS_FAILURE_BLOCKED -> R.string.status_fail_blocked - STATUS_FAILURE_ABORTED -> R.string.status_fail_aborted - STATUS_FAILURE_INVALID -> R.string.status_fail_invalid - STATUS_FAILURE_CONFLICT -> R.string.status_fail_conflict - STATUS_FAILURE_STORAGE -> R.string.status_fail_storage - STATUS_FAILURE_INCOMPATIBLE -> R.string.status_fail_incompatible - STATUS_FAILURE_TIMEOUT -> R.string.status_fail_timeout - else -> 999 - } - Log.e("OwnDroid", intent.getIntExtra(EXTRA_STATUS, 999).toString()) - installAppDone.value = true - if(toastText != 999){ - val text = context.getString(R.string.app_installer_status) + context.getString(toastText) - Toast.makeText(context, text, Toast.LENGTH_SHORT).show() - } - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index 9f0e7e5..3fd33c1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -31,9 +31,9 @@ fun uriToStream( operation: (stream: InputStream)->Unit ){ try { - val stream = context.contentResolver.openInputStream(uri) - if(stream != null) { operation(stream) } - stream?.close() + context.contentResolver.openInputStream(uri)?.use { + operation(it) + } } catch(_: FileNotFoundException) { Toast.makeText(context, R.string.file_not_exist, Toast.LENGTH_SHORT).show() } catch(_: IOException) { Toast.makeText(context, R.string.io_exception, Toast.LENGTH_SHORT).show() } @@ -91,3 +91,5 @@ fun Context.showOperationResultToast(success: Boolean) { fun getContext(): Context { return Class.forName("android.app.ActivityThread").getMethod("currentApplication").invoke(null) as Context } + +const val APK_MIME = "application/vnd.android.package-archive" diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt index 39e0c40..d0385b3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Applications.kt @@ -1,5 +1,6 @@ package com.bintianqi.owndroid.dpm +import android.app.AlertDialog import android.app.PendingIntent import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.PERMISSION_GRANT_STATE_DEFAULT @@ -9,8 +10,11 @@ import android.app.admin.PackagePolicy import android.app.admin.PackagePolicy.PACKAGE_POLICY_ALLOWLIST import android.app.admin.PackagePolicy.PACKAGE_POLICY_ALLOWLIST_AND_SYSTEM import android.app.admin.PackagePolicy.PACKAGE_POLICY_BLOCKLIST +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller import android.content.pm.PackageManager.NameNotFoundException import android.net.Uri import android.os.Build.VERSION @@ -73,15 +77,17 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.startActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.bintianqi.owndroid.InstallAppActivity +import com.bintianqi.owndroid.APK_MIME +import com.bintianqi.owndroid.AppInstallerActivity +import com.bintianqi.owndroid.AppInstallerViewModel import com.bintianqi.owndroid.MyViewModel -import com.bintianqi.owndroid.PackageInstallerReceiver import com.bintianqi.owndroid.R import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.Animations @@ -153,7 +159,6 @@ fun ApplicationManage(navCtrl:NavHostController, vm: MyViewModel) { composable(route = "Accessibility") { PermittedAccessibility(pkgName) } composable(route = "IME") { PermittedIME(pkgName) } composable(route = "KeepUninstalled") { KeepUninstalledApp(pkgName) } - composable(route = "InstallApp") { InstallApp() } composable(route = "UninstallApp") { UninstallApp(pkgName) } } } @@ -253,7 +258,14 @@ private fun Home(navCtrl:NavHostController, pkgName: String) { if(pkgName != "") dialogStatus = 2 } } - FunctionItem(title = R.string.install_app, icon = R.drawable.install_mobile_fill0) { navCtrl.navigate("InstallApp") } + val chooseApks = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { + val intent = Intent(context, AppInstallerActivity::class.java) + intent.putExtra(Intent.EXTRA_STREAM, it.toTypedArray()) + startActivity(context, intent, null) + } + FunctionItem(title = R.string.install_app, icon = R.drawable.install_mobile_fill0) { + chooseApks.launch(APK_MIME) + } FunctionItem(title = R.string.uninstall_app, icon = R.drawable.delete_fill0) { navCtrl.navigate("UninstallApp") } if(VERSION.SDK_INT >= 34 && (deviceOwner || dpm.isOrgProfile(receiver))) { FunctionItem(title = R.string.set_default_dialer, icon = R.drawable.call_fill0) { @@ -876,10 +888,39 @@ private fun UninstallApp(pkgName: String) { Column(modifier = Modifier.fillMaxWidth()) { Button( onClick = { - val intent = Intent(context, PackageInstallerReceiver::class.java) - val intentSender = PendingIntent.getBroadcast(context, 8, intent, PendingIntent.FLAG_IMMUTABLE).intentSender - val pkgInstaller = context.getPI() - pkgInstaller.uninstall(pkgName, intentSender) + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val statusExtra = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + if(statusExtra == PackageInstaller.STATUS_PENDING_USER_ACTION) { + @SuppressWarnings("UnsafeIntentLaunch") + context.startActivity(intent.getParcelableExtra(Intent.EXTRA_INTENT) as Intent?) + } else { + context.unregisterReceiver(this) + if(statusExtra == PackageInstaller.STATUS_SUCCESS) { + context.showOperationResultToast(true) + } else { + AlertDialog.Builder(context) + .setTitle(R.string.failure) + .setMessage(parsePackageInstallerMessage(context, intent)) + .setPositiveButton(R.string.confirm) { dialog, _ -> dialog.dismiss() } + .show() + } + } + } + } + ContextCompat.registerReceiver( + context, receiver, IntentFilter(AppInstallerViewModel.ACTION), null, + null, ContextCompat.RECEIVER_EXPORTED + ) + val pi = if(VERSION.SDK_INT >= 34) { + PendingIntent.getBroadcast( + context, 0, Intent(AppInstallerViewModel.ACTION), + PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE + ).intentSender + } else { + PendingIntent.getBroadcast(context, 0, Intent(AppInstallerViewModel.ACTION), PendingIntent.FLAG_MUTABLE).intentSender + } + context.getPackageInstaller().uninstall(pkgName, pi) }, enabled = pkgName != "", modifier = Modifier.fillMaxWidth() @@ -901,57 +942,3 @@ private fun UninstallApp(pkgName: String) { } } -@Composable -private fun InstallApp() { - val context = LocalContext.current - val focusMgr = LocalFocusManager.current - val sharedPrefs = context.getSharedPreferences("data", Context.MODE_PRIVATE) - var apkFileUri by remember { mutableStateOf(null) } - val getFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - result.data.also { if(it != null) apkFileUri = it.data } - } - Column(modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp).verticalScroll(rememberScrollState())) { - Spacer(Modifier.padding(vertical = 10.dp)) - Text(text = stringResource(R.string.install_app), style = typography.headlineLarge) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - focusMgr.clearFocus() - val installApkIntent = Intent(Intent.ACTION_GET_CONTENT) - installApkIntent.setType("application/vnd.android.package-archive") - installApkIntent.addCategory(Intent.CATEGORY_OPENABLE) - getFileLauncher.launch(installApkIntent) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.select_apk)) - } - AnimatedVisibility(apkFileUri != null) { - Spacer(Modifier.padding(vertical = 3.dp)) - Column(modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { - val intent = Intent(context, InstallAppActivity::class.java) - intent.data = apkFileUri - context.startActivity(intent) - }, - enabled = !sharedPrefs.getBoolean("dhizuku", false) && context.isDeviceOwner, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.silent_install)) - } - Button( - onClick = { - val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) - intent.setData(apkFileUri) - intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - context.startActivity(intent) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.request_install)) - } - } - } - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index 87934a9..9206013 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -2,7 +2,6 @@ package com.bintianqi.owndroid.dpm import android.Manifest import android.annotation.SuppressLint -import android.app.PendingIntent import android.app.admin.ConnectEvent import android.app.admin.DevicePolicyManager import android.app.admin.DnsEvent @@ -15,7 +14,6 @@ import android.content.Context import android.content.Intent import android.content.pm.IPackageInstaller import android.content.pm.PackageInstaller -import android.content.pm.PackageManager import android.os.Build.VERSION import android.os.UserManager import android.util.Log @@ -23,8 +21,6 @@ import androidx.activity.result.ActivityResultLauncher import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi import androidx.annotation.StringRes -import com.bintianqi.owndroid.InstallAppActivity -import com.bintianqi.owndroid.PackageInstallerReceiver import com.bintianqi.owndroid.R import com.bintianqi.owndroid.Receiver import com.bintianqi.owndroid.backToHomeStateFlow @@ -40,8 +36,6 @@ import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray -import java.io.IOException -import java.io.InputStream import java.io.OutputStream lateinit var addDeviceAdmin: ActivityResultLauncher @@ -84,24 +78,6 @@ fun DevicePolicyManager.isOrgProfile(receiver: ComponentName): Boolean { return VERSION.SDK_INT >= 30 && this.isProfileOwnerApp("com.bintianqi.owndroid") && isManagedProfile(receiver) && isOrganizationOwnedDeviceWithManagedProfile } -@Throws(IOException::class) -fun installPackage(context: Context, inputStream: InputStream) { - val packageInstaller = context.packageManager.packageInstaller - val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) - val sessionId = packageInstaller.createSession(params) - val session = packageInstaller.openSession(sessionId) - val out = session.openWrite("COSU", 0, -1) - val buffer = ByteArray(65536) - var c: Int - while(inputStream.read(buffer).also{c = it}!=-1) { out.write(buffer, 0, c) } - session.fsync(out) - inputStream.close() - out.close() - val intent = Intent(context, PackageInstallerReceiver::class.java) - val pendingIntent = PendingIntent.getBroadcast(context, sessionId, intent, PendingIntent.FLAG_IMMUTABLE).intentSender - session.commit(pendingIntent) -} - @SuppressLint("PrivateApi") private fun binderWrapperDevicePolicyManager(appContext: Context): DevicePolicyManager? { try { @@ -142,7 +118,7 @@ private fun binderWrapperPackageInstaller(appContext: Context): PackageInstaller return null } -fun Context.getPI(): PackageInstaller { +fun Context.getPackageInstaller(): PackageInstaller { val sharedPref = this.getSharedPreferences("data", Context.MODE_PRIVATE) if(sharedPref.getBoolean("dhizuku", false)) { if (!dhizukuPermissionGranted()) { @@ -246,16 +222,6 @@ fun Context.resetDevicePolicy() { dpm.setRecommendedGlobalProxy(receiver, null) } -fun Context.toggleInstallAppActivity() { - val sharedPrefs = getSharedPreferences("data", Context.MODE_PRIVATE) - val disable = sharedPrefs.getBoolean("dhizuku", false) || !isDeviceOwner - packageManager?.setComponentEnabledSetting( - ComponentName(this, InstallAppActivity::class.java), - if (disable) PackageManager.COMPONENT_ENABLED_STATE_DISABLED else PackageManager.COMPONENT_ENABLED_STATE_ENABLED, - PackageManager.DONT_KILL_APP - ) -} - data class PermissionItem( val permission: String, @StringRes val label: Int, @@ -608,3 +574,29 @@ fun dhizukuPermissionGranted() = false } +fun parsePackageInstallerMessage(context: Context, result: Intent): String { + val status = result.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) + val statusMessage = result.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + val otherPackageName = result.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME) + return when(status) { + PackageInstaller.STATUS_FAILURE_BLOCKED -> + context.getString( + R.string.status_failure_blocked, + otherPackageName ?: context.getString(R.string.unknown) + ) + PackageInstaller.STATUS_FAILURE_ABORTED -> + context.getString(R.string.status_failure_aborted) + PackageInstaller.STATUS_FAILURE_INVALID -> + context.getString(R.string.status_failure_invalid) + PackageInstaller.STATUS_FAILURE_CONFLICT -> + context.getString(R.string.status_failure_conflict, otherPackageName ?: "???") + PackageInstaller.STATUS_FAILURE_STORAGE -> + context.getString(R.string.status_failure_storage) + + result.getStringExtra(PackageInstaller.EXTRA_STORAGE_PATH).let { if(it == null) "" else "\n$it" } + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> + context.getString(R.string.status_failure_incompatible) + PackageInstaller.STATUS_FAILURE_TIMEOUT -> + context.getString(R.string.timeout) + else -> "" + } + statusMessage.let { if(it == null) "" else "\n$it" } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt index 8a20be4..ecd7c5f 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Permissions.kt @@ -256,7 +256,6 @@ private fun toggleDhizukuMode(status: Boolean, context: Context) { if(grantResult == PackageManager.PERMISSION_GRANTED) { sharedPref.edit().putBoolean("dhizuku", true).apply() Dhizuku.init(context) - context.toggleInstallAppActivity() backToHomeStateFlow.value = true } else { dhizukuErrorStatus.value = 2 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f059ae7..e1b23d6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -417,21 +417,6 @@ Тихое удаление Запросить удаление Установить приложение - Выберите APK... - Тихая установка - Запросить установку - Поиск - - Установщик приложений: - Ожидание действия пользователя - Ошибка: заблокировано - Ошибка: прервано - Ошибка: неверный APK - Ошибка: конфликт - Ошибка: недостаточно места - Ошибка: несовместимо - Ошибка: истекло время ожидания - Ограничения пользователя @@ -668,9 +653,19 @@ Доступ к датчикам тела в фоновом режиме Распознавание активности - Версия - Код версии - Анализ информации APK... + + App installer + Mode + Full install + Inherit existing + Packages + Add package(s) + The operation was blocked by: %1$s + The operation was aborted. + The operation failed because one or more of the APKs was invalid. For example, they might be malformed, corrupt, incorrectly signed, mismatched, etc. + The operation failed because it conflicts with %1$s. For example, an existing permission, incompatible certificates, etc. You can uninstall %1$s to fix the issue. + The operation failed because of storage issues. For example, the device may be running low on space, or external media may be unavailable. You may try to help free space or insert different external media. + The operation failed because it is fundamentally incompatible with this device. For example, the app may require a hardware feature that doesn\'t exist, it may be missing native code for the ABIs supported by the device, or it requires a newer SDK version, etc. \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index eea53f8..be39434 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -417,22 +417,8 @@ Sessiz kaldırma Kaldırma isteği Uygulamayı yükle - APK seç... - Sessiz yükleme - Yükleme isteği Search - - Uygulama yükleyici: - Bekleyen kullanıcı işlemi - Başarısız: engellendi - Başarısız: iptal edildi - Başarısız: geçersiz APK - Başarısız: çakışma - Başarısız: boş alan yok - Başarısız: uyumsuz - Başarısız: zaman aşımı - Kullanıcı kısıtlaması Profil sahibi sınırlı işlev kullanabilir @@ -664,9 +650,19 @@ Arka planda vücut sensörlerine eriş Aktivite tanıma - Version name - Version code - Parsing APK info... + + App installer + Mode + Full install + Inherit existing + Packages + Add package(s) + The operation was blocked by: %1$s + The operation was aborted. + The operation failed because one or more of the APKs was invalid. For example, they might be malformed, corrupt, incorrectly signed, mismatched, etc. + The operation failed because it conflicts with %1$s. For example, an existing permission, incompatible certificates, etc. You can uninstall %1$s to fix the issue. + The operation failed because of storage issues. For example, the device may be running low on space, or external media may be unavailable. You may try to help free space or insert different external media. + The operation failed because it is fundamentally incompatible with this device. For example, the app may require a hardware feature that doesn\'t exist, it may be missing native code for the ABIs supported by the device, or it requires a newer SDK version, etc. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 83e5e91..31e517c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -402,22 +402,9 @@ 静默卸载 请求卸载 安装应用 - 选择APK... - 静默安装 - 请求安装 启用系统应用 重新启用一个默认被禁用的系统应用 搜索 - - 应用安装器: - 等待用户操作 - 被阻止 - 被打断 - 无效APK - 冲突 - 空间不足 - 不兼容 - 超时 用户限制 @@ -649,9 +636,18 @@ 后台使用身体传感器 查看使用情况 - 版本名 - 版本号 - 解析APK信息中... + App安装器 + 模式 + 完整安装 + 继承已有 + 软件包 + 添加包 + 操作被 %1$s 阻止。 + 操作被打断。 + 操作失败,因为有一个或多个APK无效。例如,它们可能被篡改,损坏,签名错误,不匹配等。 + 操作失败,因为它与 %1$s 冲突。例如,存在的权限,不完整的证书等。你可以卸载 %1$s 以解决这个问题。 + 由于存储问题,操作失败。例如,设备空间不足,或外部媒体不可用。你可以尝试清理空间或插入不同的外部媒体。 + 操作失败,因为它与设备不兼容。例如,这个app可能需要不存在的硬件功能,它有可能缺少受此设备支持的ABI的本地代码,或它需要高于此设备的SDK版本。 Dhizuku可以分享Device owner权限给其余应用 指示设备是否除了密钥证明之外还支持设备标识符证明 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba3902e..6f402bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ Disable Enable Success + Failure Failed Add Remove @@ -71,6 +72,7 @@ Overview Features Default + Timeout Click to activate @@ -441,20 +443,7 @@ Silent uninstall Request uninstall Install app - Select APK... - Silent install - Request install Search - - App installer: - Pending user action - Fail: blocked - Fail: aborted - Fail: invalid APK - Fail: conflict - Fail: no space - Fail: incompatible - Fail: timeout User restriction @@ -687,9 +676,18 @@ Access body sensors in background Activity recognition - Version name - Version code - Parsing APK info... + App installer + Mode + Full install + Inherit existing + Packages + Add package(s) + The operation was blocked by: %1$s + The operation was aborted. + The operation failed because one or more of the APKs was invalid. For example, they might be malformed, corrupt, incorrectly signed, mismatched, etc. + The operation failed because it conflicts with %1$s. For example, an existing permission, incompatible certificates, etc. You can uninstall %1$s to fix the issue. + The operation failed because of storage issues. For example, the device may be running low on space, or external media may be unavailable. You may try to help free space or insert different external media. + The operation failed because it is fundamentally incompatible with this device. For example, the app may require a hardware feature that doesn\'t exist, it may be missing native code for the ABIs supported by the device, or it requires a newer SDK version, etc. Dhizuku is a tool that can share Device owner permissions to other application. Indicates if the device supports attestation of device identifiers in addition to key attestation. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13626cb..f1304aa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,8 +2,8 @@ agp = "8.8.0" kotlin = "2.0.21" -navigation-compose = "2.8.5" -composeBom = "2025.01.00" +navigation-compose = "2.8.6" +composeBom = "2025.01.01" accompanist-drawablepainter = "0.35.0-alpha" accompanist-permissions = "0.37.0" shizuku = "13.1.5" @@ -15,6 +15,8 @@ serialization = "1.7.3" [libraries] androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-activity-compose = { module = "androidx.activity:activity-compose" } androidx-material3 = { module = "androidx.compose.material3:material3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }