mirror of
https://github.com/awfixers-stuff/OwnDroid.git
synced 2026-03-23 19:15:58 +00:00
Split UI and ViewModel
This commit is contained in:
@@ -1,85 +1,15 @@
|
|||||||
package com.bintianqi.owndroid
|
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.PackageInfo
|
|
||||||
import android.content.pm.PackageInstaller
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
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.foundation.pager.HorizontalPager
|
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
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.Tab
|
|
||||||
import androidx.compose.material3.TabRow
|
|
||||||
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.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
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.core.net.toUri
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import com.bintianqi.owndroid.ui.AppInstaller
|
||||||
import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage
|
|
||||||
import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem
|
|
||||||
import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
|
|
||||||
import com.bintianqi.owndroid.ui.theme.OwnDroidTheme
|
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:FragmentActivity() {
|
class AppInstallerActivity:FragmentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -91,311 +21,11 @@ class AppInstallerActivity:FragmentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
val theme by myVm.theme.collectAsStateWithLifecycle()
|
val theme by myVm.theme.collectAsStateWithLifecycle()
|
||||||
OwnDroidTheme(theme) {
|
OwnDroidTheme(theme) {
|
||||||
val installing by vm.installing.collectAsStateWithLifecycle()
|
val uiState by vm.uiState.collectAsState()
|
||||||
val options by vm.options.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(
|
AppInstaller(
|
||||||
installing, options, { if(!installing) vm.options.value = it },
|
uiState, vm::onPackagesAdd, vm::onPackageRemove, vm::startInstall, vm::closeResultDialog
|
||||||
packages, { uri -> vm.packages.update { it.minus(uri) } },
|
|
||||||
{ uris -> vm.packages.update { it.plus(uris) } },
|
|
||||||
vm::startInstall, writtenPackages, writingPackage,
|
|
||||||
result, { vm.result.value = null }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun AppInstaller(
|
|
||||||
installing: Boolean = false,
|
|
||||||
options: SessionParamsOptions = SessionParamsOptions(),
|
|
||||||
onOptionsChange: (SessionParamsOptions) -> Unit = {},
|
|
||||||
packages: Set<Uri> = setOf("https://example.com".toUri()),
|
|
||||||
onPackageRemove: (Uri) -> Unit = {},
|
|
||||||
onPackageChoose: (List<Uri>) -> Unit = {},
|
|
||||||
onStartInstall: () -> Unit = {},
|
|
||||||
writtenPackages: Set<Uri> = setOf("https://example.com".toUri()),
|
|
||||||
writingPackage: Uri? = null,
|
|
||||||
result: Intent? = null,
|
|
||||||
onResultDialogClose: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
var appLockDialog by rememberSaveable { mutableStateOf(false) }
|
|
||||||
val coroutine = rememberCoroutineScope()
|
|
||||||
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 = {
|
|
||||||
if(SP.lockPasswordHash.isNullOrEmpty()) onStartInstall() else appLockDialog = true
|
|
||||||
},
|
|
||||||
expanded = !installing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
var tab by remember { mutableIntStateOf(0) }
|
|
||||||
val pagerState = rememberPagerState { 2 }
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
tab = pagerState.targetPage
|
|
||||||
Column(modifier = Modifier.padding(paddingValues)) {
|
|
||||||
TabRow(tab) {
|
|
||||||
Tab(
|
|
||||||
tab == 0,
|
|
||||||
onClick = {
|
|
||||||
coroutine.launch { scrollState.animateScrollTo(0) }
|
|
||||||
coroutine.launch { pagerState.animateScrollToPage(0) }
|
|
||||||
},
|
|
||||||
text = { Text(stringResource(R.string.packages)) }
|
|
||||||
)
|
|
||||||
Tab(
|
|
||||||
tab == 1,
|
|
||||||
onClick = {
|
|
||||||
coroutine.launch { scrollState.animateScrollTo(0) }
|
|
||||||
coroutine.launch { pagerState.animateScrollToPage(1) }
|
|
||||||
},
|
|
||||||
text = { Text(stringResource(R.string.options)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
HorizontalPager(pagerState) { page ->
|
|
||||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(top = 8.dp)) {
|
|
||||||
if(page == 0) Packages(installing, packages, onPackageRemove, onPackageChoose, writtenPackages, writingPackage)
|
|
||||||
else Options(options, onOptionsChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResultDialog(result, onResultDialogClose)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(appLockDialog) {
|
|
||||||
AppLockDialog({
|
|
||||||
appLockDialog = false
|
|
||||||
onStartInstall()
|
|
||||||
}) { appLockDialog = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ColumnScope.Packages(
|
|
||||||
installing: Boolean,
|
|
||||||
packages: Set<Uri>, onRemove: (Uri) -> Unit, onChoose: (List<Uri>) -> Unit,
|
|
||||||
writtenPackages: Set<Uri>, writingPackage: Uri?
|
|
||||||
) {
|
|
||||||
val chooseSplitPackage = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onChoose)
|
|
||||||
packages.forEach {
|
|
||||||
PackageItem(
|
|
||||||
it, installing,
|
|
||||||
{ onRemove(it) }, it in writtenPackages, it == writingPackage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AnimatedVisibility(!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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SessionParamsOptions(
|
|
||||||
val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
|
||||||
val keepOriginalEnabledSetting: Boolean = false,
|
|
||||||
val noKill: Boolean = false,
|
|
||||||
val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ColumnScope.Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.mode), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp),
|
|
||||||
style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
FullWidthRadioButtonItem(R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL) {
|
|
||||||
onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false))
|
|
||||||
}
|
|
||||||
FullWidthRadioButtonItem(R.string.inherit_existing, options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) {
|
|
||||||
onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING))
|
|
||||||
}
|
|
||||||
if(Build.VERSION.SDK_INT >= 34) {
|
|
||||||
AnimatedVisibility(options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) {
|
|
||||||
FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) {
|
|
||||||
onChange(options.copy(noKill = it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FullWidthCheckBoxItem(R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting) {
|
|
||||||
onChange(options.copy(keepOriginalEnabledSetting = it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.install_location), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp),
|
|
||||||
style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
FullWidthRadioButtonItem(R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO) {
|
|
||||||
onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO))
|
|
||||||
}
|
|
||||||
FullWidthRadioButtonItem(R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY) {
|
|
||||||
onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY))
|
|
||||||
}
|
|
||||||
FullWidthRadioButtonItem(R.string.prefer_external, options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL) {
|
|
||||||
onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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<Uri>(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) {
|
|
||||||
packages.update { it + clipData.getItemAt(i).uri }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val installing = MutableStateFlow(false)
|
|
||||||
val result = MutableStateFlow<Intent?>(null)
|
|
||||||
val packages = MutableStateFlow(setOf<Uri>())
|
|
||||||
|
|
||||||
val options = MutableStateFlow(SessionParamsOptions())
|
|
||||||
|
|
||||||
val writtenPackages = MutableStateFlow(setOf<Uri>())
|
|
||||||
val writingPackage = MutableStateFlow<Uri?>(null)
|
|
||||||
private fun getSessionParams(): PackageInstaller.SessionParams {
|
|
||||||
return PackageInstaller.SessionParams(options.value.mode).apply {
|
|
||||||
if(Build.VERSION.SDK_INT >= 34) {
|
|
||||||
if(options.value.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent()
|
|
||||||
setDontKillApp(options.value.noKill)
|
|
||||||
}
|
|
||||||
setInstallLocation(options.value.location)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun startInstall() {
|
|
||||||
if(installing.value) return
|
|
||||||
installing.value = true
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
val context = getApplication<Application>()
|
|
||||||
val packageInstaller = context.packageManager.packageInstaller
|
|
||||||
val sessionId = packageInstaller.createSession(getSessionParams())
|
|
||||||
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?)
|
|
||||||
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
)
|
|
||||||
} 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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.PackageInfo
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
class AppInstallerViewModel(application: Application): AndroidViewModel(application) {
|
||||||
|
val uiState = MutableStateFlow(UiState())
|
||||||
|
data class UiState(
|
||||||
|
val packages: List<Uri> = emptyList(),
|
||||||
|
val installing: Boolean = false,
|
||||||
|
val packageWriting: Int = -1,
|
||||||
|
val result: Intent? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
fun initialize(intent: Intent) {
|
||||||
|
val list = mutableListOf<Uri>()
|
||||||
|
intent.data?.let { list += it }
|
||||||
|
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { list += it }
|
||||||
|
intent.getParcelableArrayExtra(Intent.EXTRA_STREAM)?.forEach { list += it as Uri }
|
||||||
|
intent.clipData?.let { clipData ->
|
||||||
|
for(i in 0..clipData.itemCount - 1) {
|
||||||
|
list += clipData.getItemAt(i).uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uiState.update { it.copy(it.packages + list.distinct()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPackagesAdd(packages: List<Uri>) {
|
||||||
|
uiState.update {
|
||||||
|
it.copy(packages = it.packages.plus(packages).distinct())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPackageRemove(uri: Uri) {
|
||||||
|
uiState.update {
|
||||||
|
it.copy(packages = it.packages.minus(uri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSessionParams(options: SessionParamsOptions): PackageInstaller.SessionParams {
|
||||||
|
return PackageInstaller.SessionParams(options.mode).apply {
|
||||||
|
if(Build.VERSION.SDK_INT >= 34) {
|
||||||
|
if(options.keepOriginalEnabledSetting) setApplicationEnabledSettingPersistent()
|
||||||
|
setDontKillApp(options.noKill)
|
||||||
|
}
|
||||||
|
setInstallLocation(options.location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startInstall(options: SessionParamsOptions) {
|
||||||
|
if (uiState.value.installing) return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
installPackages(options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installPackages(options: SessionParamsOptions) {
|
||||||
|
val packageInstaller = application.packageManager.packageInstaller
|
||||||
|
val sessionId = packageInstaller.createSession(getSessionParams(options))
|
||||||
|
val session = packageInstaller.openSession(sessionId)
|
||||||
|
try {
|
||||||
|
uiState.update { it.copy(packageWriting = 0) }
|
||||||
|
uiState.value.packages.forEach { uri ->
|
||||||
|
session.openWrite(uri.hashCode().toString(), 0, -1).use { splitPackageOut ->
|
||||||
|
application.contentResolver.openInputStream(uri)!!.use { splitPackageIn ->
|
||||||
|
splitPackageIn.copyTo(splitPackageOut)
|
||||||
|
}
|
||||||
|
session.fsync(splitPackageOut)
|
||||||
|
}
|
||||||
|
uiState.update { it.copy(packageWriting = it.packageWriting + 1) }
|
||||||
|
}
|
||||||
|
} catch(e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
session.abandon()
|
||||||
|
uiState.update { it.copy(installing = false, packageWriting = -1) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
application, Receiver(), IntentFilter(ACTION), null,
|
||||||
|
null, ContextCompat.RECEIVER_EXPORTED
|
||||||
|
)
|
||||||
|
val pi = if(Build.VERSION.SDK_INT >= 34) {
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
application, sessionId, Intent(ACTION),
|
||||||
|
PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT or PendingIntent.FLAG_MUTABLE
|
||||||
|
).intentSender
|
||||||
|
} else {
|
||||||
|
PendingIntent.getBroadcast(application, sessionId, Intent(ACTION), PendingIntent.FLAG_MUTABLE).intentSender
|
||||||
|
}
|
||||||
|
session.commit(pi)
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class Receiver() : 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?)
|
||||||
|
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
uiState.update { it.copy(result = intent) }
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeResultDialog() {
|
||||||
|
if (uiState.value.result?.getIntExtra(PackageInstaller.EXTRA_STATUS, 999) == PackageInstaller.STATUS_SUCCESS) {
|
||||||
|
uiState.update { it.copy(emptyList(), packageWriting = -1, result = null) }
|
||||||
|
} else {
|
||||||
|
uiState.update { it.copy(packageWriting = -1, result = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
companion object {
|
||||||
|
const val ACTION = "com.bintianqi.owndroid.action.PACKAGE_INSTALLER_SESSION_STATUS_CHANGED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionParamsOptions(
|
||||||
|
val mode: Int = PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
||||||
|
val keepOriginalEnabledSetting: Boolean = false,
|
||||||
|
val noKill: Boolean = false,
|
||||||
|
val location: Int = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY,
|
||||||
|
)
|
||||||
@@ -13,10 +13,12 @@ import android.widget.Toast
|
|||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.SaverScope
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -151,3 +153,12 @@ fun Context.popToast(resId: Int) {
|
|||||||
fun Context.popToast(str: String) {
|
fun Context.popToast(str: String) {
|
||||||
Toast.makeText(this, str, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, str, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SerializableSaver<T>(val serializer: KSerializer<T>) : Saver<T, String> {
|
||||||
|
override fun restore(value: String): T? {
|
||||||
|
return Json.decodeFromString(serializer, value)
|
||||||
|
}
|
||||||
|
override fun SaverScope.save(value: T): String? {
|
||||||
|
return Json.encodeToString(serializer, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
258
app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt
Normal file
258
app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package com.bintianqi.owndroid.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
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.fillMaxHeight
|
||||||
|
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.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
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.Tab
|
||||||
|
import androidx.compose.material3.TabRow
|
||||||
|
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.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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 com.bintianqi.owndroid.APK_MIME
|
||||||
|
import com.bintianqi.owndroid.AppInstallerViewModel
|
||||||
|
import com.bintianqi.owndroid.AppLockDialog
|
||||||
|
import com.bintianqi.owndroid.R
|
||||||
|
import com.bintianqi.owndroid.SP
|
||||||
|
import com.bintianqi.owndroid.SerializableSaver
|
||||||
|
import com.bintianqi.owndroid.SessionParamsOptions
|
||||||
|
import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.URLDecoder
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AppInstaller(
|
||||||
|
uiState: AppInstallerViewModel.UiState = AppInstallerViewModel.UiState(),
|
||||||
|
onPackagesAdd: (List<Uri>) -> Unit = {},
|
||||||
|
onPackageRemove: (Uri) -> Unit = {},
|
||||||
|
onStartInstall: (SessionParamsOptions) -> Unit = {},
|
||||||
|
onResultDialogClose: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
var appLockDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var options by rememberSaveable(stateSaver = SerializableSaver(SessionParamsOptions.serializer())) {
|
||||||
|
mutableStateOf(SessionParamsOptions())
|
||||||
|
}
|
||||||
|
val coroutine = rememberCoroutineScope()
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.app_installer)) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if(uiState.packages.isNotEmpty()) ExtendedFloatingActionButton(
|
||||||
|
text = { Text(stringResource(R.string.start)) },
|
||||||
|
icon = {
|
||||||
|
if(uiState.installing) CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
else Icon(Icons.Default.PlayArrow, null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if(SP.lockPasswordHash.isNullOrEmpty()) onStartInstall(options) else appLockDialog = true
|
||||||
|
},
|
||||||
|
expanded = !uiState.installing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
var tab by remember { mutableIntStateOf(0) }
|
||||||
|
val pagerState = rememberPagerState { 2 }
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
tab = pagerState.targetPage
|
||||||
|
Column(modifier = Modifier.padding(paddingValues)) {
|
||||||
|
TabRow(tab) {
|
||||||
|
Tab(
|
||||||
|
tab == 0,
|
||||||
|
onClick = {
|
||||||
|
coroutine.launch { scrollState.animateScrollTo(0) }
|
||||||
|
coroutine.launch { pagerState.animateScrollToPage(0) }
|
||||||
|
},
|
||||||
|
text = { Text(stringResource(R.string.packages)) }
|
||||||
|
)
|
||||||
|
Tab(
|
||||||
|
tab == 1,
|
||||||
|
onClick = {
|
||||||
|
coroutine.launch { scrollState.animateScrollTo(0) }
|
||||||
|
coroutine.launch { pagerState.animateScrollToPage(1) }
|
||||||
|
},
|
||||||
|
text = { Text(stringResource(R.string.options)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalPager(pagerState, Modifier.fillMaxHeight(), verticalAlignment = Alignment.Top) { page ->
|
||||||
|
if (page == 0) Packages(uiState, onPackageRemove, onPackagesAdd)
|
||||||
|
else Options(options) { options = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResultDialog(uiState.result, onResultDialogClose)
|
||||||
|
}
|
||||||
|
if(appLockDialog) {
|
||||||
|
AppLockDialog({
|
||||||
|
appLockDialog = false
|
||||||
|
onStartInstall(options)
|
||||||
|
}) { appLockDialog = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Packages(
|
||||||
|
uiState: AppInstallerViewModel.UiState, onRemove: (Uri) -> Unit, onAdd: (List<Uri>) -> Unit
|
||||||
|
) {
|
||||||
|
val chooseSplitPackage = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents(), onAdd)
|
||||||
|
LazyColumn(Modifier.padding(top = 8.dp)) {
|
||||||
|
itemsIndexed(uiState.packages, { _, it -> it }) { i, it ->
|
||||||
|
val status = when {
|
||||||
|
uiState.packageWriting < 0 -> 0
|
||||||
|
i < uiState.packageWriting -> 3
|
||||||
|
i == uiState.packageWriting -> 2
|
||||||
|
else -> 1
|
||||||
|
}
|
||||||
|
PackageItem(it, status) { onRemove(it) }
|
||||||
|
}
|
||||||
|
if (!uiState.installing) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().animateItem().padding(vertical = 4.dp).clickable {
|
||||||
|
chooseSplitPackage.launch(APK_MIME)
|
||||||
|
}.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, null, modifier = Modifier.padding(horizontal = 10.dp))
|
||||||
|
Text(stringResource(R.string.add_packages), style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param status 0: not installing, 1: installing, 2: writing, 3: written
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LazyItemScope.PackageItem(uri: Uri, status: Int, onRemove: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth().animateItem().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)
|
||||||
|
)
|
||||||
|
when (status) {
|
||||||
|
0 -> IconButton(onRemove) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove))
|
||||||
|
}
|
||||||
|
2 -> CircularProgressIndicator(Modifier.padding(end = 8.dp).size(24.dp))
|
||||||
|
3 -> Icon(Icons.Default.Check, null, Modifier.padding(end = 8.dp), MaterialTheme.colorScheme.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Options(options: SessionParamsOptions, onChange: (SessionParamsOptions) -> Unit) = Column {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.mode), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
FullWidthRadioButtonItem(R.string.full_install, options.mode == PackageInstaller.SessionParams.MODE_FULL_INSTALL) {
|
||||||
|
onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_FULL_INSTALL, noKill = false))
|
||||||
|
}
|
||||||
|
FullWidthRadioButtonItem(R.string.inherit_existing, options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) {
|
||||||
|
onChange(options.copy(mode = PackageInstaller.SessionParams.MODE_INHERIT_EXISTING))
|
||||||
|
}
|
||||||
|
if(Build.VERSION.SDK_INT >= 34) {
|
||||||
|
AnimatedVisibility(options.mode == PackageInstaller.SessionParams.MODE_INHERIT_EXISTING) {
|
||||||
|
FullWidthCheckBoxItem(R.string.dont_kill_app, options.noKill) {
|
||||||
|
onChange(options.copy(noKill = it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FullWidthCheckBoxItem(R.string.keep_original_enabled_setting, options.keepOriginalEnabledSetting) {
|
||||||
|
onChange(options.copy(keepOriginalEnabledSetting = it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.install_location), modifier = Modifier.padding(top = 10.dp, start = 8.dp, bottom = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
FullWidthRadioButtonItem(R.string.auto, options.location == PackageInfo.INSTALL_LOCATION_AUTO) {
|
||||||
|
onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_AUTO))
|
||||||
|
}
|
||||||
|
FullWidthRadioButtonItem(R.string.internal_only, options.location == PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY) {
|
||||||
|
onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY))
|
||||||
|
}
|
||||||
|
FullWidthRadioButtonItem(R.string.prefer_external, options.location == PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL) {
|
||||||
|
onChange(options.copy(location = PackageInfo.INSTALL_LOCATION_PREFER_EXTERNAL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user