diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index bcb83283..bce892d0 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -58,16 +58,16 @@ jobs:
run: |
name=`ls app/build/outputs/apk/release/*.apk | awk -F '(/|.apk)' '{print $6}'` && echo "name=${name}" >> $GITHUB_OUTPUT
- - name: Upload built apk
+ - name: Upload apk
if: success() && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: ${{ steps.release-name.outputs.name }}
- path: app/build/outputs/apk/release/*.apk
+ path: app/build/outputs/apk/release/*.apk*
- - name: Upload mappings
+ - name: Upload mapping
if: success() && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
- name: mappings
+ name: ${{ steps.release-name.outputs.name }}-mapping
path: app/build/outputs/mapping/release
diff --git a/README.md b/README.md
index 8562c746..b6554457 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,8 @@
# PI
[](https://github.com/SanmerApps/PI/releases) [](https://github.com/SanmerApps/PI/releases/latest) [](https://weblate.sanmer.app/engage/pi/)
-PI is short for `Package Installer`.
-
## Supported Versions
-Android 10 ~ 14
+Android 11 ~ 15
## Credits
- [tabler/tabler-icons](https://github.com/tabler/tabler-icons.git)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 54dfcc37..d084316b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -31,6 +31,7 @@ android {
"pt",
"pt-rBR",
"ru",
+ "su",
"vi",
"zh-rCN"
)
@@ -42,8 +43,8 @@ android {
storePassword = project.releaseKeyStorePassword
keyAlias = project.releaseKeyAlias
keyPassword = project.releaseKeyPassword
- enableV2Signing = true
enableV3Signing = true
+ enableV4Signing = true
}
} else {
signingConfigs.getByName("debug")
@@ -74,10 +75,8 @@ android {
"META-INF/**",
"okhttp3/**",
"kotlin/**",
- "org/**",
- "**.properties",
"**.bin",
- "**/*.proto"
+ "**.properties"
)
dependenciesInfo.includeInApk = false
@@ -98,14 +97,15 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.compose.ui.util)
- implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.core)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.datastore.core)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.service)
- implementation(libs.androidx.lifecycle.viewModel.compose)
+ implementation(libs.androidx.lifecycle.viewmodel)
+ implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.androidx.navigation.compose)
implementation(libs.appiconloader)
implementation(libs.appiconloader.coil)
@@ -115,4 +115,4 @@ dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.protobuf)
implementation(libs.timber)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 05dbbf17..906719a4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,7 +4,7 @@
-
+
-
-
-
-
-
-
-
diff --git a/app/src/main/kotlin/dev/sanmer/pi/App.kt b/app/src/main/kotlin/dev/sanmer/pi/App.kt
index 1c7be41a..f03203da 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/App.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/App.kt
@@ -39,7 +39,7 @@ class App : Application(), ImageLoaderFactory {
val channels = listOf(
NotificationChannel(
Const.CHANNEL_ID_INSTALL,
- context.getString(R.string.notification_name_install),
+ context.getString(R.string.install_service),
NotificationManager.IMPORTANCE_HIGH
)
)
diff --git a/app/src/main/kotlin/dev/sanmer/pi/Const.kt b/app/src/main/kotlin/dev/sanmer/pi/Const.kt
index 7bcedac6..d6c9db8d 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/Const.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/Const.kt
@@ -1,11 +1,8 @@
package dev.sanmer.pi
object Const {
- // Url
const val GITHUB_URL = "https://github.com/SanmerApps/PI"
const val TRANSLATE_URL = "https://weblate.sanmer.app/engage/pi"
- // Notification
const val CHANNEL_ID_INSTALL = "INSTALL"
- const val NOTIFICATION_ID_INSTALL = 1024
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/pi/compat/BuildCompat.kt b/app/src/main/kotlin/dev/sanmer/pi/compat/BuildCompat.kt
index 8036a118..46004540 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/compat/BuildCompat.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/compat/BuildCompat.kt
@@ -5,14 +5,11 @@ import androidx.annotation.ChecksSdkIntAtLeast
object BuildCompat {
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
- val atLeastU get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+ val atLeastU inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
- val atLeastT get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ val atLeastT inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
- val atLeastS get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
-
- @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R)
- val atLeastR get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ val atLeastS inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/pi/compat/MediaStoreCompat.kt b/app/src/main/kotlin/dev/sanmer/pi/compat/MediaStoreCompat.kt
index c0ee0d4e..e4dd2119 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/compat/MediaStoreCompat.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/compat/MediaStoreCompat.kt
@@ -4,20 +4,14 @@ import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.net.Uri
-import android.os.Build
-import android.os.Environment
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.system.Os
-import androidx.annotation.RequiresApi
import androidx.core.net.toFile
-import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import java.io.File
-import java.io.IOException
object MediaStoreCompat {
- @RequiresApi(Build.VERSION_CODES.R)
fun Context.createMediaStoreUri(
file: File,
collection: Uri = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL),
@@ -29,33 +23,7 @@ object MediaStoreCompat {
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
}
- return contentResolver.insert(collection, entry) ?: throw IOException("Cannot insert $file")
- }
-
- private fun createDownloadUri(
- path: String
- ) = Environment.getExternalStoragePublicDirectory(
- Environment.DIRECTORY_DOWNLOADS
- ).let {
- val file = File(it, path)
- file.parentFile?.apply { if (!exists()) mkdirs() }
- file.toUri()
- }
-
- fun Context.createDownloadUri(
- path: String,
- mimeType: String
- ) = when {
- BuildCompat.atLeastR -> runCatching {
- createMediaStoreUri(
- file = File(Environment.DIRECTORY_DOWNLOADS, path),
- mimeType = mimeType
- )
- }.getOrElse {
- createDownloadUri(path)
- }
-
- else -> createDownloadUri(path)
+ return requireNotNull(contentResolver.insert(collection, entry))
}
private fun ContentResolver.queryString(uri: Uri, columnName: String): String? {
@@ -118,8 +86,7 @@ object MediaStoreCompat {
require(uri.scheme == "content") { "Uri lacks 'content' scheme: $uri" }
contentResolver.openFileDescriptor(
- getDocumentUri(this, uri),
- "r"
+ getDocumentUri(this, uri), "r"
)?.use {
return Os.readlink("/proc/self/fd/${it.fd}")
}
diff --git a/app/src/main/kotlin/dev/sanmer/pi/datastore/UserPreferencesDataSource.kt b/app/src/main/kotlin/dev/sanmer/pi/datastore/UserPreferencesDataSource.kt
index 90854a3e..5d832bb9 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/datastore/UserPreferencesDataSource.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/datastore/UserPreferencesDataSource.kt
@@ -20,14 +20,6 @@ class UserPreferencesDataSource @Inject constructor(
}
}
- suspend fun setDynamicColor(value: Boolean) = withContext(Dispatchers.IO) {
- userPreferences.updateData {
- it.copy(
- dynamicColor = value
- )
- }
- }
-
suspend fun setRequester(value: String) = withContext(Dispatchers.IO) {
userPreferences.updateData {
it.copy(
diff --git a/app/src/main/kotlin/dev/sanmer/pi/datastore/model/UserPreferences.kt b/app/src/main/kotlin/dev/sanmer/pi/datastore/model/UserPreferences.kt
index 6a677263..00d1449f 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/datastore/model/UserPreferences.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/datastore/model/UserPreferences.kt
@@ -1,19 +1,21 @@
package dev.sanmer.pi.datastore.model
import dev.sanmer.pi.BuildConfig
-import dev.sanmer.pi.compat.BuildCompat
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
+import kotlinx.serialization.protobuf.ProtoNumber
import java.io.InputStream
import java.io.OutputStream
@Serializable
data class UserPreferences(
+ @ProtoNumber(1)
val provider: Provider = Provider.None,
- val dynamicColor: Boolean = BuildCompat.atLeastS,
+ @ProtoNumber(3)
val requester: String = BuildConfig.APPLICATION_ID,
+ @ProtoNumber(4)
val executor: String = BuildConfig.APPLICATION_ID
) {
fun encodeTo(output: OutputStream) = output.write(
diff --git a/app/src/main/kotlin/dev/sanmer/pi/repository/UserPreferencesRepository.kt b/app/src/main/kotlin/dev/sanmer/pi/repository/UserPreferencesRepository.kt
index 314fedf3..d0eb5d0d 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/repository/UserPreferencesRepository.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/repository/UserPreferencesRepository.kt
@@ -13,8 +13,6 @@ class UserPreferencesRepository @Inject constructor(
suspend fun setProvider(value: Provider) = userPreferencesDataSource.setProvider(value)
- suspend fun setDynamicColor(value: Boolean) = userPreferencesDataSource.setDynamicColor(value)
-
suspend fun setRequester(value: String) = userPreferencesDataSource.setRequester(value)
suspend fun setExecutor(value: String) = userPreferencesDataSource.setExecutor(value)
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 21b26e44..deaca905 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/service/InstallService.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/service/InstallService.kt
@@ -9,6 +9,7 @@ import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.SessionInfo
+import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.os.Process
import androidx.core.app.NotificationCompat
@@ -32,33 +33,43 @@ import dev.sanmer.pi.ktx.parcelable
import dev.sanmer.pi.ktx.tmpDir
import dev.sanmer.pi.repository.UserPreferencesRepository
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import me.zhanghai.android.appiconloader.AppIconLoader
import timber.log.Timber
import java.io.File
import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
@AndroidEntryPoint
class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallback {
@Inject
lateinit var userPreferencesRepository: UserPreferencesRepository
- private val appIconLoader by lazy {
- AppIconLoader(45.dp, true, this)
- }
+ private val appIconLoader by lazy { AppIconLoader(45.dp, true, this) }
+ private val notificationManager by lazy { NotificationManagerCompat.from(this) }
private val pm by lazy { Compat.getPackageManager() }
private val pi by lazy { Compat.getPackageInstaller() }
- private val tasks = mutableListOf()
+ init {
+ lifecycleScope.launch {
+ while (currentCoroutineContext().isActive) {
+ delay(5.seconds)
+ if (pendingTask.isEmpty()) stopSelf()
+ }
+ }
+ }
override fun onCreated(sessionId: Int) {
val session = pi.getSessionInfo(sessionId)
Timber.i("onCreated<$sessionId>: ${session?.appPackageName}")
- tasks.add(sessionId)
- onProgressChanged(
+ notifyProgress(
id = sessionId,
appLabel = session?.label ?: sessionId.toString(),
appIcon = session?.appIcon,
@@ -69,7 +80,7 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
override fun onProgressChanged(sessionId: Int, progress: Float) {
val session = pi.getSessionInfo(sessionId)
- onProgressChanged(
+ notifyProgress(
id = sessionId,
appLabel = session?.label ?: sessionId.toString(),
appIcon = session?.appIcon,
@@ -77,15 +88,6 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
)
}
- override fun onFinished(sessionId: Int, success: Boolean) {
- Timber.i("onFinished<$sessionId>: success = $success")
- tasks.remove(sessionId)
-
- if (tasks.isEmpty()) {
- stopSelf()
- }
- }
-
private val SessionInfo.label get() =
appLabel ?: appPackageName ?: sessionId.toString()
@@ -107,69 +109,79 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val sticky = super.onStartCommand(intent, flags, startId)
+ val archivePath = intent?.archivePathOrNull ?: return sticky
+ val archiveInfo = intent.archiveInfoOrNull ?: return sticky
+ val filenames = intent.filenames
+
lifecycleScope.launch(Dispatchers.IO) {
- val archivePath = intent?.archivePathOrNull ?: return@launch
- val archiveInfo = intent.archiveInfoOrNull ?: return@launch
- val filenames = intent.filenames
-
- val appIcon = archiveInfo.applicationInfo?.let(appIconLoader::loadIcon)
- val appLabel = archiveInfo.applicationInfo?.loadLabel(packageManager)
- ?: archiveInfo.packageName
-
- val userPreferences = userPreferencesRepository.data.first()
- val originatingUid = getPackageUid(userPreferences.requester)
- pi.setInstallerPackageName(userPreferences.executor)
-
- val params = createSessionParams()
- params.setAppIcon(appIcon)
- params.setAppLabel(appLabel)
- params.setAppPackageName(archiveInfo.packageName)
- if (originatingUid != Process.INVALID_UID) {
- params.setOriginatingUid(originatingUid)
- }
+ install(archivePath, archiveInfo, filenames)
+ pendingTask.removeAt(0)
+ }
- val sessionId = pi.createSession(params)
- val session = pi.openSession(sessionId)
+ return sticky
+ }
- when {
- archivePath.isDirectory -> {
- session.writeApks(archivePath, filenames)
- }
+ private suspend fun install(
+ archivePath: File,
+ archiveInfo: PackageInfo,
+ filenames: List
+ ) = withContext(Dispatchers.IO) {
+ val appIcon = archiveInfo.applicationInfo?.let(appIconLoader::loadIcon)
+ val appLabel = archiveInfo.applicationInfo?.loadLabel(packageManager)
+ ?: archiveInfo.packageName
+
+ val userPreferences = userPreferencesRepository.data.first()
+ val originatingUid = getPackageUid(userPreferences.requester)
+ pi.setInstallerPackageName(userPreferences.executor)
+
+ val params = createSessionParams()
+ params.setAppIcon(appIcon)
+ params.setAppLabel(appLabel)
+ params.setAppPackageName(archiveInfo.packageName)
+ if (originatingUid != Process.INVALID_UID) {
+ params.setOriginatingUid(originatingUid)
+ }
- archivePath.isFile -> {
- session.writeApk(archivePath)
- }
- }
+ val sessionId = pi.createSession(params)
+ val session = pi.openSession(sessionId)
- val result = session.commit()
- val status = result.getIntExtra(
- PackageInstaller.EXTRA_STATUS,
- PackageInstaller.STATUS_FAILURE
- )
+ when {
+ archivePath.isDirectory -> {
+ session.writeApks(archivePath, filenames)
+ }
- when (status) {
- PackageInstaller.STATUS_SUCCESS -> {
- onInstallSucceeded(
- id = sessionId,
- appLabel = appLabel,
- appIcon = appIcon,
- packageName = archiveInfo.packageName
- )
- }
-
- else -> {
- val msg = result.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
- Timber.e("onFailed<${archiveInfo.packageName}>: $msg")
- onInstallFailed(
- id = sessionId,
- appLabel = appLabel,
- appIcon = appIcon,
- )
- }
+ archivePath.isFile -> {
+ session.writeApk(archivePath)
}
}
- return super.onStartCommand(intent, flags, startId)
+ val result = session.commit()
+ val status = result.getIntExtra(
+ PackageInstaller.EXTRA_STATUS,
+ PackageInstaller.STATUS_FAILURE
+ )
+
+ when (status) {
+ PackageInstaller.STATUS_SUCCESS -> {
+ notifySuccess(
+ id = sessionId,
+ appLabel = appLabel,
+ appIcon = appIcon,
+ packageName = archiveInfo.packageName
+ )
+ }
+
+ else -> {
+ val msg = result.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ Timber.e("onFailed<${archiveInfo.packageName}>: $msg")
+ notifyFailure(
+ id = sessionId,
+ appLabel = appLabel,
+ appIcon = appIcon,
+ )
+ }
+ }
}
private fun createSessionParams(): PackageInstaller.SessionParams {
@@ -200,13 +212,30 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
Process.INVALID_UID
)
- private fun onProgressChanged(
+ private fun setForeground() {
+ val notification = newNotificationBuilder()
+ .setContentTitle(getText(R.string.install_service))
+ .setSilent(true)
+ .setOngoing(true)
+ .setGroup(GROUP_KEY)
+ .setGroupSummary(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ notification.hashCode(),
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ )
+ }
+
+ private fun notifyProgress(
id: Int,
appLabel: CharSequence,
appIcon: Bitmap?,
progress: Float
) {
- val notification = baseNotificationBuilder()
+ val notification = newNotificationBuilder()
.setLargeIcon(appIcon)
.setContentTitle(appLabel)
.setProgress(100, (100 * progress).toInt(), false)
@@ -218,7 +247,7 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
notify(id, notification)
}
- private fun onInstallSucceeded(
+ private fun notifySuccess(
id: Int,
appLabel: CharSequence,
appIcon: Bitmap?,
@@ -230,10 +259,10 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
)
}
- val notification = baseNotificationBuilder()
+ val notification = newNotificationBuilder()
.setLargeIcon(appIcon)
.setContentTitle(appLabel)
- .setContentText(getString(R.string.message_install_success))
+ .setContentText(getText(R.string.message_install_success))
.setContentIntent(intent)
.setSilent(true)
.setAutoCancel(true)
@@ -242,34 +271,22 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
notify(id, notification)
}
- private fun onInstallFailed(
+ private fun notifyFailure(
id: Int,
appLabel: CharSequence,
appIcon: Bitmap?
) {
- val notification = baseNotificationBuilder()
+ val notification = newNotificationBuilder()
.setLargeIcon(appIcon)
.setContentTitle(appLabel)
- .setContentText(getString(R.string.message_install_fail))
+ .setContentText(getText(R.string.message_install_fail))
.build()
notify(id, notification)
}
- private fun setForeground() {
- val notification = baseNotificationBuilder()
- .setContentTitle(getString(R.string.notification_name_install))
- .setSilent(true)
- .setOngoing(true)
- .setGroup(GROUP_KEY)
- .setGroupSummary(true)
- .build()
-
- startForeground(Const.NOTIFICATION_ID_INSTALL, notification)
- }
-
- private fun baseNotificationBuilder() =
- NotificationCompat.Builder(this, Const.CHANNEL_ID_INSTALL)
+ private fun newNotificationBuilder() =
+ NotificationCompat.Builder(applicationContext, Const.CHANNEL_ID_INSTALL)
.setSmallIcon(R.drawable.launcher_outline)
@SuppressLint("MissingPermission")
@@ -280,13 +297,13 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
true
}
- NotificationManagerCompat.from(this).apply {
- if (granted) notify(id, notification)
+ if (granted) {
+ notificationManager.notify(id, notification)
}
}
companion object {
- private const val GROUP_KEY = "INSTALL_SERVICE_GROUP_KEY"
+ private const val GROUP_KEY = "dev.sanmer.pi.INSTALL_SERVICE_GROUP_KEY"
private const val EXTRA_ARCHIVE_PATH = "dev.sanmer.pi.extra.ARCHIVE_PATH"
private val Intent.archivePathOrNull: File?
@@ -300,6 +317,8 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
private val Intent.filenames: List
get() = getStringArrayExtra(EXTRA_ARCHIVE_FILENAMES)?.toList() ?: emptyList()
+ private val pendingTask = mutableListOf()
+
fun start(
context: Context,
archivePath: File,
@@ -311,6 +330,7 @@ class InstallService : LifecycleService(), PackageInstallerDelegate.SessionCallb
intent.putExtra(EXTRA_ARCHIVE_INFO, archiveInfo)
intent.putExtra(EXTRA_ARCHIVE_FILENAMES, filenames.toTypedArray())
+ pendingTask.add(archiveInfo.packageName)
context.startService(intent)
}
}
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 d7d90a5b..936c4a04 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/ui/InstallActivity.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/ui/InstallActivity.kt
@@ -55,15 +55,13 @@ class InstallActivity : ComponentActivity() {
val preferences = if (userPreferences == null) {
return@setContent
} else {
- checkNotNull(userPreferences)
+ requireNotNull(userPreferences)
}
CompositionLocalProvider(
LocalUserPreferences provides preferences
) {
- AppTheme(
- dynamicColor = preferences.dynamicColor
- ) {
+ AppTheme {
InstallScreen()
}
}
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 e420b9f5..fe6f6c54 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/ui/MainActivity.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/ui/MainActivity.kt
@@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() {
return@setContent
} else {
isLoading = false
- checkNotNull(userPreferences)
+ requireNotNull(userPreferences)
}
LaunchedEffect(userPreferences) {
@@ -56,9 +56,7 @@ class MainActivity : ComponentActivity() {
CompositionLocalProvider(
LocalUserPreferences provides preferences
) {
- AppTheme(
- dynamicColor = preferences.dynamicColor
- ) {
+ AppTheme {
Crossfade(
targetState = preferences.provider != Provider.None,
label = "MainActivity"
diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/PermissionActivity.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/PermissionActivity.kt
deleted file mode 100644
index 6cac5762..00000000
--- a/app/src/main/kotlin/dev/sanmer/pi/ui/PermissionActivity.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-package dev.sanmer.pi.ui
-
-import android.content.Intent
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.viewModels
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.getValue
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import dagger.hilt.android.AndroidEntryPoint
-import dev.sanmer.pi.repository.UserPreferencesRepository
-import dev.sanmer.pi.ui.main.PermissionScreen
-import dev.sanmer.pi.ui.provider.LocalUserPreferences
-import dev.sanmer.pi.ui.theme.AppTheme
-import dev.sanmer.pi.viewmodel.PermissionViewModel
-import kotlinx.coroutines.launch
-import timber.log.Timber
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class PermissionActivity : ComponentActivity() {
- @Inject
- lateinit var userPreferencesRepository: UserPreferencesRepository
-
- private val viewModel: PermissionViewModel by viewModels()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- Timber.d("onCreate")
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
-
- when {
- intent.isOk -> init()
- else -> finish()
- }
-
- setContent {
- val userPreferences by userPreferencesRepository.data
- .collectAsStateWithLifecycle(initialValue = null)
-
- val preferences = if (userPreferences == null) {
- return@setContent
- } else {
- checkNotNull(userPreferences)
- }
-
- CompositionLocalProvider(
- LocalUserPreferences provides preferences
- ) {
- AppTheme(
- dynamicColor = preferences.dynamicColor
- ) {
- PermissionScreen()
- }
- }
- }
- }
-
- override fun finish() {
- Intent().apply {
- putExtra(EXTRA_PERMISSIONS, viewModel.permissions.toTypedArray())
- putExtra(EXTRA_PERMISSION_GRANT_RESULTS, viewModel.permissionResults())
- setResult(RESULT_OK, this)
- }
-
- super.finish()
- }
-
- override fun onDestroy() {
- Timber.d("onDestroy")
- super.onDestroy()
- }
-
- private fun init() {
- val packageName = checkNotNull(callingPackage)
- val permissions = intent.permissions.toList()
-
- lifecycleScope.launch {
- if (!viewModel.load(packageName, permissions)) {
- finish()
- }
- }
- }
-
- companion object {
- const val ACTION_REQUEST_PERMISSIONS = "dev.sanmer.pi.action.REQUEST_PERMISSIONS"
- const val EXTRA_PERMISSIONS = "dev.sanmer.pi.extra.PERMISSIONS"
- const val EXTRA_PERMISSION_GRANT_RESULTS = "dev.sanmer.pi.extra.PERMISSION_GRANT_RESULTS"
-
- private val Intent.permissions: Array
- get() = getStringArrayExtra(EXTRA_PERMISSIONS) ?: emptyArray()
-
- private val Intent.isOk: Boolean
- get() = action == ACTION_REQUEST_PERMISSIONS
- || permissions.isNotEmpty()
-
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/component/BottomSheetLayout.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/component/BottomSheetLayout.kt
index 99d510d9..c616ac4b 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/ui/component/BottomSheetLayout.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/ui/component/BottomSheetLayout.kt
@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.contentColorFor
@@ -17,11 +19,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
@@ -32,6 +36,7 @@ fun BottomSheetLayout(
modifier: Modifier = Modifier,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
+ sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
contentWindowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
@@ -41,12 +46,15 @@ fun BottomSheetLayout(
modifier = modifier
.background(scrimColor)
.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom
) {
var edgeToTop by remember { mutableStateOf(false) }
SubcomposeLayout(
modifier = Modifier
+ .widthIn(max = sheetMaxWidth)
+ .fillMaxWidth()
.background(
color = containerColor,
shape = shape
diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/component/PageIndicator.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/component/PageIndicator.kt
index d2899559..5672e486 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/ui/component/PageIndicator.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/ui/component/PageIndicator.kt
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -30,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import dev.sanmer.pi.R
@@ -38,11 +38,11 @@ fun PageIndicator(
icon: @Composable ColumnScope.() -> Unit,
text: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
- minHeight: Dp? = null
+ height: Dp = Dp.Unspecified
) = Column(
- modifier = modifier then (if (minHeight != null) {
+ modifier = modifier then (if (height.isSpecified) {
Modifier
- .defaultMinSize(minHeight = minHeight)
+ .height(height = height)
.fillMaxWidth()
} else {
Modifier.fillMaxSize()
@@ -62,7 +62,7 @@ fun PageIndicator(
@DrawableRes icon: Int,
text: String,
modifier: Modifier = Modifier,
- minHeight: Dp? = null
+ height: Dp = Dp.Unspecified
) = PageIndicator(
modifier = modifier,
icon = {
@@ -81,7 +81,7 @@ fun PageIndicator(
overflow = TextOverflow.Ellipsis
)
},
- minHeight = minHeight
+ height = height
)
@Composable
@@ -89,22 +89,22 @@ fun PageIndicator(
@DrawableRes icon: Int,
@StringRes text: Int,
modifier: Modifier = Modifier,
- minHeight: Dp? = null
+ height: Dp = Dp.Unspecified
) = PageIndicator(
modifier = modifier,
icon = icon,
text = stringResource(id = text),
- minHeight = minHeight
+ height = height
)
@Composable
fun Loading(
modifier: Modifier = Modifier,
- minHeight: Dp? = null
+ height: Dp = Dp.Unspecified
) = PageIndicator(
icon = {
CircularProgressIndicator(
- modifier = Modifier.size(50.dp),
+ modifier = Modifier.size(PageIndicatorDefaults.IconSize * (3f/4f)),
strokeWidth = 5.dp,
strokeCap = StrokeCap.Round
)
@@ -117,23 +117,23 @@ fun Loading(
)
},
modifier = modifier,
- minHeight = minHeight
+ height = height
)
@Composable
fun Failed(
message: String?,
modifier: Modifier = Modifier,
- minHeight: Dp? = null
+ height: Dp = Dp.Unspecified
) = PageIndicator(
icon = R.drawable.alert_triangle,
text = message ?: stringResource(id = R.string.unknown_error),
modifier = modifier,
- minHeight = minHeight
+ height = height
)
object PageIndicatorDefaults {
- val IconSize = 80.dp
+ val IconSize = 64.dp
val IconColor @Composable get() = MaterialTheme.colorScheme.outline.copy(0.5f)
val TextStyle @Composable get() = TextStyle(
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 183df1b8..90338e98 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
@@ -93,17 +93,17 @@ fun InstallScreen(
) { state ->
when (state) {
State.None -> Loading(
- minHeight = 240.dp
+ height = 240.dp
)
State.InvalidProvider -> Failed(
message = stringResource(id = R.string.install_invalid_provider),
- minHeight = 240.dp
+ height = 240.dp
)
State.InvalidPackage -> Failed(
message = stringResource(id = R.string.install_invalid_package),
- minHeight = 240.dp
+ height = 240.dp
)
else -> InstallContent()
diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/main/MainScreen.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/main/MainScreen.kt
index 20dc2fb2..c924ed6a 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/ui/main/MainScreen.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/ui/main/MainScreen.kt
@@ -1,18 +1,21 @@
package dev.sanmer.pi.ui.main
+import androidx.compose.animation.AnimatedContentScope
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
-import dev.sanmer.pi.ui.main.Screen.Companion.apps
-import dev.sanmer.pi.ui.main.Screen.Companion.settings
-import dev.sanmer.pi.ui.main.Screen.Companion.workingMode
import dev.sanmer.pi.ui.screens.apps.AppsScreen
import dev.sanmer.pi.ui.screens.settings.SettingsScreen
import dev.sanmer.pi.ui.screens.workingmode.WorkingModeScreen
@@ -26,56 +29,49 @@ fun MainScreen() {
) {
NavHost(
navController = navController,
- startDestination = Screen.Apps.route
+ startDestination = Screen.Apps()
) {
- apps(navController)
- settings(navController)
- workingMode(navController)
+ Screen.Apps(navController).addTo(this)
+ Screen.Settings(navController).addTo(this)
+ Screen.WorkingMode(navController).addTo(this)
}
}
}
-enum class Screen(val route: String) {
- Apps("Apps"),
- Settings("Settings"),
- WorkingMode("WorkingMode");
+sealed class Screen(
+ private val route: String,
+ private val content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
+ private val arguments: List = emptyList(),
+ private val enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition) = { fadeIn() },
+ private val exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition) = { fadeOut() },
+) {
+ fun addTo(builder: NavGraphBuilder) = builder.composable(
+ route = this@Screen.route,
+ arguments = this@Screen.arguments,
+ enterTransition = this@Screen.enterTransition,
+ exitTransition = this@Screen.exitTransition,
+ content = this@Screen.content
+ )
- companion object {
+ @Suppress("FunctionName")
+ companion object Routes {
+ fun Apps() = "Apps"
+ fun Settings() = "Settings"
+ fun WorkingMode() = "WorkingMode"
+ }
- fun NavGraphBuilder.apps(
- navController: NavController
- ) = composable(
- route = Apps.route,
- enterTransition = { fadeIn() },
- exitTransition = { fadeOut() }
- ) {
- AppsScreen(
- navController = navController
- )
- }
+ class Apps(navController: NavController) : Screen(
+ route = Apps(),
+ content = { AppsScreen(navController = navController) }
+ )
- fun NavGraphBuilder.settings(
- navController: NavController
- ) = composable(
- route = Settings.route,
- enterTransition = { fadeIn() },
- exitTransition = { fadeOut() }
- ) {
- SettingsScreen(
- navController = navController
- )
- }
+ class Settings(navController: NavController) : Screen(
+ route = Settings(),
+ content = { SettingsScreen(navController = navController) }
+ )
- fun NavGraphBuilder.workingMode(
- navController: NavController
- ) = composable(
- route = WorkingMode.route,
- enterTransition = { fadeIn() },
- exitTransition = { fadeOut() }
- ) {
- WorkingModeScreen(
- navController = navController
- )
- }
- }
+ class WorkingMode(navController: NavController) : Screen(
+ route = WorkingMode(),
+ content = { WorkingModeScreen(navController = navController) }
+ )
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/main/PermissionScreen.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/main/PermissionScreen.kt
deleted file mode 100644
index 833d449b..00000000
--- a/app/src/main/kotlin/dev/sanmer/pi/ui/main/PermissionScreen.kt
+++ /dev/null
@@ -1,226 +0,0 @@
-package dev.sanmer.pi.ui.main
-
-import androidx.activity.compose.BackHandler
-import androidx.compose.animation.Crossfade
-import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material3.Button
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.OutlinedCard
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextDecoration
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import dev.sanmer.pi.R
-import dev.sanmer.pi.ktx.finishActivity
-import dev.sanmer.pi.model.IPackageInfo
-import dev.sanmer.pi.ui.component.BottomSheetLayout
-import dev.sanmer.pi.ui.component.Loading
-import dev.sanmer.pi.ui.ktx.bottom
-import dev.sanmer.pi.ui.screens.apps.component.AppItem
-import dev.sanmer.pi.viewmodel.PermissionViewModel
-
-@Composable
-fun PermissionScreen(
- viewModel: PermissionViewModel = hiltViewModel()
-) {
- val context = LocalContext.current
-
- BackHandler {
- context.finishActivity()
- }
-
- BottomSheetLayout(
- bottomBar = { bottomPadding ->
- if (!viewModel.isLoading) {
- BottomBar(
- modifier = Modifier
- .padding(bottomPadding)
- .padding(horizontal = 20.dp)
- .padding(bottom = 20.dp),
- onGrant = viewModel::grantPermissions
- )
- }
- },
- shape = MaterialTheme.shapes.large.bottom(0.dp)
- ) { contentPadding ->
- Crossfade(
- modifier = Modifier
- .animateContentSize()
- .padding(contentPadding)
- .padding(all = 20.dp),
- targetState = viewModel.isLoading,
- label = "PermissionScreen"
- ) { isLoading ->
- when {
- isLoading -> Loading(
- minHeight = 240.dp
- )
-
- else -> PermissionContent()
- }
- }
- }
-}
-
-@Composable
-private fun BottomBar(
- modifier: Modifier = Modifier,
- onGrant: () -> Unit
-) = Row(
- modifier = modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(20.dp)
-) {
- Spacer(modifier = Modifier.weight(1f))
-
- val context = LocalContext.current
- OutlinedButton(
- onClick = {
- context.finishActivity()
- }
- ) {
- Text(text = stringResource(id = R.string.permission_button_deny))
- }
-
- Button(
- onClick = {
- onGrant()
- context.finishActivity()
- }
- ) {
- Text(text = stringResource(id = R.string.permission_button_grant))
- }
-}
-
-@Composable
-private fun PermissionContent(
- modifier: Modifier = Modifier,
- viewModel: PermissionViewModel = hiltViewModel()
-) = Column(
- modifier = modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
-) {
- AppItem(
- packageInfo = viewModel.packageInfo
- )
-
- PermissionsItem(
- permissions = viewModel.permissions,
- isRequiredPermission = viewModel::isRequiredPermission,
- togglePermission = viewModel::togglePermission
- )
-}
-
-@Composable
-private fun AppItem(
- packageInfo: IPackageInfo,
-) = TittleItem(
- text = stringResource(id = R.string.permission_app_title)
-) {
- Surface(
- shape = MaterialTheme.shapes.large,
- tonalElevation = 6.dp,
- border = CardDefaults.outlinedCardBorder()
- ) {
- AppItem(
- pi = packageInfo,
- enabled = false,
- iconSize = 45.dp,
- iconEnd = 15.dp,
- contentPaddingValues = PaddingValues(15.dp),
- verticalAlignment = Alignment.Top
- )
- }
-}
-
-@Composable
-private fun PermissionsItem(
- permissions: List,
- isRequiredPermission: (String) -> Boolean,
- togglePermission: (String) -> Unit
-) {
- val state = rememberLazyListState()
- LazyColumn(
- state = state,
- verticalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- item {
- TittleItem(text = stringResource(id = R.string.permission_permissions_title))
- }
- items(
- items = permissions
- ) {
- PermissionItem(
- permission = it,
- isRequiredPermission = isRequiredPermission,
- togglePermission = togglePermission
- )
- }
- }
-}
-
-@Composable
-private fun PermissionItem(
- permission: String,
- isRequiredPermission: (String) -> Boolean,
- togglePermission: (String) -> Unit,
-) {
- val required by remember {
- derivedStateOf { isRequiredPermission(permission) }
- }
-
- OutlinedCard(
- shape = MaterialTheme.shapes.medium,
- onClick = { togglePermission(permission) }
- ) {
- Row(
- modifier = Modifier
- .padding(vertical = 10.dp, horizontal = 15.dp)
- .fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- painter = painterResource(id = R.drawable.code),
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Column(
- modifier = Modifier.padding(start = 10.dp)
- ) {
- Text(
- text = permission,
- style = MaterialTheme.typography.bodyMedium,
- textDecoration = when {
- !required -> TextDecoration.LineThrough
- else -> TextDecoration.None
- },
- )
- }
- }
- }
-}
\ No newline at end of file
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
new file mode 100644
index 00000000..e6e62a05
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/pi/ui/provider/LocalSnackbarHostState.kt
@@ -0,0 +1,6 @@
+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 f1a9419d..0a8e55cb 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
@@ -2,12 +2,16 @@ package dev.sanmer.pi.ui.screens.apps
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Snackbar
+import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
@@ -17,6 +21,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
@@ -30,6 +35,7 @@ import dev.sanmer.pi.ui.component.PageIndicator
import dev.sanmer.pi.ui.component.SearchTopBar
import dev.sanmer.pi.ui.ktx.navigateSingleTopTo
import dev.sanmer.pi.ui.main.Screen
+import dev.sanmer.pi.ui.provider.LocalSnackbarHostState
import dev.sanmer.pi.ui.screens.apps.component.AppList
import dev.sanmer.pi.viewmodel.AppsViewModel
@@ -62,12 +68,22 @@ fun AppsScreen(
navController = navController,
scrollBehavior = scrollBehavior
)
+ },
+ snackbarHost = {
+ SnackbarHost(hostState = LocalSnackbarHostState.current) {
+ Snackbar(
+ snackbarData = it,
+ shape = MaterialTheme.shapes.medium
+ )
+ }
}
) { contentPadding ->
Box(
modifier = Modifier
.imePadding()
.nestedScroll(scrollBehavior.nestedScrollConnection)
+ .fillMaxSize(),
+ contentAlignment = Alignment.TopCenter
) {
if (viewModel.isLoading) {
Loading(
@@ -130,10 +146,10 @@ private fun TopBar(
}
IconButton(
- onClick = { navController.navigateSingleTopTo(Screen.Settings.route) }
+ onClick = { navController.navigateSingleTopTo(Screen.Settings()) }
) {
Icon(
- painter = painterResource(id = R.drawable.settings),
+ painter = painterResource(id = R.drawable.settings_2),
contentDescription = null
)
}
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 d2d6f0d0..b95926c2 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
@@ -4,7 +4,6 @@ import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,9 +15,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
@@ -28,28 +25,21 @@ import dev.sanmer.pi.model.IPackageInfo
import dev.sanmer.pi.ui.component.Logo
@Composable
-internal fun AppItem(
+fun AppItem(
pi: IPackageInfo,
- iconSize: Dp = 45.dp,
- iconEnd: Dp = 12.dp,
- contentPaddingValues: PaddingValues = PaddingValues(12.dp),
- verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
- onClick: () -> Unit = {},
- enabled: Boolean = true
+ onClick: () -> Unit,
+ trailing: @Composable (() -> Unit)? = null
) = Row(
modifier = Modifier
- .clip(shape = MaterialTheme.shapes.medium)
- .clickable(
- enabled = enabled,
- onClick = onClick
- )
- .padding(contentPaddingValues)
+ .clickable(onClick = onClick)
+ .padding(all = 15.dp)
.fillMaxWidth(),
- verticalAlignment = verticalAlignment
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val context = LocalContext.current
AsyncImage(
- modifier = Modifier.size(iconSize),
+ modifier = Modifier.size(40.dp),
model = ImageRequest.Builder(context)
.data(pi)
.crossfade(true)
@@ -58,9 +48,7 @@ internal fun AppItem(
)
Column(
- modifier = Modifier
- .padding(start = iconEnd)
- .weight(1f)
+ modifier = Modifier.weight(1f),
) {
Text(
text = pi.appLabel,
@@ -89,11 +77,13 @@ internal fun AppItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
- if (pi.isRequester) Icon(R.drawable.cube_plus)
- if (pi.isExecutor) Icon(R.drawable.code)
- if (pi.isAuthorized) Icon(R.drawable.package_import)
+ if (pi.isRequester) Icon(R.drawable.file_import)
+ if (pi.isExecutor) Icon(R.drawable.player_play)
+ if (pi.isAuthorized) Icon(R.drawable.shield)
}
}
+
+ trailing?.invoke()
}
@Composable
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 72e0ed31..dc988cb3 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
@@ -1,166 +1,91 @@
package dev.sanmer.pi.ui.screens.apps.component
import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.asPaddingValues
-import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.draw.clip
+import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.ui.component.MenuChip
-import dev.sanmer.pi.ui.ktx.bottom
+import dev.sanmer.pi.ui.provider.LocalSnackbarHostState
import dev.sanmer.pi.viewmodel.AppsViewModel
import kotlinx.coroutines.launch
@Composable
-internal fun AppList(
+fun AppList(
list: List,
state: LazyListState,
buildSettings: (IPackageInfo) -> AppsViewModel.Settings,
contentPadding: PaddingValues = PaddingValues(0.dp)
+) = LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize(),
+ state = state,
+ contentPadding = contentPadding
) {
- var packageName by remember { mutableStateOf("") }
- val packageInfo by remember(list, packageName) {
- derivedStateOf {
- list.firstOrNull { it.packageName == packageName }
- }
- }
-
- packageInfo?.let {
- BottomSheet(
+ items(list) {
+ AppItem(
pi = it,
- onClose = { packageName = "" },
buildSettings = buildSettings
)
}
-
- LazyColumn(
- modifier = Modifier.animateContentSize(),
- state = state,
- contentPadding = contentPadding
- ) {
- items(
- items = list,
- key = { it.packageName }
- ) { pi ->
- AppItem(
- pi = pi,
- onClick = { packageName = pi.packageName }
- )
- }
- }
}
@Composable
-private fun BottomSheet(
+private fun AppItem(
pi: IPackageInfo,
- onClose: () -> Unit,
buildSettings: (IPackageInfo) -> AppsViewModel.Settings
-) = ModalBottomSheet(
- onDismissRequest = onClose,
- dragHandle = null,
- windowInsets = WindowInsets(0.dp),
- shape = MaterialTheme.shapes.large.bottom(0.dp)
-) {
- val contentPadding = WindowInsets.navigationBars.asPaddingValues()
-
- val settings by remember(pi) {
- derivedStateOf { buildSettings(pi) }
- }
-
- Column(
- modifier = Modifier
- .padding(contentPadding)
- .padding(all = 20.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- AppItem(
- pi = pi.toIPackageInfo(),
- enabled = false,
- iconSize = 60.dp,
- iconEnd = 20.dp,
- contentPaddingValues = PaddingValues(0.dp),
- verticalAlignment = Alignment.Top
- )
-
- SettingButtons(
- settings = settings
- )
-
- SettingItem(
- pi = pi,
- settings = settings
- )
- }
-}
-
-@Composable
-private fun SettingButtons(
- settings: AppsViewModel.Settings
-) = Row(
- verticalAlignment = Alignment.CenterVertically
) {
- val context = LocalContext.current
- val scope = rememberCoroutineScope()
+ var expend by rememberSaveable(pi) { mutableStateOf(false) }
+ val degrees by animateFloatAsState(
+ targetValue = if (expend) 90f else 0f,
+ label = "AppItem Icon"
+ )
- if (settings.isOpenable) {
- FilledTonalIconButton(
- onClick = { settings.launch(context) }
- ) {
+ AppItem(
+ pi = pi,
+ onClick = { expend = !expend },
+ trailing = {
Icon(
- painter = painterResource(id = R.drawable.window_maximize),
- contentDescription = null
+ painter = painterResource(id = R.drawable.chevron_right),
+ contentDescription = null,
+ modifier = Modifier.rotate(degrees)
)
}
- }
-
- FilledTonalIconButton(
- onClick = { settings.view(context) }
- ) {
- Icon(
- painter = painterResource(id = R.drawable.eye),
- contentDescription = null
- )
- }
+ )
- FilledTonalIconButton(
- onClick = {
- scope.launch { settings.export(context) }
- }
- ) {
- Icon(
- painter = painterResource(id = R.drawable.package_export),
- contentDescription = null
- )
- }
+ if (expend) SettingItem(
+ pi = pi,
+ settings = buildSettings(pi)
+ )
}
@OptIn(ExperimentalLayoutApi::class)
@@ -168,39 +93,74 @@ private fun SettingButtons(
private fun SettingItem(
pi: IPackageInfo,
settings: AppsViewModel.Settings
-) = FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
+) = Box(
+ modifier = Modifier
+ .padding(all = 10.dp)
+ .clip(shape = MaterialTheme.shapes.medium)
+ .border(
+ border = CardDefaults.outlinedCardBorder(),
+ shape = MaterialTheme.shapes.medium
+ )
) {
- val scope = rememberCoroutineScope()
+ val snackbarHostState = LocalSnackbarHostState.current
- MenuChip(
- selected = pi.isAuthorized,
- onClick = {
- scope.launch {
- settings.setAuthorized()
- }
- },
- label = { Text(text = stringResource(id = R.string.app_authorized)) },
- )
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(all = 15.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ MenuChip(
+ selected = pi.isRequester,
+ enabled = !pi.isRequester,
+ onClick = {
+ scope.launch {
+ settings.setRequester()
+ }
+ },
+ label = { Text(text = stringResource(id = R.string.app_requester)) },
+ )
- MenuChip(
- selected = pi.isRequester,
- onClick = {
- scope.launch {
- settings.setRequester()
- }
- },
- label = { Text(text = stringResource(id = R.string.app_requester)) },
- )
+ MenuChip(
+ selected = pi.isExecutor,
+ enabled = !pi.isExecutor,
+ onClick = {
+ scope.launch {
+ settings.setExecutor()
+ }
+ },
+ label = { Text(text = stringResource(id = R.string.app_executor)) }
+ )
- MenuChip(
- selected = pi.isExecutor,
- onClick = {
- scope.launch {
- settings.setExecutor()
- }
- },
- label = { Text(text = stringResource(id = R.string.app_executor)) },
- )
+ MenuChip(
+ selected = pi.isAuthorized,
+ enabled = pi.packageName != BuildConfig.APPLICATION_ID,
+ onClick = {
+ scope.launch {
+ settings.setAuthorized()
+ }
+ },
+ label = { Text(text = stringResource(id = R.string.app_authorize)) },
+ )
+
+ MenuChip(
+ selected = false,
+ onClick = {
+ scope.launch {
+ if (settings.export(context)) {
+ snackbarHostState.showSnackbar(
+ message = context.getString(
+ R.string.app_export_msg, "Download/PI"
+ )
+ )
+ }
+ }
+ },
+ label = { Text(text = stringResource(id = R.string.app_export)) },
+ )
+ }
}
\ 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 16b06831..618c82da 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
@@ -8,9 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
@@ -18,7 +16,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import dev.sanmer.pi.BuildConfig
@@ -31,7 +28,6 @@ import dev.sanmer.pi.ktx.localizedDisplayName
import dev.sanmer.pi.ktx.viewUrl
import dev.sanmer.pi.ui.component.NavigateUpTopBar
import dev.sanmer.pi.ui.component.SettingNormalItem
-import dev.sanmer.pi.ui.component.SettingSwitchItem
import dev.sanmer.pi.ui.ktx.navigateSingleTopTo
import dev.sanmer.pi.ui.main.Screen
import dev.sanmer.pi.ui.provider.LocalUserPreferences
@@ -60,10 +56,6 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState())
.padding(contentPadding)
) {
- TittleItem(
- text = stringResource(id = R.string.settings_behavior)
- )
-
ServiceItem(
isAlive = viewModel.isProviderAlive,
platform = viewModel.providerPlatform,
@@ -81,27 +73,14 @@ fun SettingsScreen(
}
),
onClick = {
- navController.navigateSingleTopTo(Screen.WorkingMode.route)
+ navController.navigateSingleTopTo(Screen.WorkingMode())
}
)
- TittleItem(
- text = stringResource(id = R.string.settings_interface)
- )
-
LanguageItem(
context = context
)
- SettingSwitchItem(
- icon = R.drawable.color_swatch,
- title = stringResource(id = R.string.settings_dynamic_color),
- desc = stringResource(id = R.string.settings_dynamic_color_desc),
- checked = userPreferences.dynamicColor,
- onChange = viewModel::setDynamicColor,
- enabled = BuildCompat.atLeastS
- )
-
SettingNormalItem(
icon = R.drawable.language,
title = stringResource(id = R.string.settings_translation),
@@ -123,17 +102,6 @@ fun SettingsScreen(
}
}
-@Composable
-private fun TittleItem(
- text: String,
- modifier: Modifier = Modifier
-) = Text(
- modifier = modifier.padding(all = 16.dp),
- text = text,
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.primary
-)
-
@Composable
private fun ServiceItem(
isAlive: Boolean,
diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/workingmode/component/WorkingModeItem.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/workingmode/component/WorkingModeItem.kt
index 5923d289..777dcd37 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/ui/screens/workingmode/component/WorkingModeItem.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/ui/screens/workingmode/component/WorkingModeItem.kt
@@ -22,7 +22,7 @@ import androidx.compose.ui.unit.dp
import dev.sanmer.pi.R
@Composable
-internal fun WorkingModeItem(
+fun WorkingModeItem(
title: String,
desc: String,
modifier: Modifier = Modifier,
diff --git a/app/src/main/kotlin/dev/sanmer/pi/ui/theme/Theme.kt b/app/src/main/kotlin/dev/sanmer/pi/ui/theme/Theme.kt
index 5f3402c1..37a9926d 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/ui/theme/Theme.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/ui/theme/Theme.kt
@@ -17,11 +17,10 @@ import dev.sanmer.pi.compat.BuildCompat
@Composable
fun AppTheme(
- dynamicColor: Boolean,
darkMode: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
- val colorScheme = getColorScheme(dynamicColor, darkMode)
+ val colorScheme = getColorScheme(darkMode)
SystemBarStyle(
darkMode = darkMode
@@ -36,9 +35,9 @@ fun AppTheme(
}
@Composable
-private fun getColorScheme(dynamicColor: Boolean, darkMode: Boolean): ColorScheme {
+private fun getColorScheme(darkMode: Boolean): ColorScheme {
val context = LocalContext.current
- return if (BuildCompat.atLeastS && dynamicColor) {
+ return if (BuildCompat.atLeastS) {
when {
darkMode -> dynamicDarkColorScheme(context)
else -> dynamicLightColorScheme(context)
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 0a47f7e4..7f85d0e7 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/AppsViewModel.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/AppsViewModel.kt
@@ -3,6 +3,7 @@ package dev.sanmer.pi.viewmodel
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
+import android.os.Environment
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -12,10 +13,9 @@ 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.compat.MediaStoreCompat.createDownloadUri
+import dev.sanmer.pi.compat.MediaStoreCompat.createMediaStoreUri
import dev.sanmer.pi.delegate.AppOpsManagerDelegate
import dev.sanmer.pi.delegate.AppOpsManagerDelegate.Mode.Companion.isAllowed
-import dev.sanmer.pi.ktx.viewPackage
import dev.sanmer.pi.model.IPackageInfo
import dev.sanmer.pi.model.IPackageInfo.Companion.toIPackageInfo
import dev.sanmer.pi.repository.UserPreferencesRepository
@@ -30,7 +30,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
-import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.inject.Inject
@@ -75,7 +74,9 @@ class AppsViewModel @Inject constructor(
.onEach { isAlive ->
if (!isAlive) return@onEach
- packagesFlow.value = getPackages()
+ packagesFlow.update {
+ getPackages()
+ }
aom.startWatchingMode(
op = AppOpsManagerDelegate.OP_REQUEST_INSTALL_PACKAGES,
@@ -161,26 +162,10 @@ class AppsViewModel @Inject constructor(
fun closeSearch() {
isSearch = false
- keyFlow.value = ""
+ keyFlow.update { "" }
}
fun buildSettings(packageInfo: IPackageInfo) = object : Settings {
- private val launchIntent by lazy {
- pm.getLaunchIntentForPackage(
- packageInfo.packageName, UserHandleCompat.myUserId()
- )
- }
-
- override val isOpenable by lazy { launchIntent != null }
-
- override fun launch(context: Context) {
- context.startActivity(launchIntent)
- }
-
- override fun view(context: Context) {
- context.viewPackage(packageInfo.packageName)
- }
-
override suspend fun export(context: Context): Boolean {
val sourceDir = packageInfo.applicationInfo?.let { File(it.sourceDir) }
if (sourceDir == null) return false
@@ -192,25 +177,19 @@ class AppsViewModel @Inject constructor(
file.name.endsWith(".apk")
} ?: return false
- val streams = files.map { it to it.inputStream().buffered() }
- when {
- streams.size == 1 -> {
- context.exportApk(
- input = streams.first().second,
- path = path
- )
- }
+ return when {
+ files.size == 1 -> context.exportApk(
+ file = files.first(),
+ path = path
+ )
- streams.size > 1 -> {
- context.exportApks(
- inputs = streams,
- path = path + 's'
- )
- }
- }
+ files.size > 1 -> context.exportApks(
+ files = files.toList(),
+ path = path + 's'
+ )
- streams.forEach { it.second.close() }
- return true
+ else -> false
+ }
}
override suspend fun setAuthorized() {
@@ -244,16 +223,16 @@ class AppsViewModel @Inject constructor(
}
private suspend fun Context.exportApk(
- input: InputStream,
+ file: File,
path: String,
) = withContext(Dispatchers.IO) {
- val uri = createDownloadUri(
- path = path,
+ val uri = createMediaStoreUri(
+ file = File(Environment.DIRECTORY_DOWNLOADS, path),
mimeType = "android/vnd.android.package-archive"
)
contentResolver.openOutputStream(uri)?.use { output ->
- input.copyTo(output)
+ file.inputStream().buffered().copyTo(output)
return@withContext true
}
@@ -261,18 +240,18 @@ class AppsViewModel @Inject constructor(
}
private suspend fun Context.exportApks(
- inputs: List>,
+ files: List,
path: String,
) = withContext(Dispatchers.IO) {
- val uri = createDownloadUri(
- path = path,
+ val uri = createMediaStoreUri(
+ file = File(Environment.DIRECTORY_DOWNLOADS, path),
mimeType = "android/zip"
)
contentResolver.openOutputStream(uri)?.let(::ZipOutputStream)?.use { output ->
- inputs.forEach { (file, input) ->
+ files.forEach { file ->
output.putNextEntry(ZipEntry(file.name))
- input.copyTo(output)
+ file.inputStream().buffered().copyTo(output)
output.closeEntry()
}
@@ -288,9 +267,6 @@ class AppsViewModel @Inject constructor(
).isAllowed()
interface Settings {
- val isOpenable: Boolean
- fun launch(context: Context)
- fun view(context: Context)
suspend fun export(context: Context): Boolean
suspend fun setAuthorized()
suspend fun setRequester()
diff --git a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/PermissionViewModel.kt b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/PermissionViewModel.kt
deleted file mode 100644
index 83911f35..00000000
--- a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/PermissionViewModel.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-package dev.sanmer.pi.viewmodel
-
-import android.content.pm.PackageInfo
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import dev.sanmer.pi.Compat
-import dev.sanmer.pi.UserHandleCompat
-import dev.sanmer.pi.model.IPackageInfo
-import dev.sanmer.pi.model.IPackageInfo.Companion.toIPackageInfo
-import dev.sanmer.pi.repository.UserPreferencesRepository
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.withContext
-import javax.inject.Inject
-
-@HiltViewModel
-class PermissionViewModel @Inject constructor(
- private val userPreferencesRepository: UserPreferencesRepository
-) : ViewModel() {
- private val pm by lazy { Compat.getPackageManager() }
- private val perm by lazy { Compat.getPermissionManager() }
-
- var packageInfo by mutableStateOf(IPackageInfo.empty())
- private set
-
- var permissions by mutableStateOf(emptyList())
- private set
- private val requiredPermissions = mutableStateListOf()
-
- var isLoading by mutableStateOf(true)
- private set
-
- suspend fun load(packageName: String, permissions: List) = withContext(Dispatchers.IO) {
- val userPreferences = userPreferencesRepository.data.first()
-
- if (!Compat.init(userPreferences.provider)) {
- return@withContext false
- }
-
- this@PermissionViewModel.packageInfo = getPackageInfo(packageName).toIPackageInfo()
- this@PermissionViewModel.permissions = permissions
-
- requiredPermissions.addAll(permissions)
- isLoading = false
-
- true
- }
-
- fun isRequiredPermission(permission: String): Boolean {
- return permission in requiredPermissions
- }
-
- fun togglePermission(permission: String) {
- if (isRequiredPermission(permission)) {
- requiredPermissions.remove(permission)
- } else {
- requiredPermissions.add(permission)
- }
- }
-
- fun grantPermissions() {
- requiredPermissions.forEach {
- perm.grantRuntimePermission(
- packageName = packageInfo.packageName,
- permissionName = it,
- userId = UserHandleCompat.myUserId()
- )
- }
- }
-
- fun permissionResults() = permissions.map {
- perm.checkPermission(
- packageName = packageInfo.packageName,
- permissionName = it,
- userId = UserHandleCompat.myUserId()
- )
- }.toIntArray()
-
- private fun getPackageInfo(packageName: String?): PackageInfo {
- if (packageName == null) return PackageInfo()
- return runCatching {
- pm.getPackageInfo(
- packageName, 0, UserHandleCompat.myUserId()
- )
- }.getOrNull() ?: PackageInfo()
- }
-}
\ 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 9a2a241f..c7f5f362 100644
--- a/app/src/main/kotlin/dev/sanmer/pi/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/kotlin/dev/sanmer/pi/viewmodel/SettingsViewModel.kt
@@ -22,12 +22,6 @@ class SettingsViewModel @Inject constructor(
Timber.d("SettingsViewModel init")
}
- fun setDynamicColor(value: Boolean) {
- viewModelScope.launch {
- userPreferencesRepository.setDynamicColor(value)
- }
- }
-
fun setProvider(value: Provider) {
viewModelScope.launch {
userPreferencesRepository.setProvider(value)
diff --git a/app/src/main/res/drawable/chevron_right.xml b/app/src/main/res/drawable/chevron_right.xml
new file mode 100644
index 00000000..11239d1b
--- /dev/null
+++ b/app/src/main/res/drawable/chevron_right.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/src/main/res/drawable/color_swatch.xml b/app/src/main/res/drawable/color_swatch.xml
deleted file mode 100644
index ebd72a23..00000000
--- a/app/src/main/res/drawable/color_swatch.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/cube_plus.xml b/app/src/main/res/drawable/cube_plus.xml
deleted file mode 100644
index 88e6957d..00000000
--- a/app/src/main/res/drawable/cube_plus.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/eye.xml b/app/src/main/res/drawable/file_import.xml
similarity index 74%
rename from app/src/main/res/drawable/eye.xml
rename to app/src/main/res/drawable/file_import.xml
index 719a32b0..1daefc48 100644
--- a/app/src/main/res/drawable/eye.xml
+++ b/app/src/main/res/drawable/file_import.xml
@@ -4,13 +4,13 @@
android:viewportWidth="24"
android:viewportHeight="24">
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/package_import.xml b/app/src/main/res/drawable/package_import.xml
deleted file mode 100644
index bf79916e..00000000
--- a/app/src/main/res/drawable/package_import.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/player_play.xml b/app/src/main/res/drawable/player_play.xml
new file mode 100644
index 00000000..1788d56e
--- /dev/null
+++ b/app/src/main/res/drawable/player_play.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml
deleted file mode 100644
index e66e7c0b..00000000
--- a/app/src/main/res/drawable/settings.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/settings_2.xml b/app/src/main/res/drawable/settings_2.xml
new file mode 100644
index 00000000..3d3d11f7
--- /dev/null
+++ b/app/src/main/res/drawable/settings_2.xml
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/shield.xml b/app/src/main/res/drawable/shield.xml
new file mode 100644
index 00000000..c6f03fca
--- /dev/null
+++ b/app/src/main/res/drawable/shield.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/src/main/res/drawable/window_maximize.xml b/app/src/main/res/drawable/window_maximize.xml
deleted file mode 100644
index 9f235eac..00000000
--- a/app/src/main/res/drawable/window_maximize.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index a93ea234..1980f9c3 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -9,7 +9,6 @@
مقدم طلب التثبيت
قائمة فارغة
الإعدادات
- لون ديناميكي
المشاركة في الترجمة
ساعدنا في ترجمة PI إلى لغتك
حزمة التثبيت
@@ -22,22 +21,18 @@
فشل التثبيت
جاري التحميل…
بحث…
- تثبيت الخدمة
+ تثبيت الخدمة
خطأ غير معروف
ABI
- إستخدام لون مظهر النظام
يتطلب صلاحيات الروت التي يوفرها Magisk أو KernelSU أو APatch
- السلوك
إضغط لمحاولة البدء
- الواجهة
اللغة
إفتراضي تبع النظام
رمز المصدر
- حسناً
- إلغاء
+ إلغاء
فشل تحليل الحزمة
الحزمة غير متاحة
- مخول
+ مخول
الطالب
المنفذ
\ 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 bddf7cfd..c252ab9e 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1,6 +1,5 @@
- Comportamiento
Instalar
Configuración
Modo de trabajo
@@ -10,11 +9,8 @@
Versión %1$d, %2$s
El servicio no está en ejecución
Haga clic para intentar iniciar
- Interfaz
Idioma
Sistema por defecto
- Color dinámico
- Utilizar el color del tema del sistema
Participar en la traducción
Ayúdenos a traducir PI a su idioma
Código fuente
@@ -29,19 +25,14 @@
Error al analizar el paquete
ABI
Lista vacía
- Instalar servicio
+ Instalar servicio
Error desconocido
- Ok
- Cancelar
+ Cancelar
Instalación correcta
Instalación fallida
Cargando…
Buscar…
- Autorizado
+ Autorizado
Solicitante
Ejecutor
- Permisos
- Denegar
- Aplicación
- Conceder
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 84ecc4c2..f52460f1 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -3,27 +3,22 @@
Installer
Mode de travail
Nécessite l\'autorisation fournie par Sui ou Shizuku
- Nécessite les permissions Root fournies par Magisk ou KernelSU
+ Nécessite les permissions Root fournies par Magisk, KernelSU ou APatch
Service est en cours d\'exécution
Le service n\'est pas en cours d\'exécution
Version %1$d, %2$s
- Service d\'installation
+ Service d\'installation
Erreur inconnue
- Comportement
Cliquer pour essayer de démarrer
- Interface
Language
Système par défaut
Code source
Échec de l\'analyse du paquet
Paquet d\'installation
Le service n\'est pas disponible
- Couleur dynamique
- Utiliser la couleur du thème système
Participer à la traduction
Aidez nous a traduire PI dans vôtre langue
- OK
- Annuler
+ Annuler
Installation réussie
Échec de l\'installation
Chargement…
@@ -37,4 +32,5 @@
Non précisé
Installer
Paramètres
+ Exporter
\ No newline at end of file
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 2e980ad5..93502f2c 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -2,17 +2,13 @@
Setelan
Mode kerja
- Diotorisasi
+ Diotorisasi
Eksekutor
- Tindakan
Layanan telah berjalan
Versi %1$d, %2$s
Klik untuk mencoba memulai
- Antarmuka
Bahasa
Bawaan sistem
- Warna dinamis
- Menggunakan warna tema sistem
Berpartisipasi dalam penerjemahan
Kode sumber
Paket instalasi
@@ -27,21 +23,16 @@
Pasang
Layanan tidak tersedia
Parsing paket gagal
- Oke
- Batal
+ Batal
Pemasangan berhasil
Pemasangan gagal
Memuat…
Cari…
Daftar kosong
- Layanan pemasangan
+ Layanan pemasangan
Kesalahan tidak diketahui
Pasang
Memerlukan izin Root dari Magisk, KernelSU, atau APatch
Layanan tidak berjalan
Bantu kami menerjemahkan PI ke dalam bahasa Anda
- Izin
- Tolak
- Aplikasi
- Izinkan
\ No newline at end of file
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index 71994977..3f6919e4 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -6,7 +6,6 @@
דורש הרשאות המסופקות על ידי Sui או Shizuku
ארכיטקטורה
פיצ\'ר דינמי
- תפעול
חבילת התקנה
מבקש ההתקנה
צפיפות תצוגה
@@ -16,28 +15,24 @@
השירות פועל
גרסה %1$d, %2$s
השירות לא פועל
- ממשק
שפה
ברירת מחדל של המערכת
- צבע דינמי
- שימוש בצבע ערכת הנושא של המערכת
עזור לנו לתרגם את PI לשפה שלך
קוד מקור
שגיאה לא ידועה
- התקנת שירות
+ התקנת שירות
רשימה ריקה
חיפוש…
נטען…
ההתקנה נכשלה
ההתקנה בוצעה בהצלחה
- ביטול
- אישור
+ ביטול
התקנה
לא מוגדר
שפה
השירות אינו זמין
ניתוח החבילה כשל
- מורשה
+ מורשה
מבצע
מבקש
\ 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 bdbd770a..07ff587d 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -10,15 +10,13 @@
Solicitante de instalação
Lista vazia
Configurações
- Cor dinâmica
- Use a cor do tema do sistema
Pacote de instalação
Instalar
Instalação bem-sucedida
Falha na instalação
Carregando…
Pesquisar…
- Instalar serviço
+ Instalar serviço
Erro desconhecido
Densidade da tela
Recurso dinâmico
@@ -27,21 +25,16 @@
Não especificado
Participe da tradução
Ajude-nos a traduzir o PI para o seu idioma
- Comportamento
Clique para tentar começar
- Interface
Idioma
Código fonte
- OK
- Cancelar
+ Cancelar
Padrão do sistema
Falha na análise do pacote
O serviço não está disponível
- Autorizado
+ Autorizado
Solicitante
Executor
- Permissões
- Negar
- Permitir
- App
+ Exportar
+ Exportado para %1$s
\ No newline at end of file
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index bf5837c4..22ac9381 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -8,8 +8,6 @@
Versão %1$d, %2$s
Solicitante de instalação
Lista vazia
- Cor dinâmica
- Use a cor do tema do sistema
Pacote de instalação
Instalar
Falha na instalação
@@ -18,7 +16,7 @@
Necessita de permissão concedida por Sui ou Shizuku
Definições
Instalação bem-sucedida
- Instalar serviço
+ Instalar serviço
Erro desconhecido
Recurso dinâmico
ABI
@@ -27,21 +25,16 @@
Não especificado
Participe da tradução
Ajude-nos a traduzir o PI para o seu idioma
- Comportamento
Clique para tentar começar
- Interface
Idioma
Padrão do sistema
Código fonte
- OK
- Cancelar
+ Cancelar
O serviço não está disponível
Falha na análise do pacote
- Autorizado
+ Autorizado
Solicitante
Executor
- Permissões
- Negar
- Conceder
- Aplicação
+ Exportar
+ Exportado para %1$s
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 846d6c97..98d92bd5 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -9,21 +9,16 @@
Требуется разрешение Sui или Shizuku
Плотность экрана
Язык
- ОК
Установка прервана
Пустой список
Неизвестная ошибка
Динамическая функция
Неопределённый
Установка завершена
- Сервис установки
- Поведение
+ Сервис установки
Нажмите, чтобы попытаться начать
- Интерфейс
Язык
Системный по умолчанию
- Динамический цвет
- Использует системные цвета
Участие в переводе
Помогите нам перевести PI на ваш язык
Исходный код
@@ -35,6 +30,6 @@
Настройки
Сервис не доступен
Сбой синтаксического анализа пакета
- Отмена
+ Отмена
Поиск…
\ 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
new file mode 100644
index 00000000..ef0a6628
--- /dev/null
+++ b/app/src/main/res/values-su/strings.xml
@@ -0,0 +1,38 @@
+
+
+ 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
+ Pangaturan
+ Layanan geus jalan
+ Vérsi %1$d, %2$s
+ Layanan teu jalan
+ Klik keur ngajalankeun
+ Basa
+ Ngamuat…
+ Téang…
+ Parsing pakét gagal
+ Eusi kosong
+ Bolay
+ Pamasangan réngsé
+ Pamasangan gagal
+ Bawaan ti sistem
+ Babantu pikeun tarjamahan
+ Ngabantu ka urang pikeun tarjamahan PI kana basa anjeun
+ Sumber kodeu
+ Pakét pamasangan
+ Paménta pamasangan
+ Fitur dinamis
+ ABI
+ Basa
+ Teu puguh rupana
+ Pasang
+ Layanan teu sadia
+ Pasang layanan
+ Error teu dipikanyaho
+
\ No newline at end of file
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 2e47ea47..ec39e7c6 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -8,8 +8,6 @@
Trình yêu cầu cài đặt
Danh sách trống
Thiết đặt
- Màu sắc linh động
- Sử dụng bảng màu Material You
Tham gia dịch thuật
Hãy giúp chúng tôi dịch PI sang ngôn ngữ của bạn
Gói cài đặt
@@ -22,7 +20,7 @@
Cài đặt thất bại
Đang tải…
Tìm…
- Cài đặt dịch vụ
+ Cài đặt dịch vụ
Lỗi không rõ
ABI
Chế độ làm việc
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 1b874cfe..a27adb09 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -9,22 +9,20 @@
需要由 Magisk, KernelSU 或 APatch 提供 Root 权限
- 已授权
请求者
执行者
+ 授权
+ 导出
+ 已导出至 %1$s
设置
- 行为
服务正在运行
版本 %1$d,%2$s
服务未运行
点击尝试启动
- 界面
语言
系统默认
- 动态颜色
- 使用系统主题颜色
参与翻译
帮助我们将 PI 翻译至您的语言
源码
@@ -38,31 +36,19 @@
语言
未知
安装
+ 取消
服务不可用
安装包解析失败
-
- 应用
- 权限
- 拒绝
- 允许
-
-
- 确定
- 取消
-
+ 安装服务
安装成功
安装失败
加载中…
+ 未知错误
搜索…
空列表
-
- 安装服务
-
-
- 未知错误
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 72a6f712..3466aae6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -9,22 +9,20 @@
Requires Root permissions provided by Magisk, KernelSU or APatch
- Authorized
Requester
Executor
+ Authorize
+ Export
+ Exported to %1$s
Settings
- Behavior
Service is running
Version %1$d, %2$s
Service is not running
Click to try to start
- Interface
Language
System default
- Dynamic color
- Use system theme color
Participate in translation
Help us translate PI into your language
Source code
@@ -38,31 +36,19 @@
Language
Unspecified
Install
+ Cancel
Service is not available
Package parsing failed
-
- Application
- Permissions
- Deny
- Grant
-
-
- OK
- Cancel
-
+ Install service
Install successful
Install failed
Loading…
+ Unknown error
Search…
Empty list
-
- Install service
-
-
- Unknown error
\ No newline at end of file
diff --git a/app/src/main/res/values/strings_untranslatable.xml b/app/src/main/res/values/strings_untranslatable.xml
index e205809d..df64da2b 100644
--- a/app/src/main/res/values/strings_untranslatable.xml
+++ b/app/src/main/res/values/strings_untranslatable.xml
@@ -6,7 +6,4 @@
Shizuku
Root
-
- @string/dialog_cancel
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index feb9ec9f..46db803b 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -9,6 +9,7 @@
+
\ No newline at end of file
diff --git a/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt b/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt
index 0f9eef52..991ada2b 100644
--- a/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt
+++ b/build-logic/src/main/kotlin/ApplicationConventionPlugin.kt
@@ -18,8 +18,8 @@ class ApplicationConventionPlugin : Plugin {
buildToolsVersion = "35.0.0"
defaultConfig {
- minSdk = 29
- targetSdk = 34
+ minSdk = 30
+ targetSdk = compileSdk
}
compileOptions {
diff --git a/build-logic/src/main/kotlin/LibraryConventionPlugin.kt b/build-logic/src/main/kotlin/LibraryConventionPlugin.kt
index bdecd35a..d3621dcb 100644
--- a/build-logic/src/main/kotlin/LibraryConventionPlugin.kt
+++ b/build-logic/src/main/kotlin/LibraryConventionPlugin.kt
@@ -18,7 +18,7 @@ class LibraryConventionPlugin : Plugin {
buildToolsVersion = "35.0.0"
defaultConfig {
- minSdk = 29
+ minSdk = 30
}
compileOptions {
diff --git a/build.gradle.kts b/build.gradle.kts
index 31e81432..f119da7d 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ task("clean") {
}
subprojects {
- val baseVersionName by extra("1.1.3")
+ val baseVersionName by extra("1.1.4")
apply(plugin = "maven-publish")
configure {
diff --git a/core/src/main/kotlin/dev/sanmer/pi/BuildCompat.kt b/core/src/main/kotlin/dev/sanmer/pi/BuildCompat.kt
index 19a4fa40..4f95322e 100644
--- a/core/src/main/kotlin/dev/sanmer/pi/BuildCompat.kt
+++ b/core/src/main/kotlin/dev/sanmer/pi/BuildCompat.kt
@@ -5,14 +5,11 @@ import androidx.annotation.ChecksSdkIntAtLeast
internal object BuildCompat {
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
- val atLeastU get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+ val atLeastU inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
- val atLeastT get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ val atLeastT inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
- val atLeastS get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
-
- @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R)
- val atLeastR get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ val atLeastS inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
\ No newline at end of file
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 898bd2c6..2b36a567 100644
--- a/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageInstallerDelegate.kt
+++ b/core/src/main/kotlin/dev/sanmer/pi/delegate/PackageInstallerDelegate.kt
@@ -18,6 +18,8 @@ import dev.sanmer.pi.IntentReceiverCompat
import dev.sanmer.su.IServiceManager
import dev.sanmer.su.ServiceManagerCompat.getSystemService
import dev.sanmer.su.ServiceManagerCompat.proxyBy
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import java.io.File
class PackageInstallerDelegate(
@@ -183,7 +185,7 @@ class PackageInstallerDelegate(
commit(sender)
}
- fun PackageInstaller.Session.writeApk(path: File) {
+ suspend fun PackageInstaller.Session.writeApk(path: File) = withContext(Dispatchers.IO) {
openWrite(path.name, 0, path.length()).use { output ->
path.inputStream().buffered().use { input ->
input.copyTo(output)
@@ -192,7 +194,7 @@ class PackageInstallerDelegate(
}
}
- fun PackageInstaller.Session.writeApks(path: File, filenames: List) {
+ suspend fun PackageInstaller.Session.writeApks(path: File, filenames: List) {
filenames.forEach { name ->
val file = File(path, name)
if (file.exists()) writeApk(file)
diff --git a/core/src/main/kotlin/dev/sanmer/pi/delegate/PermissionManagerDelegate.kt b/core/src/main/kotlin/dev/sanmer/pi/delegate/PermissionManagerDelegate.kt
index d5f71b80..a84d4e95 100644
--- a/core/src/main/kotlin/dev/sanmer/pi/delegate/PermissionManagerDelegate.kt
+++ b/core/src/main/kotlin/dev/sanmer/pi/delegate/PermissionManagerDelegate.kt
@@ -1,5 +1,7 @@
package dev.sanmer.pi.delegate
+import android.companion.virtual.VirtualDeviceManagerHidden
+import android.content.Context
import android.content.pm.IPackageManager
import android.permission.IPermissionManager
import dev.sanmer.pi.BuildCompat
@@ -23,36 +25,28 @@ class PermissionManagerDelegate(
fun grantRuntimePermission(packageName: String, permissionName: String, userId: Int) {
when {
- BuildCompat.atLeastU -> try {
- permissionManager.grantRuntimePermission(
- packageName,
- permissionName,
- "default:0",
- userId
- )
- } catch (e: NoSuchMethodError) {
- try {
+ BuildCompat.atLeastU ->
+ rollback {
permissionManager.grantRuntimePermission(
packageName,
permissionName,
- 0,
- userId)
- } catch (e: NoSuchMethodError) {
+ VirtualDeviceManagerHidden.PERSISTENT_DEVICE_ID_DEFAULT,
+ userId
+ )
+ } ?: rollback {
permissionManager.grantRuntimePermission(
packageName,
permissionName,
+ Context.DEVICE_ID_DEFAULT,
userId
)
- }
- }
-
- BuildCompat.atLeastR -> permissionManager.grantRuntimePermission(
- packageName,
- permissionName,
- userId
- )
+ } ?: permissionManager.grantRuntimePermission(
+ packageName,
+ permissionName,
+ userId
+ )
- else -> packageManager.grantRuntimePermission(
+ else -> permissionManager.grantRuntimePermission(
packageName,
permissionName,
userId
@@ -62,59 +56,61 @@ class PermissionManagerDelegate(
fun revokeRuntimePermission(packageName: String, permissionName: String, userId: Int) {
when {
- BuildCompat.atLeastU -> try {
- permissionManager.revokeRuntimePermission(
- packageName,
- permissionName,
- "default:0",
- userId,
- null
- )
- } catch (e: NoSuchMethodError) {
- try {
+ BuildCompat.atLeastU ->
+ rollback {
permissionManager.revokeRuntimePermission(
packageName,
permissionName,
- 0,
+ VirtualDeviceManagerHidden.PERSISTENT_DEVICE_ID_DEFAULT,
userId,
null
)
- } catch (e: NoSuchMethodError) {
+ } ?: rollback {
permissionManager.revokeRuntimePermission(
packageName,
permissionName,
- userId
+ Context.DEVICE_ID_DEFAULT,
+ userId,
+ null
)
- }
- }
-
- BuildCompat.atLeastR -> permissionManager.revokeRuntimePermission(
- packageName,
- permissionName,
- userId
- )
+ } ?: permissionManager.revokeRuntimePermission(
+ packageName,
+ permissionName,
+ userId,
+ null
+ )
- else -> packageManager.revokeRuntimePermission(
+ else -> permissionManager.revokeRuntimePermission(
packageName,
permissionName,
- userId
+ userId,
+ null
)
}
}
fun checkPermission(packageName: String, permissionName: String, userId: Int): Int {
return when {
- BuildCompat.atLeastS -> packageManager.checkPermission(
- permissionName,
- packageName,
- userId
- )
-
- BuildCompat.atLeastR -> permissionManager.checkPermission(
- permissionName,
- packageName,
- userId
- )
+ BuildCompat.atLeastU ->
+ rollback {
+ permissionManager.checkPermission(
+ packageName,
+ permissionName,
+ VirtualDeviceManagerHidden.PERSISTENT_DEVICE_ID_DEFAULT,
+ userId
+ )
+ } ?: rollback {
+ permissionManager.checkPermission(
+ packageName,
+ permissionName,
+ Context.DEVICE_ID_DEFAULT,
+ userId
+ )
+ } ?: packageManager.checkPermission(
+ packageName,
+ permissionName,
+ userId
+ )
else -> packageManager.checkPermission(
permissionName,
@@ -123,4 +119,14 @@ class PermissionManagerDelegate(
)
}
}
+
+ private inline fun rollback(block: () -> T): T? {
+ return try {
+ block()
+ } catch (_: NoSuchMethodError) {
+ null
+ } catch (_: NoSuchFieldError) {
+ null
+ }
+ }
}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 75f612b5..d71a00bf 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,16 +1,16 @@
[versions]
androidGradlePlugin = "8.5.1"
-androidxActivity = "1.9.0"
-androidxAnnotation = "1.8.0"
+androidxActivity = "1.9.1"
+androidxAnnotation = "1.8.1"
androidxAppCompat = "1.7.0"
-androidxCompose = "1.7.0-beta05"
+androidxCompose = "1.7.0-beta06"
androidxComposeMaterial3 = "1.2.1"
androidxCore = "1.13.1"
androidxCoreSplashscreen = "1.0.1"
androidxDataStore = "1.1.1"
androidxDocumentFile = "1.0.1"
androidxHiltNavigationCompose = "1.2.0"
-androidxLifecycle = "2.8.3"
+androidxLifecycle = "2.8.4"
androidxNavigation = "2.7.7"
androidxRoom = "2.6.1"
appiconloader = "1.5.0"
@@ -21,47 +21,48 @@ kotlin = "2.0.0"
kotlinxCoroutines = "1.8.1"
kotlinxDatetime = "0.6.0"
kotlinxSerialization = "1.7.1"
-ksp = "2.0.0-1.0.23"
+ksp = "2.0.0-1.0.24"
timber = "5.0.1"
[libraries]
-android-gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
-compose-gradle = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" }
-kotlin-gradle = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
-ksp-gradle = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
+android-gradle = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" }
+compose-gradle = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
+kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
+ksp-gradle = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
-androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
-androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidxAnnotation" }
-androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" }
-androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidxComposeMaterial3" }
-androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidxCompose" }
-androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidxCompose" }
-androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidxCompose" }
-androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util", version.ref = "androidxCompose" }
-androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
-androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidxCoreSplashscreen" }
-androidx-datastore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" }
-androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidxDocumentFile" }
-androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
-androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
-androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "androidxLifecycle" }
-androidx-lifecycle-viewModel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
-androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" }
-androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidxRoom" }
-androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "androidxRoom" }
-androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidxRoom" }
-appiconloader = { group = "me.zhanghai.android.appiconloader", name = "appiconloader", version.ref = "appiconloader" }
-appiconloader-coil = { group = "me.zhanghai.android.appiconloader", name = "appiconloader-coil", version.ref = "appiconloader" }
-coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
-coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
-hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
-kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
-kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
-kotlinx-serialization-protobuf = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-protobuf", version.ref = "kotlinxSerialization" }
-rikka-refine-annotation = { group = "dev.rikka.tools.refine", name = "annotation", version.ref = "hiddenApiRefine" }
-rikka-refine-compiler = { group = "dev.rikka.tools.refine", name = "annotation-processor", version.ref = "hiddenApiRefine" }
-rikka-refine-runtime = { group = "dev.rikka.tools.refine", name = "runtime", version.ref = "hiddenApiRefine" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
+androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidxAnnotation" }
+androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppCompat" }
+androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidxComposeMaterial3" }
+androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" }
+androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidxCompose" }
+androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidxCompose" }
+androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "androidxCompose" }
+androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxCoreSplashscreen" }
+androidx-datastore-core = { module = "androidx.datastore:datastore", version.ref = "androidxDataStore" }
+androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "androidxDocumentFile" }
+androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
+androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
+androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "androidxLifecycle" }
+androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" }
+androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidxLifecycle" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidxRoom" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidxRoom" }
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidxRoom" }
+appiconloader = { module = "me.zhanghai.android.appiconloader:appiconloader", version.ref = "appiconloader" }
+appiconloader-coil = { module = "me.zhanghai.android.appiconloader:appiconloader-coil", version.ref = "appiconloader" }
+coil-kt = { module = "io.coil-kt:coil", version.ref = "coil" }
+coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
+hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
+hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
+kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinxSerialization" }
+rikka-refine-annotation = { module = "dev.rikka.tools.refine:annotation", version.ref = "hiddenApiRefine" }
+rikka-refine-compiler = { module = "dev.rikka.tools.refine:annotation-processor", version.ref = "hiddenApiRefine" }
+rikka-refine-runtime = { module = "dev.rikka.tools.refine:runtime", version.ref = "hiddenApiRefine" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
sanmer-su = { module = "dev.sanmer.su:core", version = "+" }
diff --git a/stub/src/main/java/android/companion/virtual/VirtualDeviceManagerHidden.java b/stub/src/main/java/android/companion/virtual/VirtualDeviceManagerHidden.java
new file mode 100644
index 00000000..3e726d22
--- /dev/null
+++ b/stub/src/main/java/android/companion/virtual/VirtualDeviceManagerHidden.java
@@ -0,0 +1,8 @@
+package android.companion.virtual;
+
+import dev.rikka.tools.refine.RefineAs;
+
+@RefineAs(VirtualDeviceManager.class)
+public class VirtualDeviceManagerHidden {
+ public static String PERSISTENT_DEVICE_ID_DEFAULT;
+}
\ No newline at end of file
diff --git a/stub/src/main/java/android/content/pm/IPackageManager.java b/stub/src/main/java/android/content/pm/IPackageManager.java
index b0378e10..16575177 100644
--- a/stub/src/main/java/android/content/pm/IPackageManager.java
+++ b/stub/src/main/java/android/content/pm/IPackageManager.java
@@ -44,10 +44,6 @@ public interface IPackageManager extends IInterface {
String[] getPackagesForUid(int uid) throws RemoteException;
- void grantRuntimePermission(String packageName, String permissionName, int userId) throws RemoteException;
-
- void revokeRuntimePermission(String packageName, String permissionName, int userId) throws RemoteException;
-
int checkPermission(String permName, String pkgName, int userId) throws RemoteException;
abstract class Stub extends Binder implements IPackageManager {
diff --git a/stub/src/main/java/android/permission/IPermissionManager.java b/stub/src/main/java/android/permission/IPermissionManager.java
index 72a248cb..f0bf65f7 100644
--- a/stub/src/main/java/android/permission/IPermissionManager.java
+++ b/stub/src/main/java/android/permission/IPermissionManager.java
@@ -5,28 +5,31 @@
import android.os.IInterface;
import android.os.RemoteException;
+import androidx.annotation.RequiresApi;
+
public interface IPermissionManager extends IInterface {
void grantRuntimePermission(String packageName, String permissionName, int userId) throws RemoteException;
- // Android 14 QPR2
+ @RequiresApi(34) // QPR2
void grantRuntimePermission(String packageName, String permissionName, int deviceId, int userId) throws RemoteException;
- // Android 14 QPR3
+ @RequiresApi(34) // QPR3
void grantRuntimePermission(String packageName, String permissionName, String persistentDeviceId, int userId) throws RemoteException;
- void revokeRuntimePermission(String packageName, String permissionName, int userId) throws RemoteException;
-
- // Android 14 QPR1
void revokeRuntimePermission(String packageName, String permissionName, int userId, String reason) throws RemoteException;
- // Android 14 QPR2
+ @RequiresApi(34) // QPR2
void revokeRuntimePermission(String packageName, String permissionName, int deviceId, int userId, String reason) throws RemoteException;
- // Android 14 QPR3
+ @RequiresApi(34) // QPR3
void revokeRuntimePermission(String packageName, String permissionName, String persistentDeviceId, int userId, String reason) throws RemoteException;
- int checkPermission(String permName, String pkgName, int userId) throws RemoteException;
+ @RequiresApi(34) // QPR2
+ int checkPermission(String packageName, String permissionName, int deviceId, int userId) throws RemoteException;
+
+ @RequiresApi(34) // QPR3
+ int checkPermission(String packageName, String permissionName, String persistentDeviceId, int userId) throws RemoteException;
abstract class Stub extends Binder implements IPermissionManager {
public static IPermissionManager asInterface(IBinder obj) {