diff --git a/.github/workflows/android.yaml b/.github/workflows/android.yaml index 9bd8b5e6..59b21e92 100644 --- a/.github/workflows/android.yaml +++ b/.github/workflows/android.yaml @@ -36,7 +36,7 @@ jobs: - name: Set up JDK uses: actions/setup-java@v4 with: - distribution: 'zulu' + distribution: 'jetbrains' java-version: 21 - name: Set up Gradle diff --git a/.github/workflows/maven.yaml b/.github/workflows/maven.yaml index 1f1b88c8..3fef6a30 100644 --- a/.github/workflows/maven.yaml +++ b/.github/workflows/maven.yaml @@ -27,7 +27,7 @@ jobs: - name: Set up JDK uses: actions/setup-java@v4 with: - distribution: 'zulu' + distribution: 'jetbrains' java-version: 21 - name: Set up Gradle @@ -41,4 +41,4 @@ jobs: env: GITHUB_ACTOR: ${{ github.repository_owner }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./gradlew publish \ No newline at end of file + run: ./gradlew publish diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8863f95f..452e2f3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,7 +23,11 @@ android { versionCode = commitCount ndk.abiFilters += listOf("arm64-v8a", "x86_64") - resourceConfigurations += arrayOf( + } + + @Suppress("UnstableApiUsage") + androidResources { + localeFilters += listOf( "en", "ar", "es", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fce0327a..35f8e034 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,7 +44,7 @@ diff --git a/app/src/main/kotlin/dev/sanmer/pi/App.kt b/app/src/main/kotlin/dev/sanmer/pi/App.kt index 6d7aaee9..2ed28b09 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/App.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/App.kt @@ -38,13 +38,13 @@ class App : Application(), ImageLoaderFactory { private fun createNotificationChannels(context: Context) { val channels = listOf( NotificationChannel( - Const.CHANNEL_ID_INSTALL, - context.getString(R.string.installation_service), + Const.CHANNEL_ID_PARSE, + context.getString(R.string.parsing_service), NotificationManager.IMPORTANCE_HIGH ), NotificationChannel( - Const.CHANNEL_ID_PARSE, - context.getString(R.string.parsing_service), + Const.CHANNEL_ID_INSTALL, + context.getString(R.string.installation_service), NotificationManager.IMPORTANCE_HIGH ) ) diff --git a/app/src/main/kotlin/dev/sanmer/pi/Compat.kt b/app/src/main/kotlin/dev/sanmer/pi/Compat.kt deleted file mode 100644 index 24264bb8..00000000 --- a/app/src/main/kotlin/dev/sanmer/pi/Compat.kt +++ /dev/null @@ -1,74 +0,0 @@ -package dev.sanmer.pi - -import android.os.Process -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import dev.sanmer.pi.datastore.model.Provider -import dev.sanmer.pi.delegate.AppOpsManagerDelegate -import dev.sanmer.pi.delegate.PackageInstallerDelegate -import dev.sanmer.pi.delegate.PackageManagerDelegate -import dev.sanmer.pi.delegate.PermissionManagerDelegate -import dev.sanmer.su.IServiceManager -import dev.sanmer.su.ServiceManagerCompat -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import timber.log.Timber - -object Compat { - private var mServiceOrNull: IServiceManager? = null - private val mService: IServiceManager - get() = checkNotNull(mServiceOrNull) { - "IServiceManager haven't been received" - } - - var isAlive by mutableStateOf(false) - private set - - private val _isAliveFlow = MutableStateFlow(false) - val isAliveFlow get() = _isAliveFlow.asStateFlow() - - val platform: String - get() = when (mService.uid) { - Process.ROOT_UID -> "root" - Process.SHELL_UID -> "adb" - else -> "unknown" - } - - private fun state(): Boolean { - isAlive = mServiceOrNull != null - _isAliveFlow.value = isAlive - - return isAlive - } - - suspend fun init(provider: Provider) = when { - isAlive -> true - else -> try { - mServiceOrNull = when (provider) { - Provider.Shizuku -> ServiceManagerCompat.fromShizuku() - Provider.Superuser -> ServiceManagerCompat.fromLibSu() - else -> null - } - - state() - } catch (e: Throwable) { - Timber.e(e) - - mServiceOrNull = null - state() - } - } - - fun get(fallback: T, block: Compat.() -> T): T { - return when { - isAlive -> block(this) - else -> fallback - } - } - - fun getAppOpsService() = AppOpsManagerDelegate(mService) - fun getPackageManager() = PackageManagerDelegate(mService) - fun getPackageInstaller() = PackageInstallerDelegate(mService) - fun getPermissionManager() = PermissionManagerDelegate(mService) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/Const.kt b/app/src/main/kotlin/dev/sanmer/pi/Const.kt index 6c06c31f..881f13e4 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/Const.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/Const.kt @@ -8,4 +8,5 @@ object Const { const val NOTIFICATION_ID_INSTALL = 1024 const val NOTIFICATION_ID_PARSE = 1025 + const val NOTIFICATION_ID_UPDATED = 1026 } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/datastore/PreferenceDataSource.kt b/app/src/main/kotlin/dev/sanmer/pi/datastore/PreferenceDataSource.kt deleted file mode 100644 index a1965abd..00000000 --- a/app/src/main/kotlin/dev/sanmer/pi/datastore/PreferenceDataSource.kt +++ /dev/null @@ -1,38 +0,0 @@ -package dev.sanmer.pi.datastore - -import androidx.datastore.core.DataStore -import dev.sanmer.pi.datastore.model.Preference -import dev.sanmer.pi.datastore.model.Provider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import javax.inject.Inject - -class PreferenceDataSource @Inject constructor( - private val dataStore: DataStore -) { - val data get() = dataStore.data - - suspend fun setProvider(value: Provider) = withContext(Dispatchers.IO) { - dataStore.updateData { - it.copy( - provider = value - ) - } - } - - suspend fun setRequester(value: String) = withContext(Dispatchers.IO) { - dataStore.updateData { - it.copy( - requester = value - ) - } - } - - suspend fun setExecutor(value: String) = withContext(Dispatchers.IO) { - dataStore.updateData { - it.copy( - executor = value - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/datastore/PreferenceSerializer.kt b/app/src/main/kotlin/dev/sanmer/pi/datastore/PreferenceSerializer.kt index aa7714fa..caf85cbb 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/datastore/PreferenceSerializer.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/datastore/PreferenceSerializer.kt @@ -34,10 +34,10 @@ class PreferenceSerializer @Inject constructor() : Serializer { @Module @InstallIn(SingletonComponent::class) - object Provider { + object Impl { @Provides @Singleton - fun DataStore( + fun dataStore( @ApplicationContext context: Context, serializer: PreferenceSerializer ): DataStore = diff --git a/app/src/main/kotlin/dev/sanmer/pi/datastore/model/Preference.kt b/app/src/main/kotlin/dev/sanmer/pi/datastore/model/Preference.kt index 6752df53..97d4b054 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/datastore/model/Preference.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/datastore/model/Preference.kt @@ -22,7 +22,7 @@ data class Preference( ProtoBuf.encodeToByteArray(this) ) - companion object { + companion object Default { fun decodeFromStream(input: InputStream): Preference = ProtoBuf.decodeFromByteArray(input.readBytes()) } diff --git a/app/src/main/kotlin/dev/sanmer/pi/ktx/ContextExt.kt b/app/src/main/kotlin/dev/sanmer/pi/ktx/ContextExt.kt index 62563ff0..21af0ae0 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/ktx/ContextExt.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/ktx/ContextExt.kt @@ -4,8 +4,6 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.Intent -import android.net.Uri -import android.provider.Settings import androidx.core.app.LocaleManagerCompat import java.util.Locale @@ -19,24 +17,6 @@ fun Context.viewUrl(url: String) { ) } -fun Context.viewPackage(packageName: String) { - Intent( - Intent.ACTION_SHOW_APP_INFO - ).apply { - putExtra(Intent.EXTRA_PACKAGE_NAME, packageName) - startActivity(this) - } -} - -fun Context.appSetting(packageName: String) { - startActivity( - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", packageName, null) - ) - ) -} - fun Context.findActivity(): Activity? { var context = this while (context is ContextWrapper) { diff --git a/app/src/main/kotlin/dev/sanmer/pi/model/IPackageInfo.kt b/app/src/main/kotlin/dev/sanmer/pi/model/IPackageInfo.kt index 5f6215b8..09b4182f 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/model/IPackageInfo.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/model/IPackageInfo.kt @@ -11,7 +11,7 @@ data class IPackageInfo( val isRequester: Boolean, val isExecutor: Boolean ) : PackageInfoDelegate(inner) { - companion object { + companion object Default { fun PackageInfo.toIPackageInfo( isAuthorized: Boolean = false, isRequester: Boolean = false, diff --git a/app/src/main/kotlin/dev/sanmer/pi/model/ServiceState.kt b/app/src/main/kotlin/dev/sanmer/pi/model/ServiceState.kt new file mode 100644 index 00000000..150d467e --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/pi/model/ServiceState.kt @@ -0,0 +1,13 @@ +package dev.sanmer.pi.model + +import dev.sanmer.su.IServiceManager + +sealed class ServiceState { + data object Pending : ServiceState() + data class Success(val service: IServiceManager) : ServiceState() + data class Failure(val error: Throwable) : ServiceState() + + val isPending inline get() = this == Pending + val isSucceed inline get() = this is Success + val isFailed inline get() = this is Failure +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/receiver/BroadcastReceiverEntryPoint.kt b/app/src/main/kotlin/dev/sanmer/pi/receiver/BroadcastReceiverEntryPoint.kt new file mode 100644 index 00000000..004b3416 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/pi/receiver/BroadcastReceiverEntryPoint.kt @@ -0,0 +1,14 @@ +package dev.sanmer.pi.receiver + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.sanmer.pi.repository.PreferenceRepository +import dev.sanmer.pi.repository.ServiceRepository + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface BroadcastReceiverEntryPoint { + fun preferenceRepository(): PreferenceRepository + fun serviceRepository(): ServiceRepository +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/receiver/Updated.kt b/app/src/main/kotlin/dev/sanmer/pi/receiver/Updated.kt index 7cc32bcd..b8df9811 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/receiver/Updated.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/receiver/Updated.kt @@ -1,42 +1,86 @@ package dev.sanmer.pi.receiver +import android.Manifest import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.EntryPointAccessors import dev.sanmer.pi.Const import dev.sanmer.pi.R +import dev.sanmer.pi.compat.BuildCompat +import dev.sanmer.pi.compat.PermissionCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber class Updated : BroadcastReceiver() { + private val coroutineScope = CoroutineScope(Dispatchers.IO) + override fun onReceive(context: Context, intent: Intent?) { when (intent?.action) { Intent.ACTION_MY_PACKAGE_REPLACED -> { - context.deleteExternalCacheDir() - context.notifyUpdated() + val pending = goAsync() + coroutineScope.launch { + context.deleteExternalCacheDir() + context.performDexOpt() + pending.finish() + } } } } - private fun Context.deleteExternalCacheDir() { + private suspend fun Context.deleteExternalCacheDir() = withContext(Dispatchers.IO) { externalCacheDir?.deleteRecursively() } - @Throws(SecurityException::class) + private suspend fun Context.performDexOpt() = withContext(Dispatchers.IO) { + val entryPoint = EntryPointAccessors.fromApplication( + applicationContext, + BroadcastReceiverEntryPoint::class.java + ) + + val serviceRepository = entryPoint.serviceRepository() + val state = serviceRepository.state.first { !it.isPending } + + notifyUpdated() + if (state.isSucceed) { + runCatching { + Timber.d("optimize $packageName") + val pm = serviceRepository.getPackageManager() + pm.clearApplicationProfileData(packageName) + pm.performDexOpt(packageName).also { + if (!it) Timber.e("Failed to optimize $packageName") + } + }.onFailure { error -> + Timber.e(error, "Failed to optimize $packageName") + } + } + } + private fun Context.notifyUpdated() { + if ( + BuildCompat.atLeastT + && !PermissionCompat.checkPermission(this, Manifest.permission.POST_NOTIFICATIONS) + ) return + val intent = packageManager.getLaunchIntentForPackage(packageName) val flag = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val pending = PendingIntent.getActivity(this, 0, intent, flag) val builder = NotificationCompat.Builder(this, Const.CHANNEL_ID_INSTALL) - .setSmallIcon(R.drawable.launcher_outline) + .setSmallIcon(R.drawable.layout_list) .setContentIntent(pending) .setContentTitle(getText(R.string.updated_title)) .setContentText(getText(R.string.updated_text)) .setAutoCancel(true) NotificationManagerCompat.from(this).apply { - notify(builder.hashCode(), builder.build()) + notify(Const.NOTIFICATION_ID_UPDATED, builder.build()) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/repository/PreferenceRepository.kt b/app/src/main/kotlin/dev/sanmer/pi/repository/PreferenceRepository.kt index aa5b5eba..fca69030 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/repository/PreferenceRepository.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/repository/PreferenceRepository.kt @@ -1,25 +1,46 @@ package dev.sanmer.pi.repository -import dev.sanmer.pi.datastore.PreferenceDataSource +import androidx.datastore.core.DataStore +import dev.sanmer.pi.datastore.model.Preference import dev.sanmer.pi.datastore.model.Provider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @Singleton class PreferenceRepository @Inject constructor( - private val dataSource: PreferenceDataSource + private val dataStore: DataStore ) { - val data get() = dataSource.data + val data get() = dataStore.data suspend fun setProvider(value: Provider) { - dataSource.setProvider(value) + withContext(Dispatchers.IO) { + dataStore.updateData { + it.copy( + provider = value + ) + } + } } suspend fun setRequester(value: String) { - dataSource.setRequester(value) + withContext(Dispatchers.IO) { + dataStore.updateData { + it.copy( + requester = value + ) + } + } } suspend fun setExecutor(value: String) { - dataSource.setExecutor(value) + withContext(Dispatchers.IO) { + dataStore.updateData { + it.copy( + executor = value + ) + } + } } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/repository/ServiceRepository.kt b/app/src/main/kotlin/dev/sanmer/pi/repository/ServiceRepository.kt new file mode 100644 index 00000000..2015f22f --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/pi/repository/ServiceRepository.kt @@ -0,0 +1,71 @@ +package dev.sanmer.pi.repository + +import dev.sanmer.pi.datastore.model.Provider +import dev.sanmer.pi.delegate.AppOpsManagerDelegate +import dev.sanmer.pi.delegate.PackageInstallerDelegate +import dev.sanmer.pi.delegate.PackageManagerDelegate +import dev.sanmer.pi.delegate.PermissionManagerDelegate +import dev.sanmer.pi.model.ServiceState +import dev.sanmer.su.IServiceManager +import dev.sanmer.su.ServiceManagerCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ServiceRepository @Inject constructor( + private val preferenceRepository: PreferenceRepository +) { + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + private var _state = MutableStateFlow(ServiceState.Pending) + val state get() = _state.asStateFlow() + + val isSucceed get() = state.value.isSucceed + + init { + preferenceObserver() + } + + private fun preferenceObserver() { + coroutineScope.launch { + preferenceRepository.data.collect { preference -> + _state.update { if (!it.isSucceed) create(preference.provider) else it } + } + } + } + + private suspend fun create(provider: Provider) = try { + when (provider) { + Provider.None -> ServiceState.Pending + Provider.Shizuku -> ServiceState.Success(ServiceManagerCompat.fromShizuku()) + Provider.Superuser -> ServiceState.Success(ServiceManagerCompat.fromLibSu()) + } + } catch (e: Throwable) { + Timber.e(e) + ServiceState.Failure(e) + } + + suspend fun recreate(provider: Provider) { + _state.update { create(provider) } + } + + private fun unsafe(block: (IServiceManager) -> T): T { + return when (val value = state.value) { + is ServiceState.Success -> block(value.service) + is ServiceState.Failure -> throw value.error + ServiceState.Pending -> throw IllegalStateException("Pending") + } + } + + fun getAppOpsManager() = unsafe { AppOpsManagerDelegate(it) } + fun getPackageManager() = unsafe { PackageManagerDelegate(it) } + fun getPackageInstaller() = unsafe { PackageInstallerDelegate(it) } + fun getPermissionManager() = unsafe { PermissionManagerDelegate(it) } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/service/InstallService.kt b/app/src/main/kotlin/dev/sanmer/pi/service/InstallService.kt index f6a01968..9ebe0d2c 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/service/InstallService.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/service/InstallService.kt @@ -18,7 +18,6 @@ import androidx.core.app.ServiceCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import dev.sanmer.pi.Compat import dev.sanmer.pi.Const import dev.sanmer.pi.ContextCompat.userId import dev.sanmer.pi.PackageParserCompat @@ -27,11 +26,12 @@ import dev.sanmer.pi.bundle.SplitConfig import dev.sanmer.pi.compat.BuildCompat import dev.sanmer.pi.compat.PermissionCompat import dev.sanmer.pi.delegate.PackageInstallerDelegate -import dev.sanmer.pi.delegate.PackageInstallerDelegate.Companion.commit -import dev.sanmer.pi.delegate.PackageInstallerDelegate.Companion.write +import dev.sanmer.pi.delegate.PackageInstallerDelegate.Default.commit +import dev.sanmer.pi.delegate.PackageInstallerDelegate.Default.write import dev.sanmer.pi.ktx.dp import dev.sanmer.pi.ktx.parcelable import dev.sanmer.pi.repository.PreferenceRepository +import dev.sanmer.pi.repository.ServiceRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -49,12 +49,15 @@ import kotlin.time.Duration.Companion.seconds @AndroidEntryPoint class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallback { @Inject - lateinit var preference: PreferenceRepository + lateinit var preferenceRepository: PreferenceRepository + + @Inject + lateinit var serviceRepository: ServiceRepository private val appIconLoader by lazy { AppIconLoader(45.dp, true, this) } private val nm by lazy { NotificationManagerCompat.from(this) } - private val pm by lazy { Compat.getPackageManager() } - private val pi by lazy { Compat.getPackageInstaller() } + private val pm by lazy { serviceRepository.getPackageManager() } + private val pi by lazy { serviceRepository.getPackageInstaller() } init { lifecycleScope.launch { @@ -127,7 +130,7 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb val appLabel = task.archiveInfo.applicationInfo?.loadLabel(packageManager) ?: task.archiveInfo.packageName - val preference = preference.data.first() + val preference = preferenceRepository.data.first() val originatingUid = getPackageUid(preference.requester) pi.setInstallerPackageName(preference.executor) @@ -155,6 +158,14 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb when (status) { PackageInstaller.STATUS_SUCCESS -> { + notifyOptimizing( + id = sessionId, + appLabel = appLabel, + appIcon = appIcon + ) + + optimize(task.archiveInfo.packageName) + notifySuccess( id = sessionId, appLabel = appLabel, @@ -175,6 +186,17 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb } } + private suspend fun optimize(packageName: String) = withContext(Dispatchers.IO) { + runCatching { + pm.clearApplicationProfileData(packageName) + pm.performDexOpt(packageName).also { + if (!it) Timber.e("Failed to optimize $packageName") + } + }.onFailure { error -> + Timber.e(error, "Failed to optimize $packageName") + }.getOrDefault(false) + } + private fun createSessionParams(): PackageInstaller.SessionParams { val params = PackageInstallerDelegate.SessionParams( PackageInstaller.SessionParams.MODE_FULL_INSTALL @@ -196,7 +218,7 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb return params } - private fun getPackageUid(packageName: String): Int = + private fun getPackageUid(packageName: String) = runCatching { pm.getPackageUid(packageName, 0, userId) }.getOrDefault( @@ -238,6 +260,23 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb notify(id, notification) } + private fun notifyOptimizing( + id: Int, + appLabel: CharSequence, + appIcon: Bitmap? + ) { + val notification = newNotificationBuilder() + .setLargeIcon(appIcon) + .setContentTitle(appLabel) + .setContentText(getString(R.string.message_optimizing)) + .setSilent(true) + .setOngoing(true) + .setGroup(GROUP_KEY) + .build() + + notify(id, notification) + } + private fun notifySuccess( id: Int, appLabel: CharSequence, @@ -251,7 +290,7 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb val notification = newNotificationBuilder() .setLargeIcon(appIcon) .setContentTitle(appLabel) - .setContentText(getText(R.string.message_install_successful)) + .setContentText(getText(R.string.message_install_succeed)) .setContentIntent(pending) .setSilent(true) .setAutoCancel(true) @@ -276,17 +315,13 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb private fun newNotificationBuilder() = NotificationCompat.Builder(applicationContext, Const.CHANNEL_ID_INSTALL) - .setSmallIcon(R.drawable.launcher_outline) + .setSmallIcon(R.drawable.layout_list) - @Throws(SecurityException::class) private fun notify(id: Int, notification: Notification) { - val granted = if (BuildCompat.atLeastT) { - PermissionCompat.checkPermission(this, Manifest.permission.POST_NOTIFICATIONS) - } else { - true - } - - if (granted) nm.notify(id, notification) + if ( + !BuildCompat.atLeastT + || PermissionCompat.checkPermission(this, Manifest.permission.POST_NOTIFICATIONS) + ) nm.notify(id, notification) } sealed class Task : Parcelable { @@ -315,7 +350,7 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb } } - companion object { + companion object Default { private const val GROUP_KEY = "dev.sanmer.pi.INSTALL_SERVICE_GROUP_KEY" private const val EXTRA_TASK = "dev.sanmer.pi.extra.TASK" diff --git a/app/src/main/kotlin/dev/sanmer/pi/service/ParseService.kt b/app/src/main/kotlin/dev/sanmer/pi/service/ParseService.kt index 137c7676..e98473e5 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/service/ParseService.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/service/ParseService.kt @@ -14,7 +14,6 @@ import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import dev.sanmer.pi.BuildConfig -import dev.sanmer.pi.Compat import dev.sanmer.pi.Const import dev.sanmer.pi.ContextCompat.userId import dev.sanmer.pi.PackageParserCompat @@ -25,7 +24,7 @@ import dev.sanmer.pi.compat.MediaStoreCompat.getOwnerPackageNameForUri import dev.sanmer.pi.compat.MediaStoreCompat.getPathForUri import dev.sanmer.pi.compat.PermissionCompat import dev.sanmer.pi.delegate.AppOpsManagerDelegate -import dev.sanmer.pi.repository.PreferenceRepository +import dev.sanmer.pi.repository.ServiceRepository import dev.sanmer.pi.ui.InstallActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext @@ -33,6 +32,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import java.util.UUID @@ -42,11 +42,11 @@ import kotlin.time.Duration.Companion.seconds @AndroidEntryPoint class ParseService : LifecycleService() { @Inject - lateinit var preference: PreferenceRepository + lateinit var serviceRepository: ServiceRepository private val nm by lazy { NotificationManagerCompat.from(this) } - private val pm by lazy { Compat.getPackageManager() } - private val aom by lazy { Compat.getAppOpsService() } + private val pm by lazy { serviceRepository.getPackageManager() } + private val aom by lazy { serviceRepository.getAppOpsManager() } init { lifecycleScope.launch { @@ -58,6 +58,7 @@ class ParseService : LifecycleService() { } override fun onCreate() { + Timber.d("onCreate") super.onCreate() setForeground() } @@ -69,90 +70,90 @@ class ParseService : LifecycleService() { override fun onDestroy() { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + Timber.d("onDestroy") super.onDestroy() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { lifecycleScope.launch(Dispatchers.IO) { val uri = intent?.data ?: return@launch - val preference = preference.data.first() - val finish = { - nm.cancel(uri.hashCode()) - pendingUris.remove(uri) - } + val state = serviceRepository.state.first { !it.isPending } - if (!Compat.init(preference.provider)) { + if (state.isSucceed) { + parse(uri) + } else { notifyFailure( id = Const.NOTIFICATION_ID_PARSE, title = getText(R.string.parsing_service), - text = getText(R.string.message_invalid_provider) + text = getText(R.string.settings_service_not_running) ) - - stopSelf() - pendingUris.clear() - return@launch } + pendingUris.remove(uri) + } + return super.onStartCommand(intent, flags, startId) + } - val packageName = getOwnerPackageNameForUri(uri) - val sourceInfo = packageName?.let(::getPackageInfo) - val path = File(getPathForUri(uri)) - Timber.i("from: $packageName, path: $path") - - notifyParsing( - id = uri.hashCode(), - filename = path.name - ) + private suspend fun parse(uri: Uri) = withContext(Dispatchers.IO) { + val packageName = getOwnerPackageNameForUri(uri) + val sourceInfo = packageName?.let(::getPackageInfo) + val path = File(getPathForUri(uri)) + Timber.i("from: $packageName, path: $path") - val archivePath = File(externalCacheDir, UUID.randomUUID().toString()) - copyToFile(uri, archivePath) - - PackageParserCompat.parsePackage(archivePath, 0)?.let { pi -> - if (sourceInfo?.isAuthorized() == true || - sourceInfo?.packageName == pi.packageName || - pi.packageName == BuildConfig.APPLICATION_ID) { - InstallService.apk( - context = applicationContext, - archivePath = archivePath, - archiveInfo = pi - ) - } else { - InstallActivity.apk( - context = applicationContext, - archivePath = archivePath, - archiveInfo = pi, - sourceInfo = sourceInfo - ) - } + notifyParsing( + id = uri.hashCode(), + filename = path.name + ) - finish() - return@launch - } + val archivePath = File(externalCacheDir, UUID.randomUUID().toString()) + copyToFile(uri, archivePath) - val archiveDir = File(externalCacheDir, UUID.randomUUID().toString()).apply { mkdirs() } - PackageParserCompat.parseAppBundle(archivePath, 0, archiveDir)?.let { bi -> - InstallActivity.appBundle( + PackageParserCompat.parsePackage(archivePath, 0)?.let { pi -> + if (sourceInfo?.isAuthorized() == true || + sourceInfo?.packageName == pi.packageName || + pi.packageName == BuildConfig.APPLICATION_ID) { + InstallService.apk( + context = applicationContext, + archivePath = archivePath, + archiveInfo = pi + ) + } else { + InstallActivity.apk( context = applicationContext, - archivePath = archiveDir, - archiveInfo = bi.baseInfo, - splitConfigs = bi.splitConfigs, + archivePath = archivePath, + archiveInfo = pi, sourceInfo = sourceInfo ) - - archivePath.delete() - finish() - return@launch } - archivePath.delete() - archiveDir.deleteRecursively() - finish() - notifyFailure( - id = uri.hashCode(), - title = path.name, - text = getText(R.string.message_parsing_failed) + nm.cancel(uri.hashCode()) + return@withContext true + } + + val archiveDir = File(externalCacheDir, UUID.randomUUID().toString()).apply { mkdirs() } + PackageParserCompat.parseAppBundle(archivePath, 0, archiveDir)?.let { bi -> + InstallActivity.appBundle( + context = applicationContext, + archivePath = archiveDir, + archiveInfo = bi.baseInfo, + splitConfigs = bi.splitConfigs, + sourceInfo = sourceInfo ) + + archivePath.delete() + nm.cancel(uri.hashCode()) + return@withContext true } - return super.onStartCommand(intent, flags, startId) + + archivePath.delete() + archiveDir.deleteRecursively() + nm.cancel(uri.hashCode()) + notifyFailure( + id = uri.hashCode(), + title = path.name, + text = getText(R.string.message_parsing_failed) + ) + + return@withContext false } private fun getPackageInfo(packageName: String): PackageInfo { @@ -215,20 +216,16 @@ class ParseService : LifecycleService() { private fun newNotificationBuilder() = NotificationCompat.Builder(applicationContext, Const.CHANNEL_ID_PARSE) - .setSmallIcon(R.drawable.launcher_outline) + .setSmallIcon(R.drawable.layout_list) - @Throws(SecurityException::class) private fun notify(id: Int, notification: Notification) { - val granted = if (BuildCompat.atLeastT) { - PermissionCompat.checkPermission(this, Manifest.permission.POST_NOTIFICATIONS) - } else { - true - } - - if (granted) nm.notify(id, notification) + if ( + !BuildCompat.atLeastT + || PermissionCompat.checkPermission(this, Manifest.permission.POST_NOTIFICATIONS) + ) nm.notify(id, notification) } - companion object { + companion object Default { private const val GROUP_KEY = "dev.sanmer.pi.PARSE_SERVICE_GROUP_KEY" private val pendingUris = mutableListOf() @@ -243,4 +240,4 @@ class ParseService : LifecycleService() { ) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/InstallActivity.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/InstallActivity.kt index 6b8198dd..dac40fe9 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/ui/InstallActivity.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/ui/InstallActivity.kt @@ -14,8 +14,8 @@ import dev.sanmer.pi.bundle.SplitConfig import dev.sanmer.pi.compat.BuildCompat import dev.sanmer.pi.compat.PermissionCompat import dev.sanmer.pi.ktx.parcelable -import dev.sanmer.pi.service.InstallService.Companion.putTask -import dev.sanmer.pi.service.InstallService.Companion.taskOrNull +import dev.sanmer.pi.service.InstallService.Default.putTask +import dev.sanmer.pi.service.InstallService.Default.taskOrNull import dev.sanmer.pi.service.InstallService.Task import dev.sanmer.pi.service.ParseService import dev.sanmer.pi.ui.main.InstallScreen @@ -67,7 +67,7 @@ class InstallActivity : ComponentActivity() { super.onDestroy() } - companion object { + companion object Default { private const val EXTRA_SOURCE_INFO = "dev.sanmer.pi.extra.SOURCE_INFO" private fun Intent.putSourceInfo(value: PackageInfo?) = diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/MainActivity.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/MainActivity.kt index 569acb21..c78255cc 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/ui/MainActivity.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/ui/MainActivity.kt @@ -4,73 +4,51 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.compose.animation.Crossfade import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import dev.sanmer.pi.Compat import dev.sanmer.pi.datastore.model.Provider -import dev.sanmer.pi.repository.PreferenceRepository import dev.sanmer.pi.ui.main.MainScreen import dev.sanmer.pi.ui.main.SetupScreen import dev.sanmer.pi.ui.provider.LocalPreference import dev.sanmer.pi.ui.theme.AppTheme -import kotlinx.coroutines.launch -import javax.inject.Inject +import dev.sanmer.pi.viewmodel.MainViewModel +import dev.sanmer.pi.viewmodel.MainViewModel.LoadState @AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject - lateinit var preference: PreferenceRepository - - private var isLoading by mutableStateOf(true) + val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) enableEdgeToEdge() - splashScreen.setKeepOnScreenCondition { isLoading } + splashScreen.setKeepOnScreenCondition { viewModel.isPending } setContent { - val preferenceState = preference.data.collectAsStateWithLifecycle(initialValue = null) - val preference = preferenceState.value ?: return@setContent - isLoading = false - - LaunchedEffect(preference) { - Compat.init(preference.provider) - } - - CompositionLocalProvider( - LocalPreference provides preference - ) { - AppTheme { - Crossfade( - targetState = preference.provider != Provider.None, - label = "MainActivity" - ) { isReady -> - if (isReady) { - MainScreen() - } else { - SetupScreen( - setProvider = ::setProvider - ) + when (viewModel.state) { + LoadState.Pending -> {} + is LoadState.Ready -> CompositionLocalProvider( + LocalPreference provides viewModel.preference + ) { + AppTheme { + Crossfade( + targetState = viewModel.preference.provider != Provider.None, + ) { isReady -> + if (isReady) { + MainScreen() + } else { + SetupScreen( + setProvider = viewModel::setProvider + ) + } } } } } } } - - private fun setProvider(value: Provider) { - lifecycleScope.launch { - preference.setProvider(value) - } - } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/main/InstallScreen.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/main/InstallScreen.kt index 6d965fe0..91dee8c9 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/ui/main/InstallScreen.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/ui/main/InstallScreen.kt @@ -103,8 +103,7 @@ fun InstallScreen( AnimatedVisibility( visible = isScrollingUp, enter = fadeIn() + scaleIn(), - exit = scaleOut() + fadeOut(), - label = "ActionButton" + exit = scaleOut() + fadeOut() ) { ActionButton(onStart = onStart) } @@ -237,7 +236,7 @@ private fun TopBar( onDeny: () -> Unit, scrollBehavior: TopAppBarScrollBehavior ) = TopAppBar( - title = { Text(text = stringResource(id = R.string.install_activity)) }, + title = { Text(text = stringResource(id = R.string.install_activity_label)) }, navigationIcon = { IconButton( onClick = onDeny diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/provider/LocalSnackbarHostState.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/provider/LocalSnackbarHostState.kt deleted file mode 100644 index e6e62a05..00000000 --- a/app/src/main/kotlin/dev/sanmer/pi/ui/provider/LocalSnackbarHostState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.sanmer.pi.ui.provider - -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.staticCompositionLocalOf - -val LocalSnackbarHostState = staticCompositionLocalOf { SnackbarHostState() } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/AppsScreen.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/AppsScreen.kt index bc47b2a0..40306d9e 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/AppsScreen.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/AppsScreen.kt @@ -24,9 +24,9 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import dev.sanmer.pi.R +import dev.sanmer.pi.ui.component.Failed import dev.sanmer.pi.ui.component.Loading import dev.sanmer.pi.ui.component.PageIndicator import dev.sanmer.pi.ui.component.SearchTopBar @@ -40,10 +40,8 @@ fun AppsScreen( navController: NavController, viewModel: AppsViewModel = hiltViewModel() ) { - val list by viewModel.apps.collectAsStateWithLifecycle() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val state = rememberLazyListState() + val listState = rememberLazyListState() BackHandler( enabled = viewModel.isSearch, @@ -73,13 +71,19 @@ fun AppsScreen( .fillMaxSize(), contentAlignment = Alignment.TopCenter ) { - if (viewModel.isLoading) { - Loading( + if (viewModel.isFailed) { + Failed( + message = stringResource(id = R.string.settings_service_not_running), modifier = Modifier.padding(contentPadding) ) + return@Scaffold } - if (list.isEmpty() && !viewModel.isLoading) { + if (viewModel.isPending) { + Loading( + modifier = Modifier.padding(contentPadding) + ) + } else if (viewModel.apps.isEmpty() && !viewModel.isQueryEmpty) { PageIndicator( icon = R.drawable.list_search, text = R.string.empty_list, @@ -88,8 +92,8 @@ fun AppsScreen( } AppList( - list = list, - state = state, + list = viewModel.apps, + listState = listState, settings = viewModel::settings, contentPadding = contentPadding ) diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/component/AppItem.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/component/AppItem.kt index ac49a4f7..667aa822 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/component/AppItem.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/component/AppItem.kt @@ -1,6 +1,6 @@ package dev.sanmer.pi.ui.screens.apps.component -import androidx.annotation.DrawableRes +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -18,7 +19,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest @@ -26,7 +29,6 @@ import dev.sanmer.pi.R import dev.sanmer.pi.compat.VersionCompat.getSdkVersion import dev.sanmer.pi.compat.VersionCompat.versionStr import dev.sanmer.pi.model.IPackageInfo -import dev.sanmer.pi.ui.component.Logo @Composable fun AppItem( @@ -85,21 +87,33 @@ fun AppItem( Spacer(modifier = Modifier.height(6.dp)) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - if (pi.isRequester) Icon(R.drawable.file_import) - if (pi.isExecutor) Icon(R.drawable.player_play) - if (pi.isAuthorized) Icon(R.drawable.shield) + if (pi.isRequester) LabelText( + text = stringResource(id = R.string.app_action_requester) + ) + if (pi.isExecutor) LabelText( + text = stringResource(id = R.string.app_action_executor) + ) + if (pi.isAuthorized) LabelText( + text = stringResource(id = R.string.app_action_authorized) + ) } } } @Composable -private fun Icon( - @DrawableRes icon: Int -) = Logo( - icon = icon, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.size(30.dp) +private fun LabelText( + text: String, + backgroundColor: Color = MaterialTheme.colorScheme.secondaryContainer +) = Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier + .background( + color = backgroundColor, + shape = CircleShape + ) + .padding(horizontal = 8.dp, vertical = 2.dp) ) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/component/AppList.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/component/AppList.kt index 67013f87..50adf57a 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/component/AppList.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/apps/component/AppList.kt @@ -28,16 +28,17 @@ import androidx.compose.ui.unit.dp import dev.sanmer.pi.BuildConfig import dev.sanmer.pi.R import dev.sanmer.pi.model.IPackageInfo -import dev.sanmer.pi.model.IPackageInfo.Companion.toIPackageInfo +import dev.sanmer.pi.model.IPackageInfo.Default.toIPackageInfo import dev.sanmer.pi.ui.component.MenuChip import dev.sanmer.pi.ui.ktx.bottom import dev.sanmer.pi.viewmodel.AppsViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable fun AppList( list: List, - state: LazyListState, + listState: LazyListState, settings: (IPackageInfo) -> AppsViewModel.Settings, contentPadding: PaddingValues = PaddingValues(0.dp) ) { @@ -52,7 +53,7 @@ fun AppList( modifier = Modifier .fillMaxWidth() .animateContentSize(), - state = state, + state = listState, contentPadding = contentPadding ) { items(list) { @@ -100,14 +101,15 @@ private fun SettingItem( .clip(shape = MaterialTheme.shapes.medium) .border( border = CardDefaults.outlinedCardBorder(), - shape = MaterialTheme.shapes.medium) + shape = MaterialTheme.shapes.medium + ) .fillMaxWidth() .padding(all = 15.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), maxItemsInEachRow = 2 ) { - val scope = rememberCoroutineScope() + val scope = rememberCoroutineScope { Dispatchers.IO } MenuChip( selected = pi.isRequester, @@ -117,7 +119,7 @@ private fun SettingItem( settings.setRequester() } }, - label = { Text(text = stringResource(id = R.string.app_requester)) }, + label = { Text(text = stringResource(id = R.string.app_action_requester)) }, ) MenuChip( @@ -128,7 +130,7 @@ private fun SettingItem( settings.setExecutor() } }, - label = { Text(text = stringResource(id = R.string.app_executor)) } + label = { Text(text = stringResource(id = R.string.app_action_executor)) } ) MenuChip( @@ -139,6 +141,13 @@ private fun SettingItem( settings.setAuthorized() } }, - label = { Text(text = stringResource(id = R.string.app_authorize)) }, + label = { + Text( + text = stringResource( + id = if (pi.isAuthorized) R.string.app_action_authorized + else R.string.app_action_authorize + ) + ) + }, ) } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/settings/SettingsScreen.kt index 103724b6..6ca79b2b 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/settings/SettingsScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import dev.sanmer.pi.Const import dev.sanmer.pi.R @@ -48,6 +49,8 @@ fun SettingsScreen( navController: NavController, viewModel: SettingsViewModel = hiltViewModel() ) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current val preference = LocalPreference.current val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() @@ -73,9 +76,8 @@ fun SettingsScreen( .padding(contentPadding) ) { ServiceItem( - isAlive = viewModel.isAlive, - platform = viewModel.platform, - tryStart = viewModel::tryStart + state = state, + restart = viewModel::restart ) SettingNormalItem( diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/settings/component/ServiceItem.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/settings/component/ServiceItem.kt index 5ae79f28..238404f8 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/settings/component/ServiceItem.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/settings/component/ServiceItem.kt @@ -1,33 +1,44 @@ package dev.sanmer.pi.ui.screens.settings.component +import android.os.Process import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import dev.sanmer.pi.BuildConfig import dev.sanmer.pi.R +import dev.sanmer.pi.model.ServiceState import dev.sanmer.pi.ui.component.SettingNormalItem +import dev.sanmer.su.IServiceManager @Composable fun ServiceItem( - isAlive: Boolean, - platform: String, - tryStart: () -> Unit + state: ServiceState, + restart: () -> Unit ) = SettingNormalItem( - icon = when { - isAlive -> R.drawable.mood_wink - else -> R.drawable.mood_xd + icon = when (state) { + ServiceState.Pending -> R.drawable.mood_neutral + is ServiceState.Success -> R.drawable.mood_wink + is ServiceState.Failure -> R.drawable.mood_xd }, - title = when { - isAlive -> stringResource(id = R.string.settings_service_running) - else -> stringResource(id = R.string.settings_service_not_running) - }, - desc = when { - isAlive -> stringResource( + title = stringResource(id = when (state) { + ServiceState.Pending -> R.string.settings_service_starting + is ServiceState.Success -> R.string.settings_service_running + is ServiceState.Failure -> R.string.settings_service_not_running + }), + desc = when (state) { + ServiceState.Pending -> stringResource(id = R.string.settings_service_wait) + is ServiceState.Success -> stringResource( id = R.string.settings_service_version, BuildConfig.VERSION_CODE, - platform + state.service.platform ) - - else -> stringResource(id = R.string.settings_service_try_start) + is ServiceState.Failure -> stringResource(id = R.string.settings_service_restart) }, - onClick = tryStart -) \ No newline at end of file + onClick = { if (state.isFailed) restart() } +) + +private val IServiceManager.platform + inline get() = when (uid) { + Process.ROOT_UID -> "root" + Process.SHELL_UID -> "adb" + else -> "unknown (${uid})" + } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/AppsViewModel.kt b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/AppsViewModel.kt index f46d88cb..4ed8cffa 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/AppsViewModel.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/AppsViewModel.kt @@ -8,19 +8,18 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dev.sanmer.pi.Compat import dev.sanmer.pi.PackageInfoCompat.isOverlayPackage import dev.sanmer.pi.UserHandleCompat import dev.sanmer.pi.delegate.AppOpsManagerDelegate import dev.sanmer.pi.ktx.combineToLatest import dev.sanmer.pi.model.IPackageInfo -import dev.sanmer.pi.model.IPackageInfo.Companion.toIPackageInfo +import dev.sanmer.pi.model.IPackageInfo.Default.toIPackageInfo import dev.sanmer.pi.repository.PreferenceRepository +import dev.sanmer.pi.repository.ServiceRepository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -28,44 +27,49 @@ import javax.inject.Inject @HiltViewModel class AppsViewModel @Inject constructor( - private val preference: PreferenceRepository + private val preferenceRepository: PreferenceRepository, + private val serviceRepository: ServiceRepository ) : ViewModel(), AppOpsManagerDelegate.AppOpsCallback { - private val isAlive get() = Compat.isAlive - private val pm by lazy { Compat.getPackageManager() } - private val aom by lazy { Compat.getAppOpsService() } + private val pm by lazy { serviceRepository.getPackageManager() } + private val aom by lazy { serviceRepository.getAppOpsManager() } var isSearch by mutableStateOf(false) private set private val queryFlow = MutableStateFlow("") - private val packagesFlow = MutableStateFlow(listOf()) - private val cacheFlow = MutableStateFlow(listOf()) - private val appsFlow = MutableStateFlow(listOf()) - val apps get() = appsFlow.asStateFlow() + private val packagesFlow = MutableSharedFlow>(1) + private val cacheFlow = MutableSharedFlow>(1) - var isLoading by mutableStateOf(true) + var loadState by mutableStateOf(LoadState.Pending) private set + val apps inline get() = loadState.apps + val isPending inline get() = loadState.isPending + + var isFailed by mutableStateOf(false) + private set + + val isQueryEmpty get() = queryFlow.value.isEmpty() override fun opChanged(op: Int, uid: Int, packageName: String) { Timber.d("opChanged<${AppOpsManagerDelegate.opToName(op)}>: $packageName") viewModelScope.launch { - packagesFlow.value = getPackages() + packagesFlow.tryEmit(getPackages()) } } init { Timber.d("AppsViewModel init") - providerObserver() + serviceObserver() dataObserver() queryObserver() } - private fun providerObserver() { + private fun serviceObserver() { viewModelScope.launch { - Compat.isAliveFlow.collectLatest { isAlive -> - if (isAlive) { - packagesFlow.update { getPackages() } + serviceRepository.state.collectLatest { state -> + if (state.isSucceed) { + packagesFlow.tryEmit(getPackages()) aom.startWatchingMode( op = AppOpsManagerDelegate.OP_REQUEST_INSTALL_PACKAGES, @@ -73,11 +77,12 @@ class AppsViewModel @Inject constructor( callback = this@AppsViewModel ) } + isFailed = state.isFailed } } addCloseable { - if (isAlive) { + if (serviceRepository.isSucceed) { aom.stopWatchingMode(callback = this) } } @@ -85,8 +90,8 @@ class AppsViewModel @Inject constructor( private fun dataObserver() { viewModelScope.launch { - packagesFlow.combineToLatest(preference.data) { source, preferences -> - cacheFlow.update { + packagesFlow.combineToLatest(preferenceRepository.data) { source, preferences -> + cacheFlow.tryEmit( source.map { pi -> pi.copy( isRequester = preferences.requester == pi.packageName, @@ -95,7 +100,7 @@ class AppsViewModel @Inject constructor( }.sortedByDescending { it.lastUpdateTime } .sortedByDescending { it.isAuthorized } .sortedByDescending { it.isExecutor || it.isRequester } - } + ) } } } @@ -103,7 +108,7 @@ class AppsViewModel @Inject constructor( private fun queryObserver() { viewModelScope.launch { cacheFlow.combineToLatest(queryFlow) { source, key -> - appsFlow.update { + loadState = LoadState.Ready( source.filter { if (key.isNotBlank()) { it.appLabel.contains(key, ignoreCase = true) @@ -112,13 +117,13 @@ class AppsViewModel @Inject constructor( true } } - } + ) } } } private suspend fun getPackages() = withContext(Dispatchers.IO) { - if (!isAlive) return@withContext emptyList() + if (!serviceRepository.isSucceed) return@withContext emptyList() val allPackages = pm.getInstalledPackages( PackageManager.GET_PERMISSIONS, UserHandleCompat.myUserId() @@ -130,8 +135,6 @@ class AppsViewModel @Inject constructor( it.toIPackageInfo( isAuthorized = it.isAuthorized() ) - }.also { - isLoading = it.isEmpty() } } @@ -164,8 +167,11 @@ class AppsViewModel @Inject constructor( } } - override suspend fun setRequester() = preference.setRequester(packageInfo.packageName) - override suspend fun setExecutor() = preference.setExecutor(packageInfo.packageName) + override suspend fun setRequester() = + preferenceRepository.setRequester(packageInfo.packageName) + + override suspend fun setExecutor() = + preferenceRepository.setExecutor(packageInfo.packageName) } private fun PackageInfo.isAuthorized() = aom.checkOpNoThrow( @@ -178,4 +184,18 @@ class AppsViewModel @Inject constructor( suspend fun setRequester() suspend fun setExecutor() } + + sealed class LoadState { + abstract val apps: List + + data object Pending : LoadState() { + override val apps = emptyList() + } + + data class Ready( + override val apps: List + ) : LoadState() + + val isPending inline get() = this is Pending + } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/InstallViewModel.kt b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/InstallViewModel.kt index ef42114b..77cefcd2 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/InstallViewModel.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/InstallViewModel.kt @@ -1,6 +1,5 @@ package dev.sanmer.pi.viewmodel -import android.app.Application import android.content.Context import android.content.pm.PackageInfo import android.text.format.Formatter @@ -9,14 +8,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import dev.sanmer.pi.PackageInfoCompat.isNotEmpty import dev.sanmer.pi.bundle.SplitConfig import dev.sanmer.pi.compat.VersionCompat.getSdkVersionDiff import dev.sanmer.pi.compat.VersionCompat.getVersionDiff import dev.sanmer.pi.model.IPackageInfo -import dev.sanmer.pi.model.IPackageInfo.Companion.toIPackageInfo +import dev.sanmer.pi.model.IPackageInfo.Default.toIPackageInfo import dev.sanmer.pi.service.InstallService import dev.sanmer.pi.service.InstallService.Task import timber.log.Timber @@ -25,9 +25,8 @@ import javax.inject.Inject @HiltViewModel class InstallViewModel @Inject constructor( - application: Application -) : AndroidViewModel(application) { - private val context: Context by lazy { getApplication() } + @ApplicationContext private val context: Context +) : ViewModel() { private val pm by lazy { context.packageManager } private var archivePath = File(".") diff --git a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/MainViewModel.kt b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/MainViewModel.kt new file mode 100644 index 00000000..64bb3607 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/MainViewModel.kt @@ -0,0 +1,56 @@ +package dev.sanmer.pi.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.sanmer.pi.datastore.model.Preference +import dev.sanmer.pi.datastore.model.Provider +import dev.sanmer.pi.repository.PreferenceRepository +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val preferenceRepository: PreferenceRepository +) : ViewModel() { + var state by mutableStateOf(LoadState.Pending) + private set + + val isPending inline get() = state is LoadState.Pending + val preference inline get() = state.preference + + init { + Timber.d("MainViewModel init") + preferenceObserver() + } + + private fun preferenceObserver() { + viewModelScope.launch { + preferenceRepository.data.collect { + state = LoadState.Ready(it) + } + } + } + + fun setProvider(value: Provider) { + viewModelScope.launch { + preferenceRepository.setProvider(value) + } + } + + sealed class LoadState { + abstract val preference: Preference + + data object Pending : LoadState() { + override val preference = Preference() + } + + data class Ready( + override val preference: Preference + ) : LoadState() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/SettingsViewModel.kt index 4701e441..116ff853 100644 --- a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/SettingsViewModel.kt @@ -3,9 +3,9 @@ package dev.sanmer.pi.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dev.sanmer.pi.Compat import dev.sanmer.pi.datastore.model.Provider import dev.sanmer.pi.repository.PreferenceRepository +import dev.sanmer.pi.repository.ServiceRepository import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber @@ -13,10 +13,10 @@ import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( - private val preference: PreferenceRepository + private val preferenceRepository: PreferenceRepository, + private val serviceRepository: ServiceRepository ) : ViewModel() { - val isAlive get() = Compat.isAlive - val platform get() = Compat.get("") { platform } + val state get() = serviceRepository.state init { Timber.d("SettingsViewModel init") @@ -24,14 +24,14 @@ class SettingsViewModel @Inject constructor( fun setProvider(value: Provider) { viewModelScope.launch { - preference.setProvider(value) + preferenceRepository.setProvider(value) } } - fun tryStart() { + fun restart() { viewModelScope.launch { - val preference = preference.data.first() - Compat.init(preference.provider) + val preference = preferenceRepository.data.first() + serviceRepository.recreate(preference.provider) } } } \ No newline at end of file diff --git a/app/src/main/res/drawable/launcher_foreground.xml b/app/src/main/res/drawable/launcher_foreground.xml index 15fd630a..8ae08191 100644 --- a/app/src/main/res/drawable/launcher_foreground.xml +++ b/app/src/main/res/drawable/launcher_foreground.xml @@ -9,7 +9,13 @@ android:translateX="6.881" android:translateY="6.881"> + - - diff --git a/app/src/main/res/drawable/file_import.xml b/app/src/main/res/drawable/layout_list.xml similarity index 70% rename from app/src/main/res/drawable/file_import.xml rename to app/src/main/res/drawable/layout_list.xml index 1daefc48..935c85cb 100644 --- a/app/src/main/res/drawable/file_import.xml +++ b/app/src/main/res/drawable/layout_list.xml @@ -4,13 +4,13 @@ android:viewportWidth="24" android:viewportHeight="24"> + + + + diff --git a/app/src/main/res/drawable/shield.xml b/app/src/main/res/drawable/shield.xml deleted file mode 100644 index c6f03fca..00000000 --- a/app/src/main/res/drawable/shield.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 2be3c279..3238c08d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1,6 +1,6 @@ - تثبيت + تثبيت وضع العمل الخدمة قيد التشغيل الخدمة لا تعمل @@ -16,7 +16,7 @@ كثافة الشاشة اللغة غير محدد - تم التثبيت بنجاح + تم التثبيت بنجاح فشل التثبيت جاري التحميل… بحث… @@ -24,14 +24,13 @@ خطأ غير معروف ABI يتطلب صلاحيات الروت التي يوفرها Magisk أو KernelSU أو APatch - إضغط لمحاولة البدء + إضغط لمحاولة البدء اللغة إفتراضي تبع النظام فشل تحليل الحزمة - الخدمة غير متاحة - مخول - الطالب - المنفذ + مخول + الطالب + المنفذ PI محدث انقر لفتح البرنامج \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 35bb82bc..f1155d96 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,6 +1,6 @@ - Instalar + Instalar Configuración Modo de trabajo Se requiere permiso de Sui o Shizuku @@ -8,7 +8,7 @@ El servicio se está ejecutando Versión %1$d, %2$s El servicio no está en funcionamiento - Haga clic para intentar iniciar + Haga clic para intentar iniciar Idioma Sistema por defecto Participar en la traducción @@ -19,19 +19,18 @@ Densidad de pantalla Idioma Sin especificar - Servicio no disponible Error al analizar el paquete ABI Lista vacía Instalar servicio Error desconocido - Instalación correcta + Instalación correcta Instalación fallida Cargando… Buscar… - Autorización - Solicitante - Ejecutor + Autorización + Solicitante + Ejecutor PI Actualizado Toca para abrir la app Servicio de análisis diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c6f1e67c..fc540d18 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,6 +1,6 @@ - Installer + Installer Mode de travail Nécessite l\'autorisation fournie par Sui ou Shizuku Nécessite les permissions Root fournies par Magisk, KernelSU ou APatch @@ -9,15 +9,14 @@ Version %1$d, %2$s Service d\'installation Erreur inconnue - Cliquer pour essayer de démarrer + Cliquer pour essayer de démarrer Language Système par défaut Échec de l\'analyse du paquet Paquet d\'installation - Le service n\'est pas disponible Participer à la traduction Aidez nous a traduire PI dans vôtre langue - Installation réussie + Installation réussie Échec de l\'installation Chargement… Rechercher… diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 452227cb..5e5cb23a 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -2,11 +2,11 @@ Setelan Mode kerja - Diotorisasi - Eksekutor + Diotorisasi + Eksekutor Layanan telah berjalan Versi %1$d, %2$s - Klik untuk mencoba memulai + Klik untuk mencoba memulai Bahasa Bawaan sistem Berpartisipasi dalam penerjemahan @@ -16,19 +16,18 @@ ABI Kepadatan layar Memerlukan izin dari Sui atau Shizuku - Pengaju + Pengaju Bahasa Tidak spesifik - Layanan tidak tersedia Parsing paket gagal - Pemasangan berhasil + Pemasangan berhasil Pemasangan gagal Memuat… Cari… Daftar kosong Layanan pemasangan Kesalahan tidak diketahui - Pasang + Pasang Memerlukan izin Root dari Magisk, KernelSU, atau APatch Layanan tidak berjalan Bantu kami menerjemahkan PI ke dalam bahasa Anda diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index c755995a..c1833276 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,7 +1,7 @@ שיטת העבודה - התקנה + התקנה הגדרות דורש הרשאות המסופקות על ידי Sui או Shizuku ארכיטקטורה @@ -9,7 +9,7 @@ חבילת התקנה מבקש ההתקנה צפיפות תצוגה - יש ללחוץ על מנת לנסות להפעיל + יש ללחוץ על מנת לנסות להפעיל השתתפות בתרגום דורשת הרשאת Root שמסופקת על ידי Magisk, KernelSU או APatch השירות פועל @@ -24,12 +24,11 @@ חיפוש… נטען… ההתקנה נכשלה - ההתקנה בוצעה בהצלחה + ההתקנה בוצעה בהצלחה לא מוגדר שפה - השירות אינו זמין ניתוח החבילה כשל - מורשה - מבצע - מבקש + מורשה + מבצע + מבקש \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 87b9415f..6edf7a94 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,6 +1,6 @@ - Instalar + Instalar Modo de trabalho Requer permissão fornecida por Sui ou Shizuku Requer permissões root fornecidas por Magisk, KernelSU ou APatch @@ -11,7 +11,7 @@ Lista vazia Configurações Pacote de instalação - Instalação bem-sucedida + Instalação bem-sucedida Falha na instalação Carregando… Pesquisar… @@ -24,14 +24,13 @@ Não especificado Participe da tradução Ajude-nos a traduzir o PI para o seu idioma - Clique para tentar começar + Clique para tentar começar Idioma Padrão do sistema Falha na análise do pacote - Serviço não disponível - Autorizar - Solicitante - Executor + Autorizar + Solicitante + Executor PI atualizado Toque para abrir o app Análise diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 361d4a7e..9a9dff5e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,6 +1,6 @@ - Instalar + Instalar Modo de trabalho Necessita de permissões root concedida por Magisk, KernelSU ou APatch O serviço está em execução @@ -14,7 +14,7 @@ Pesquisar… Necessita de permissão concedida por Sui ou Shizuku Definições - Instalação bem-sucedida + Instalação bem-sucedida Instalar serviço Erro desconhecido Recurso dinâmico @@ -24,14 +24,13 @@ Não especificado Participe da tradução Ajude-nos a traduzir o PI para o seu idioma - Clique para tentar começar + Clique para tentar começar Idioma Padrão do sistema - Serviço não disponível Falha na análise do pacote - Autorizar - Solicitante - Executor + Autorizar + Solicitante + Executor PI atualizado Toque para abrir o app Serviço de análise diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 120a9bcd..a067e53b 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1,22 +1,22 @@ Necesită permisiuni Root oferite de Magisk, KernelSU, sau APatch - Instalează + Instalează Versiunea %1$d, %2$s Setări Serviciul rulează - Solicitant - Executorul - Autorizează + Solicitant + Executorul + Autorizează Valoarea implicită a sistemului Serviciul nu rulează Participă in traducere Limbă Ajută-ne să traducem PI în limba ta - Apasă pentru a solicita pornirea + Apasă pentru a solicita pornirea ABI Analiza pachetului a eșuat - Instalat cu succes + Instalat cu succes Instalează serviciul Instalarea a eșuat Eroare necunoscută @@ -28,7 +28,6 @@ Modul de lucrare Caracteristică dinamică Nespecificat - Serviciul nu este disponibil Densitatea ecranului Solicitantul de instalare Necesită o permisiune oferită de Sui sau Shizuku diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 39981516..601e51ff 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -5,7 +5,7 @@ Служба работает Служба не запущена Версия %1$d, %2$s - Установка + Установка Требуется разрешение Sui или Shizuku Плотность экрана Язык @@ -14,9 +14,9 @@ Неизвестная ошибка Динамическая функция Неопределённый - Установка завершена + Установка завершена Сервис установки - Нажмите, чтобы попытаться начать + Нажмите, чтобы попытаться начать Язык Системный по умолчанию Участие в переводе @@ -26,12 +26,11 @@ Загрузка… ABI Настройки - Сервис не доступен Сбой синтаксического анализа пакета Поиск… PI Обновлён Нажмите чтоб открыть приложение - Авторизовать - Запросчик - Исполнитель + Авторизовать + Запросчик + Исполнитель \ No newline at end of file diff --git a/app/src/main/res/values-su/strings.xml b/app/src/main/res/values-su/strings.xml index 1d96746e..aa30e372 100644 --- a/app/src/main/res/values-su/strings.xml +++ b/app/src/main/res/values-su/strings.xml @@ -1,24 +1,24 @@ - Pasang + Pasang Kapadetan layar Modeu gawé Ngabutuhkeun idin nu disadiakeun ku Sui atawa Shizuku Ngabutuhkeun idin Root nu disadiakeun ku Magisk, KernelSU, atawa APatch - Diijinkeun - Paménta - Éksékutor + Diijinkeun + Paménta + Éksékutor Pangaturan Layanan geus jalan Vérsi %1$d, %2$s Layanan teu jalan - Klik keur ngajalankeun + Klik keur ngajalankeun Basa Ngamuat… Téang… Parsing pakét gagal Eusi kosong - Pamasangan réngsé + Pamasangan réngsé Pamasangan gagal Bawaan ti sistem Babantu pikeun tarjamahan @@ -29,7 +29,6 @@ ABI Basa Teu puguh rupana - Layanan teu sadia Pasang layanan Error teu dipikanyaho PI Dianyarkeun diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 91802a25..3edf71b5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,17 +1,17 @@ - Yükle + Yükle Çalışma modu Sui veya Shizuku tarafından sağlanan izin gereklidir Magisk, KernelSU veya APatch tarafından sağlanan Root izinleri gerektirir - Talep Eden - Yürütücü - Yetkilendir + Talep Eden + Yürütücü + Yetkilendir Ayarlar Servis çalışıyor Sürüm %1$d, %2$s Servis çalışmıyor - Başlatmayı denemek için tıklayın + Başlatmayı denemek için tıklayın Dil Sistem varsayılanı Çeviriye katıl @@ -23,10 +23,9 @@ Ekran yoğunluğu Dil Belirtilmemiş - Servis mevcut değil Paket ayrıştırma başarısız oldu Servisi yükle - Yükleme başarılı + Yükleme başarılı Yükleme başarısız Yükleniyor… Bilinmeyen hata diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 9724077b..dc27028f 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1,6 +1,6 @@ - Cài đặt + Cài đặt Cần có sự cho phép của Sui hoặc Shizuku Dịch vụ đang chạy Dịch vụ không chạy @@ -15,7 +15,7 @@ Mật độ màn hình Ngôn ngữ Không cụ thể - Cài đặt thành công + Cài đặt thành công Cài đặt thất bại Đang tải… Tìm… diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 30923e61..9fc9f259 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,24 +1,29 @@ - 安装 + 安装 工作模式 需要由 Sui 或 Shizuku 提供权限 - 需要由 Magisk, KernelSU 或 APatch 提供 Root 权限 + 需要由 Magisk,KernelSU 或 APatch 提供 Root 权限 - 请求者 - 执行者 - 授权 + 请求者 + 执行者 + 授权 + 已授权 + 重新优化 + 优化完成 设置 + 服务正在启动 + 请耐心等待 服务正在运行 版本 %1$d,%2$s 服务未运行 - 点击尝试启动 + 点击尝试启动 语言 系统默认 参与翻译 @@ -35,12 +40,12 @@ 安装服务 - 解析服务 - 安装成功 + 安装成功 安装失败 - 服务不可用 + 解析服务 正在解析 解析失败 + 正在优化 加载中… 未知错误 PI 已完成更新 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b26cb15b..0c3574f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - Install + Install Working mode @@ -9,16 +9,21 @@ Requires Root permissions provided by Magisk, KernelSU or APatch - Requester - Executor - Authorize + Requester + Executor + Authorize + Authorized + Re-optimize + Optimized Settings + Service is starting + Please wait Service is running Version %1$d, %2$s Service is not running - Click to try to start + Click to try to restart Language System default Participate in translation @@ -34,13 +39,13 @@ Unspecified - Installation service Parsing service - Installation successful - Installation failed - Service unavailable Parsing Parsing failed + Installation service + Installation successful + Installation failed + Optimizing Loading… Unknown error PI Updated diff --git a/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt b/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt index 991ada2b..0a229cfe 100644 --- a/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt +++ b/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt @@ -15,7 +15,7 @@ class ApplicationConventionPlugin : Plugin { extensions.configure { compileSdk = 35 - buildToolsVersion = "35.0.0" + buildToolsVersion = "35.0.1" defaultConfig { minSdk = 30 diff --git a/build-logic/src/main/kotlin/LibraryConventionPlugin.kt b/build-logic/src/main/kotlin/LibraryConventionPlugin.kt index d3621dcb..ad479e2c 100644 --- a/build-logic/src/main/kotlin/LibraryConventionPlugin.kt +++ b/build-logic/src/main/kotlin/LibraryConventionPlugin.kt @@ -15,7 +15,7 @@ class LibraryConventionPlugin : Plugin { extensions.configure { compileSdk = 35 - buildToolsVersion = "35.0.0" + buildToolsVersion = "35.0.1" defaultConfig { minSdk = 30 diff --git a/build.gradle.kts b/build.gradle.kts index 57f52629..5cc54112 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,12 +9,12 @@ plugins { alias(libs.plugins.ksp) apply false } -task("clean") { +tasks.register("clean") { delete(layout.buildDirectory) } subprojects { - val baseVersionName by extra("1.1.8") + val baseVersionName by extra("1.2.0") apply(plugin = "maven-publish") configure { @@ -36,4 +36,4 @@ subprojects { } } } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/dev/sanmer/pi/ContextCompat.kt b/core/src/main/kotlin/dev/sanmer/pi/ContextCompat.kt index c6e846ba..dacec862 100644 --- a/core/src/main/kotlin/dev/sanmer/pi/ContextCompat.kt +++ b/core/src/main/kotlin/dev/sanmer/pi/ContextCompat.kt @@ -7,9 +7,8 @@ import android.content.ContextWrapper import dev.rikka.tools.refine.Refine object ContextCompat { - val Context.userId get() = - Refine.unsafeCast(this) - .userId + val Context.userId + get() = Refine.unsafeCast(this).userId internal fun getContext(): Context { var context: Context = ActivityThread.currentApplication() diff --git a/core/src/main/kotlin/dev/sanmer/pi/PackageInfoCompat.kt b/core/src/main/kotlin/dev/sanmer/pi/PackageInfoCompat.kt index 609b5481..601444d9 100644 --- a/core/src/main/kotlin/dev/sanmer/pi/PackageInfoCompat.kt +++ b/core/src/main/kotlin/dev/sanmer/pi/PackageInfoCompat.kt @@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi import dev.rikka.tools.refine.Refine object PackageInfoCompat { - internal val PackageInfo.original + private inline val PackageInfo.original get() = Refine.unsafeCast(this) var PackageInfo.versionCodeMajor: Int diff --git a/core/src/main/kotlin/dev/sanmer/pi/PackageParserCompat.kt b/core/src/main/kotlin/dev/sanmer/pi/PackageParserCompat.kt index 47222e48..fb4a1974 100644 --- a/core/src/main/kotlin/dev/sanmer/pi/PackageParserCompat.kt +++ b/core/src/main/kotlin/dev/sanmer/pi/PackageParserCompat.kt @@ -86,16 +86,15 @@ object PackageParserCompat { val apkFiles = cacheDir.listFiles { f -> f.extension == "apk" } ?: throw FileNotFoundException("*.apk") - val splitFiles = mutableListOf() val splitConfigs = mutableListOf() for (apkFile in apkFiles) { if (apkFile.name == BASE_APK) continue val apk = parseApkLite(apkFile) if (apk != null) { - val splitConfig = SplitConfig.parse(apk, apkFile) - splitConfigs.add(splitConfig) - splitFiles.add(apkFile) + splitConfigs.add( + SplitConfig.parse(apk, apkFile) + ) } } diff --git a/core/src/main/kotlin/dev/sanmer/pi/UserHandleCompat.kt b/core/src/main/kotlin/dev/sanmer/pi/UserHandleCompat.kt index e9ab0a3b..56151bd3 100644 --- a/core/src/main/kotlin/dev/sanmer/pi/UserHandleCompat.kt +++ b/core/src/main/kotlin/dev/sanmer/pi/UserHandleCompat.kt @@ -3,7 +3,5 @@ package dev.sanmer.pi import android.os.UserHandleHidden object UserHandleCompat { - fun myUserId(): Int { - return UserHandleHidden.myUserId() - } + fun myUserId() = UserHandleHidden.myUserId() } \ No newline at end of file diff --git a/core/src/main/kotlin/dev/sanmer/pi/bundle/BundleInfo.kt b/core/src/main/kotlin/dev/sanmer/pi/bundle/BundleInfo.kt index df191069..d5d3a727 100644 --- a/core/src/main/kotlin/dev/sanmer/pi/bundle/BundleInfo.kt +++ b/core/src/main/kotlin/dev/sanmer/pi/bundle/BundleInfo.kt @@ -1,10 +1,13 @@ package dev.sanmer.pi.bundle import android.content.pm.PackageInfo +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import java.io.File +@Parcelize data class BundleInfo( val baseFile: File, val baseInfo: PackageInfo, val splitConfigs: List -) \ No newline at end of file +) : Parcelable \ No newline at end of file diff --git a/core/src/main/kotlin/dev/sanmer/pi/delegate/AppOpsManagerDelegate.kt b/core/src/main/kotlin/dev/sanmer/pi/delegate/AppOpsManagerDelegate.kt index 33c0b743..c2ca1665 100644 --- a/core/src/main/kotlin/dev/sanmer/pi/delegate/AppOpsManagerDelegate.kt +++ b/core/src/main/kotlin/dev/sanmer/pi/delegate/AppOpsManagerDelegate.kt @@ -23,9 +23,7 @@ class AppOpsManagerDelegate( } fun checkOpNoThrow(op: Int, uid: Int, packageName: String): Mode { - return Mode.fromCode( - appOpsService.checkOperation(op, uid, packageName) - ) + return Mode.Unsafe(appOpsService.checkOperation(op, uid, packageName)) } fun checkOpNoThrow(op: Int, packageInfo: PackageInfo): Mode { @@ -131,8 +129,8 @@ class AppOpsManagerDelegate( val isDefaulted inline get() = this == Default val isForegrounded inline get() = this == Foreground - internal companion object { - fun fromCode(value: Int) = entries.first { it.code == value } + internal companion object Unsafe { + operator fun invoke(value: Int) = entries.first { it.code == value } } } @@ -153,7 +151,7 @@ class AppOpsManagerDelegate( val ops by lazy { original.ops.map { OpEntry(it) } } } - companion object { + companion object Default { val MODE_ALLOWED get() = AppOpsManager.MODE_ALLOWED val MODE_IGNORED get() = AppOpsManager.MODE_IGNORED diff --git a/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageInstallerDelegate.kt b/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageInstallerDelegate.kt index 4c23e473..ac7f98e4 100644 --- a/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageInstallerDelegate.kt +++ b/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageInstallerDelegate.kt @@ -170,7 +170,7 @@ class PackageInstallerDelegate( original.installFlags = installFlags or flags } - companion object { + companion object Default { val INSTALL_REPLACE_EXISTING get() = PackageManagerHidden.INSTALL_REPLACE_EXISTING val INSTALL_ALLOW_TEST get() = PackageManagerHidden.INSTALL_ALLOW_TEST @@ -182,7 +182,7 @@ class PackageInstallerDelegate( } } - companion object { + companion object Default { suspend fun PackageInstaller.Session.commit() = IntentReceiverCompat.onDelegate { sender -> commit(sender) } diff --git a/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageManagerDelegate.kt b/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageManagerDelegate.kt index b0f33526..8a357084 100644 --- a/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageManagerDelegate.kt +++ b/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageManagerDelegate.kt @@ -5,6 +5,7 @@ import android.content.pm.ApplicationInfo import android.content.pm.IPackageManager import android.content.pm.PackageInfo import android.content.pm.ResolveInfo +import android.os.SystemProperties import dev.sanmer.pi.BuildCompat import dev.sanmer.su.IServiceManager import dev.sanmer.su.ServiceManagerCompat.getSystemService @@ -97,4 +98,19 @@ class PackageManagerDelegate( return intent } + + fun clearApplicationProfileData(packageName: String) { + packageManager.clearApplicationProfileData(packageName) + } + + fun performDexOpt(packageName: String): Boolean { + return packageManager.performDexOptMode( + packageName, + SystemProperties.getBoolean("dalvik.vm.usejitprofiles", false), + SystemProperties.get("pm.dexopt.install", "speed-profile"), + true, + true, + null + ) + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d1d9c5b3..d3341071 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,26 +1,26 @@ [versions] -androidGradlePlugin = "8.7.3" -androidxActivity = "1.9.3" +androidGradlePlugin = "8.9.1" +androidxActivity = "1.10.1" androidxAnnotation = "1.9.1" androidxAppCompat = "1.7.0" -androidxCompose = "1.7.6" -androidxComposeMaterial3 = "1.3.1" -androidxCore = "1.15.0" +androidxCompose = "1.7.8" +androidxComposeMaterial3 = "1.3.2" +androidxCore = "1.16.0" androidxCoreSplashscreen = "1.0.1" -androidxDataStore = "1.1.1" +androidxDataStore = "1.1.4" androidxDocumentFile = "1.0.1" androidxHiltNavigationCompose = "1.2.0" androidxLifecycle = "2.8.7" -androidxNavigation = "2.8.5" -androidxRoom = "2.6.1" +androidxNavigation = "2.8.9" +androidxRoom = "2.7.0" appiconloader = "1.5.0" coil = "2.7.0" hiddenApiRefine = "4.4.0" -hilt = "2.54" -kotlin = "2.1.0" -kotlinxCoroutines = "1.10.1" -kotlinxSerialization = "1.7.3" -ksp = "2.1.0-1.0.29" +hilt = "2.56.1" +kotlin = "2.1.20" +kotlinxCoroutines = "1.10.2" +kotlinxSerialization = "1.8.1" +ksp = "2.1.20-2.0.0" timber = "5.0.1" [libraries] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cea7a793..37f853b1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6d..f3b75f3b 100755 --- a/gradlew +++ b/gradlew @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/stub/src/main/java/android/content/pm/IPackageManager.java b/stub/src/main/java/android/content/pm/IPackageManager.java index 16575177..bc4a7fae 100644 --- a/stub/src/main/java/android/content/pm/IPackageManager.java +++ b/stub/src/main/java/android/content/pm/IPackageManager.java @@ -46,6 +46,10 @@ public interface IPackageManager extends IInterface { int checkPermission(String permName, String pkgName, int userId) throws RemoteException; + boolean performDexOptMode(String packageName, boolean checkProfiles, String targetCompilerFilter, boolean force, boolean bootComplete, String splitName) throws RemoteException; + + void clearApplicationProfileData(String packageName) throws RemoteException; + abstract class Stub extends Binder implements IPackageManager { public static IPackageManager asInterface(IBinder obj) { diff --git a/stub/src/main/java/android/os/SystemProperties.java b/stub/src/main/java/android/os/SystemProperties.java new file mode 100644 index 00000000..1ab67a7b --- /dev/null +++ b/stub/src/main/java/android/os/SystemProperties.java @@ -0,0 +1,26 @@ +package android.os; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class SystemProperties { + public static String get(@NonNull String key) { + throw new UnsupportedOperationException("Stub!"); + } + + public static String get(@NonNull String key, @Nullable String def) { + throw new UnsupportedOperationException("Stub!"); + } + + public static void set(@NonNull String key, @Nullable String val) { + throw new UnsupportedOperationException("Stub!"); + } + + public static boolean getBoolean(@NonNull String key, boolean def) { + throw new UnsupportedOperationException("Stub!"); + } + + public static int getInt(@NonNull String key, int def) { + throw new UnsupportedOperationException("Stub!"); + } +} \ No newline at end of file