diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt index f37be6c..5d0d8a1 100644 --- a/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerActivity.kt @@ -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 = setOf("https://example.com".toUri()), - onPackageRemove: (Uri) -> Unit = {}, - onPackageChoose: (List) -> Unit = {}, - onStartInstall: () -> Unit = {}, - writtenPackages: Set = 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, onRemove: (Uri) -> Unit, onChoose: (List) -> Unit, - writtenPackages: Set, 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(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(null) - val packages = MutableStateFlow(setOf()) - - val options = MutableStateFlow(SessionParamsOptions()) - - val writtenPackages = MutableStateFlow(setOf()) - val writingPackage = MutableStateFlow(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() - 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" - } -} diff --git a/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt new file mode 100644 index 0000000..db89514 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/AppInstallerViewModel.kt @@ -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 = emptyList(), + val installing: Boolean = false, + val packageWriting: Int = -1, + val result: Intent? = null + ) + + fun initialize(intent: Intent) { + val list = mutableListOf() + intent.data?.let { list += it } + intent.getParcelableExtra(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) { + 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, +) diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index fded7d3..a19ee32 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -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(val serializer: KSerializer) : Saver { + override fun restore(value: String): T? { + return Json.decodeFromString(serializer, value) + } + override fun SaverScope.save(value: T): String? { + return Json.encodeToString(serializer, value) + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt b/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt new file mode 100644 index 0000000..6b68957 --- /dev/null +++ b/app/src/main/java/com/bintianqi/owndroid/ui/AppInstaller.kt @@ -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) -> 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) -> 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 + ) + } +}