Split UI and ViewModel

This commit is contained in:
BinTianqi
2025-08-30 18:36:10 +08:00
parent 47ee999f9b
commit 765b1ea790
4 changed files with 424 additions and 375 deletions

View File

@@ -1,85 +1,15 @@
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 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.animation.AnimatedVisibility
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.collectAsState
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.lifecycle.AndroidViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.bintianqi.owndroid.dpm.parsePackageInstallerMessage
import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem
import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
import com.bintianqi.owndroid.ui.AppInstaller
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() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -91,311 +21,11 @@ class AppInstallerActivity:FragmentActivity() {
setContent {
val theme by myVm.theme.collectAsStateWithLifecycle()
OwnDroidTheme(theme) {
val installing by vm.installing.collectAsStateWithLifecycle()
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()
val uiState by vm.uiState.collectAsState()
AppInstaller(
installing, options, { if(!installing) vm.options.value = it },
packages, { uri -> vm.packages.update { it.minus(uri) } },
{ uris -> vm.packages.update { it.plus(uris) } },
vm::startInstall, writtenPackages, writingPackage,
result, { vm.result.value = null }
uiState, vm::onPackagesAdd, vm::onPackageRemove, vm::startInstall, vm::closeResultDialog
)
}
}
}
}
@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"
}
}

View File

@@ -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,
)

View File

@@ -13,10 +13,12 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import kotlinx.serialization.encodeToString
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import java.io.FileNotFoundException
import java.io.IOException
@@ -151,3 +153,12 @@ fun Context.popToast(resId: Int) {
fun Context.popToast(str: String) {
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)
}
}

View 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
)
}
}