diff --git a/app/build.gradle b/app/build.gradle index 1c7618a..84d04bc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,8 +17,8 @@ android { applicationId "com.rosan.accounts" minSdk 21 targetSdk 33 - versionCode 5 - versionName "1.4" + versionCode 6 + versionName "1.5" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -74,7 +74,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.3.2' + kotlinCompilerExtensionVersion '1.4.5' } packagingOptions { resources { @@ -103,10 +103,22 @@ dependencies { debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' - implementation project(':hidden-api') + compileOnly project(':hidden-api') + + implementation 'androidx.navigation:navigation-compose:2.6.0' + + implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3' def accompanist_version = '0.30.1' implementation "com.google.accompanist:accompanist-drawablepainter:$accompanist_version" + implementation "com.google.accompanist:accompanist-navigation-animation:$accompanist_version" + implementation "com.google.accompanist:accompanist-insets:$accompanist_version" + implementation "com.google.accompanist:accompanist-insets-ui:$accompanist_version" + implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" + + implementation 'io.insert-koin:koin-core:3.4.2' + implementation 'io.insert-koin:koin-android:3.4.2' + implementation 'io.insert-koin:koin-androidx-compose:3.4.5' def shizuku_version = "13.1.4" implementation "dev.rikka.shizuku:api:$shizuku_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 97612e6..c1a5d1b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ diff --git a/app/src/main/java/com/rosan/accounts/AccountInfo.kt b/app/src/main/java/com/rosan/accounts/AccountInfo.kt deleted file mode 100644 index f75c20a..0000000 --- a/app/src/main/java/com/rosan/accounts/AccountInfo.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.rosan.accounts - -data class AccountInfo(val userId: Int, val type: String, val name: String) diff --git a/app/src/main/java/com/rosan/accounts/AccountType.kt b/app/src/main/java/com/rosan/accounts/AccountType.kt deleted file mode 100644 index 1cad241..0000000 --- a/app/src/main/java/com/rosan/accounts/AccountType.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.rosan.accounts - -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable - -data class AccountType( - val userId: Int = 0, - val packageName: String, - val type: String = packageName, - val label: String = packageName, - val icon: Drawable = ColorDrawable(0x00000000), - val values: List = emptyList() -) diff --git a/app/src/main/java/com/rosan/accounts/App.kt b/app/src/main/java/com/rosan/accounts/App.kt new file mode 100644 index 0000000..88629c9 --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/App.kt @@ -0,0 +1,22 @@ +package com.rosan.accounts + +import android.app.Application +import android.os.Build +import com.rosan.accounts.di.init.appModules +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin +import org.lsposed.hiddenapibypass.HiddenApiBypass + +class App : Application() { + override fun onCreate() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + HiddenApiBypass.addHiddenApiExemptions("") + super.onCreate() + startKoin { + androidLogger() + androidContext(this@App) + modules(appModules) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/MainActivity.kt b/app/src/main/java/com/rosan/accounts/MainActivity.kt deleted file mode 100644 index 826aeb5..0000000 --- a/app/src/main/java/com/rosan/accounts/MainActivity.kt +++ /dev/null @@ -1,357 +0,0 @@ -package com.rosan.accounts - -import android.annotation.SuppressLint -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Close -import androidx.compose.material.icons.twotone.ContentCopy -import androidx.compose.material.icons.twotone.Delete -import androidx.compose.material.icons.twotone.Face -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.lifecycle.lifecycleScope -import com.google.accompanist.drawablepainter.rememberDrawablePainter -import com.rosan.accounts.data.common.utils.contentCopy -import com.rosan.accounts.data.common.utils.requireShizukuPermissionGranted -import com.rosan.accounts.data.common.utils.toast -import com.rosan.accounts.ui.theme.AccountsTheme -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.json.JSONArray - -class MainActivity : ComponentActivity() { - private val context = this - - private var jobOrNull: Job? = null - - private val refreshState = mutableStateOf(true) - - private val users = mutableStateMapOf>() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent() - } - - override fun onResume() { - super.onResume() - refresh() - } - - private fun refresh() { - refreshState.value = true - shizukuJob { - // Wait for the system cache be refreshed - delay(1500) - getUsers() - } - } - - @OptIn( - ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class - ) - private fun setContent() { - setContent { - AccountsTheme { - Surface( - modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background - ) { - val refreshing by refreshState - val pullRefreshState = rememberPullRefreshState( - refreshing = refreshing, onRefresh = ::refresh - ) - - var currentIndex by rememberSaveable { - mutableStateOf(0) - } - val userKeys = (if (users.isNotEmpty()) users.keys else listOf( - UserInfo( - 0, - "Default", - 0 - ) - )).sortedBy { it.id } - if (currentIndex >= userKeys.size) - currentIndex = 0 - val currentUser = userKeys[currentIndex] - val accountTypes = users[currentUser] ?: emptyList() - - Scaffold(topBar = { - TopAppBar(navigationIcon = { - IconButton(onClick = { this.finish() }) { - Icon( - imageVector = Icons.TwoTone.Close, contentDescription = null - ) - } - }, title = { - Text(stringResource(id = R.string.app_name)) - }, actions = { - AnimatedVisibility(currentUser.id != 0) { - val show = remember { - mutableStateOf(false) - } - IconButton(onClick = { - show.value = true - }) { - Icon( - imageVector = Icons.TwoTone.Delete, - contentDescription = "delete user" - ) - } - if (!show.value) return@AnimatedVisibility - DeleteDialog(show, currentUser) - } - IconButton(onClick = { - val json = JSONArray() - accountTypes.map { it.packageName } - .distinct() - .forEach { - json.put(it) - } - context.contentCopy(json.toString()) - context.toast("copied, import it in Hail!") - }) { - Icon( - imageVector = Icons.TwoTone.ContentCopy, - contentDescription = "copy" - ) - } - }) - }) { - Box( - modifier = Modifier - .padding(it) - .pullRefresh(pullRefreshState) - ) { - UsersPage( - currentUser = currentUser, - users = userKeys, - accountTypes = accountTypes, - onSelected = { - currentIndex = userKeys.indexOf(it) - } - ) - - PullRefreshIndicator( - modifier = Modifier.align(Alignment.TopCenter), - refreshing = refreshing, - state = pullRefreshState - ) - } - } - } - } - } - } - - @OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) - @Composable - private fun UsersPage( - currentUser: UserInfo, - users: List, - accountTypes: List, - onSelected: (UserInfo) -> Unit - ) { - Column(modifier = Modifier.fillMaxSize()) { - AnimatedContent( - accountTypes, modifier = Modifier - .weight(1f) - .fillMaxSize() - ) { accountTypes -> - if (accountTypes.isEmpty()) Box( - modifier = Modifier.fillMaxSize() - ) { - Text( - text = "No Application For Account", - modifier = Modifier.align(Alignment.Center), - style = MaterialTheme.typography.titleMedium - ) - } else LazyVerticalStaggeredGrid( - modifier = Modifier.fillMaxSize(), - columns = if (accountTypes.isEmpty()) StaggeredGridCells.Fixed(1) else StaggeredGridCells.Adaptive( - 200.dp - ), - contentPadding = PaddingValues(16.dp), - verticalItemSpacing = 16.dp, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(accountTypes, key = { - "${it.userId}:${it.type}" - }) { - ItemWidget(it) - } - } - } - AnimatedContent(users) { users -> - NavigationBar( - modifier = Modifier - .fillMaxWidth() - ) { - users.forEach { user -> - NavigationBarItem( - selected = currentUser.id == user.id, - onClick = { onSelected(user) }, - icon = { - Icon( - imageVector = Icons.TwoTone.Face, - contentDescription = null - ) - }, - label = { - Text("${user.name} (${user.id})") - }, - alwaysShowLabel = false - ) - } - } - } - } - } - - @SuppressLint("InlinedApi") - @Composable - private fun ItemWidget(accountType: AccountType) { - ElevatedCard { - var showAccounts by remember { - mutableStateOf(false) - } - Row(modifier = Modifier - .clickable { - showAccounts = !showAccounts - } - .padding(horizontal = 24.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp)) { - Image( - modifier = Modifier - .size(32.dp) - .align(Alignment.CenterVertically), - painter = rememberDrawablePainter(accountType.icon), - contentDescription = null - ) - Column(modifier = Modifier.weight(1f)) { - @Composable - fun MyText( - text: String, style: TextStyle = MaterialTheme.typography.bodyMedium - ) { - Text(text, style = style) - } - MyText(accountType.label, style = MaterialTheme.typography.titleMedium) - MyText("userId: ${accountType.userId}") - MyText("package: ${accountType.packageName}") - MyText("type: ${accountType.type}") - AnimatedVisibility(visible = showAccounts && accountType.values.isNotEmpty()) { - MyText("accounts: ${accountType.values.joinToString()}") - } - } - } - } - } - - @Composable - private fun DeleteDialog(show: MutableState, user: UserInfo) { - AlertDialog(onDismissRequest = { show.value = false }, title = { - Text("${user.name} (${user.id})") - }, text = { - Text( - """Are you sure you want to delete this user space? -All applications and data in this user space will be lost""" - ) - }, confirmButton = { - TextButton(onClick = { - shizukuJob { - show.value = false - removeUser(user.id) - refresh() - } - }) { - Text("Sure") - } - }, dismissButton = { - TextButton(onClick = { show.value = false }) { - Text("Cancel") - } - }) - } - - private fun shizukuJob(action: suspend () -> Unit) { - jobOrNull?.cancel() - jobOrNull = lifecycleScope.launch(Dispatchers.IO) { - val result = kotlin.runCatching { - requireShizukuPermissionGranted(context) { - action.invoke() - } - } - withContext(Dispatchers.Main) { - result.onFailure { - if (it is CancellationException) return@onFailure - it.printStackTrace() - this@MainActivity.toast("$it ${it.localizedMessage}") - } - refreshState.value = false - } - } - } - - private suspend fun getUsers() { - val result = UserService.getUsers(this@MainActivity) - withContext(Dispatchers.Main) { - users.clear() - users.putAll(result) - refreshState.value = false - } - } - - private suspend fun removeUser(userId: Int): Boolean = UserService.removeUser(userId) -} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/UserInfo.kt b/app/src/main/java/com/rosan/accounts/UserInfo.kt deleted file mode 100644 index ff1aa89..0000000 --- a/app/src/main/java/com/rosan/accounts/UserInfo.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.rosan.accounts - -data class UserInfo(val id: Int, val name: String, val numberOfAccounts: Int) \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/UserService.kt b/app/src/main/java/com/rosan/accounts/UserService.kt deleted file mode 100644 index 5b71219..0000000 --- a/app/src/main/java/com/rosan/accounts/UserService.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.rosan.accounts - -import android.accounts.IAccountManager -import android.content.Context -import android.content.pm.IPackageManager -import android.os.Binder -import android.os.Build -import android.os.IBinder -import android.os.IUserManager -import android.os.Parcel -import android.os.ParcelFileDescriptor -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import rikka.shizuku.ShizukuBinderWrapper -import rikka.shizuku.SystemServiceHelper -import java.io.FileDescriptor - -object UserService { - suspend fun removeUser(userId: Int): Boolean { - return IUserManager.Stub.asInterface(shizukuBinder(Context.USER_SERVICE)) - .removeUser(userId) - } - - suspend fun getUsers(context: Context): Map> { - val basePackageManager = context.packageManager - val accountManager = - IAccountManager.Stub.asInterface(shizukuBinder(Context.ACCOUNT_SERVICE)) - val packageManager = IPackageManager.Stub.asInterface(shizukuBinder("package")) - val users = mutableMapOf>() - getAccounts().forEach { (user, accounts) -> - val userId = user.id - val types = accountManager.getAuthenticatorTypes(userId).map { - val type = it.type - val values = accounts.filter { it.type == type }.map { it.name } - if (values.isEmpty()) return@map null - - val packageName = it.packageName - - val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - packageManager.getPackageInfo(packageName, 0L, userId) - else packageManager.getPackageInfo(packageName, 0, userId) - val applicationInfo = packageInfo.applicationInfo - var label = basePackageManager.getApplicationLabel(applicationInfo).toString() - val accountName = - basePackageManager.getText(packageName, it.labelId, applicationInfo) - - if (accountName != null && label != accountName) - label += " - $accountName" - - val icon = basePackageManager.getApplicationIcon(applicationInfo) - AccountType( - userId = userId, - packageName = it.packageName, - type = type, - label = label, - icon = icon, - values = values - ) - } - users[user] = types.filterNotNull() - } - return users - } - - private suspend fun getAccounts(): Map> { - val text = dumpsysAccount() - - val result = mutableMapOf>() - - val users = "User UserInfo\\{(\\d+):(.*):.*\\}".toRegex().findAll(text).toList().map { - val userId = it.groupValues[1].toInt() - val name = it.groupValues[2] - UserInfo(id = userId, name = name, numberOfAccounts = 0).apply { - result[this] = listOf() - } - }.let { users -> - "Accounts: (\\d+)".toRegex().findAll(text).toList().let { - users.mapIndexed { index, user -> - val numberOfAccounts = it[index].groupValues[1].toInt() - user.copy(numberOfAccounts = numberOfAccounts) - } - } - }.toMutableList() - - fun readUser(): UserInfo { - val user = users.first().let { user -> - if (user.numberOfAccounts > 0) return@let user - return readUser() - }.let { - it.copy(numberOfAccounts = it.numberOfAccounts - 1) - } - users[0] = user - return user - } - - "Account \\{name=(.*), type=(.*)\\}".toRegex().findAll(text).toList().map { - val name = it.groupValues[1] - val type = it.groupValues[2] - val userId = readUser().id - - AccountInfo( - userId = userId, - type = type, - name = name - ) - }.groupBy { it.userId }.map { (userId, accounts) -> - result[result.keys.first { userId == it.id }] = accounts - } - - return result - } - - private suspend fun dumpsysAccount(): String = withContext(Dispatchers.IO) { - val binder = SystemServiceHelper.getSystemService(Context.ACCOUNT_SERVICE) - val pipe = ParcelFileDescriptor.createPipe() - val readFD = pipe[0] - val writeFD = pipe[1] - writeFD.use { - binderWrapperDump(binder, writeFD.fileDescriptor) - } - return@withContext readFD.use { - ParcelFileDescriptor.AutoCloseInputStream(it) - .readBytes() - .decodeToString() - } - } - - private fun binderWrapperDump( - binder: IBinder, - fd: FileDescriptor, - args: Array? = null - ) { - val data = Parcel.obtain() - val reply = Parcel.obtain() - try { - data.writeFileDescriptor(fd) - data.writeStringArray(args) - shizukuBinder(binder).transact(Binder.DUMP_TRANSACTION, data, reply, 0) - reply.readException() - } finally { - data.recycle() - reply.recycle() - } - } - - private fun shizukuBinder(name: String): IBinder { - return shizukuBinder(SystemServiceHelper.getSystemService(name)) - } - - private fun shizukuBinder(binder: IBinder): IBinder { - return ShizukuBinderWrapper(binder) - } -} diff --git a/app/src/main/java/com/rosan/accounts/data/common/utils/BinderUtil.kt b/app/src/main/java/com/rosan/accounts/data/common/utils/BinderUtil.kt new file mode 100644 index 0000000..18c3f1d --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/data/common/utils/BinderUtil.kt @@ -0,0 +1,37 @@ +package com.rosan.accounts.data.common.utils + +import android.os.Binder +import android.os.IBinder +import android.os.Parcel +import android.os.ParcelFileDescriptor +import java.io.FileDescriptor + +fun IBinder.transactDump(fd: FileDescriptor, args: Array? = null) { + val data = Parcel.obtain() + val reply = Parcel.obtain() + try { + data.writeFileDescriptor(fd) + data.writeStringArray(args) + this.transact(Binder.DUMP_TRANSACTION, data, reply, 0) + reply.readException() + } finally { + data.recycle() + reply.recycle() + } +} + +fun IBinder.dumpBytes(args: Array? = null): ByteArray { + val pipe = ParcelFileDescriptor.createPipe() + val readFD = pipe[0] + val writeFD = pipe[1] + writeFD.use { + this.transactDump(it.fileDescriptor, args) + } + return readFD.use { + ParcelFileDescriptor.AutoCloseInputStream(it) + .readBytes() + } +} + +fun IBinder.dumpText(args: Array? = null): String = + dumpBytes(args).decodeToString() \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/data/common/utils/ContextUtil.kt b/app/src/main/java/com/rosan/accounts/data/common/utils/ContextUtil.kt index cb69fcf..0d4a2ca 100644 --- a/app/src/main/java/com/rosan/accounts/data/common/utils/ContextUtil.kt +++ b/app/src/main/java/com/rosan/accounts/data/common/utils/ContextUtil.kt @@ -16,7 +16,7 @@ fun runOnUiThread(action: () -> Unit) { } } -fun Context.contentCopy(text: CharSequence) { +fun Context.copy(text: CharSequence) { runOnUiThread { val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager manager.setPrimaryClip(ClipData.newPlainText("Label", text)) diff --git a/app/src/main/java/com/rosan/accounts/data/common/utils/MapsUtil.kt b/app/src/main/java/com/rosan/accounts/data/common/utils/MapsUtil.kt new file mode 100644 index 0000000..07c65dd --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/data/common/utils/MapsUtil.kt @@ -0,0 +1,7 @@ +package com.rosan.accounts.data.common.utils + +inline fun MutableMap.replace(key: K, action: (value: V?) -> V): V { + val newValue = this[key].let(action) + this[key] = newValue + return newValue +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/data/common/utils/ShizukuUtil.kt b/app/src/main/java/com/rosan/accounts/data/common/utils/ShizukuUtil.kt index 4d34194..0ce8cf4 100644 --- a/app/src/main/java/com/rosan/accounts/data/common/utils/ShizukuUtil.kt +++ b/app/src/main/java/com/rosan/accounts/data/common/utils/ShizukuUtil.kt @@ -2,10 +2,13 @@ package com.rosan.accounts.data.common.utils import android.content.Context import android.content.pm.PackageManager +import android.os.IBinder +import android.os.ServiceManager import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.first import rikka.shizuku.Shizuku +import rikka.shizuku.ShizukuBinderWrapper import rikka.sui.Sui suspend fun requireShizukuPermissionGranted(context: Context, action: suspend () -> T): T { @@ -29,4 +32,9 @@ suspend fun requireShizukuPermissionGranted(context: Context, action: suspen } }.first() return action() -} \ No newline at end of file +} + +fun shizukuBinder(name: String): IBinder = + shizukuBinder(ServiceManager.getService(name)) + +fun shizukuBinder(binder: IBinder): IBinder = ShizukuBinderWrapper(binder) \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/data/service/entity/AccountAuthenticatorEntity.kt b/app/src/main/java/com/rosan/accounts/data/service/entity/AccountAuthenticatorEntity.kt new file mode 100644 index 0000000..9f6631c --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/data/service/entity/AccountAuthenticatorEntity.kt @@ -0,0 +1,11 @@ +package com.rosan.accounts.data.service.entity + +import android.graphics.drawable.Drawable + +data class AccountAuthenticatorEntity( + val userId: Int, + val type: String, + val packageName: String, + val label: String, + val icon: Drawable +) \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/data/service/entity/AccountEntity.kt b/app/src/main/java/com/rosan/accounts/data/service/entity/AccountEntity.kt new file mode 100644 index 0000000..dd9a90d --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/data/service/entity/AccountEntity.kt @@ -0,0 +1,7 @@ +package com.rosan.accounts.data.service.entity + +data class AccountEntity( + val userId: Int, + val type: String, + val name: String +) \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/data/service/entity/UserEntity.kt b/app/src/main/java/com/rosan/accounts/data/service/entity/UserEntity.kt new file mode 100644 index 0000000..3390589 --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/data/service/entity/UserEntity.kt @@ -0,0 +1,6 @@ +package com.rosan.accounts.data.service.entity + +data class UserEntity( + val id: Int, + val name: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/data/service/model/ShizukuUserService.kt b/app/src/main/java/com/rosan/accounts/data/service/model/ShizukuUserService.kt new file mode 100644 index 0000000..1595e32 --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/data/service/model/ShizukuUserService.kt @@ -0,0 +1,128 @@ +package com.rosan.accounts.data.service.model + +import android.accounts.IAccountManager +import android.content.Context +import android.content.pm.IPackageManager +import android.os.Build +import android.os.IUserManager +import android.util.Log +import com.rosan.accounts.data.common.utils.dumpText +import com.rosan.accounts.data.common.utils.requireShizukuPermissionGranted +import com.rosan.accounts.data.common.utils.shizukuBinder +import com.rosan.accounts.data.service.entity.AccountAuthenticatorEntity +import com.rosan.accounts.data.service.entity.AccountEntity +import com.rosan.accounts.data.service.entity.UserEntity +import com.rosan.accounts.data.service.repo.UserService +import rikka.shizuku.Shizuku + +class ShizukuUserService(private val context: Context) : UserService { + private val basePackageManager by lazy { context.packageManager } + + private val userManager by lazy { IUserManager.Stub.asInterface(shizukuBinder(Context.USER_SERVICE)) } + + private val accountManager by lazy { IAccountManager.Stub.asInterface(shizukuBinder(Context.ACCOUNT_SERVICE)) } + + private val packageManager by lazy { IPackageManager.Stub.asInterface(shizukuBinder("package")) } + override suspend fun removeUser(user: UserEntity): Boolean = removeUser(user.id) + + override suspend fun removeUser(userId: Int): Boolean = userManager.removeUser(userId) + + override suspend fun getUsers(): List = requireShizukuPermissionGranted(context) { + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + userManager.getUsers(false, false, false) + else userManager.getUsers(false)).map { + UserEntity(id = it.id, name = it.name) + } + } + + override suspend fun getAccountAuthenticators(userId: Int): List = + requireShizukuPermissionGranted(context) { + accountManager.getAuthenticatorTypes(userId).map { description -> + val packageName = description.packageName + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + packageManager.getPackageInfo(packageName, 0L, userId) + else packageManager.getPackageInfo(packageName, 0, userId) + val applicationInfo = packageInfo.applicationInfo + + val name = + basePackageManager.getText(packageName, description.labelId, applicationInfo) + val label = basePackageManager.getApplicationLabel(applicationInfo).toString().let { + if (it == name) it + else "$it - $name" + } + + val icon = basePackageManager.getApplicationIcon(applicationInfo) + + AccountAuthenticatorEntity( + userId = userId, + type = description.type, + packageName = description.packageName, + label = label, + icon = icon + ) + } + } + + override suspend fun getAccounts(userId: Int): List = + requireShizukuPermissionGranted(context) { + if (Shizuku.getUid() == 0) getAccountsByManager(userId) + else getAccountsByDump(userId) + } + + private fun getAccountsByManager(userId: Int): List { + return (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val packageName = + basePackageManager.getPackagesForUid(Shizuku.getUid())?.firstOrNull() ?: "android" + accountManager.getAccountsAsUser(null, userId, packageName) + } else accountManager.getAccountsAsUser(null, userId)).map { + AccountEntity( + userId = userId, + type = it.type, + name = it.name + ) + } + } + + private fun getAccountsByDump(userId: Int): List { + val text = accountManager.asBinder().dumpText() + fun getUserIds() = + "User UserInfo\\{(\\d+):.*?\\}".toRegex() + .findAll(text) + .toList() + .map { + it.groupValues[1].toInt() + } + + fun getLengthsOfAccounts() = + "Accounts: (\\d+)".toRegex() + .findAll(text) + .toList() + .map { + it.groupValues[1].toInt() + } + + val userIds = getUserIds() + val index = userIds.indexOf(userId) + + val lengthsOfAccounts = getLengthsOfAccounts() + val skipLength = lengthsOfAccounts.slice(0 until index).fold(0) { cur, len -> + cur + len + } + val length = lengthsOfAccounts[index] + + return "Account \\{name=(.*), type=(.*)\\}".toRegex() + .findAll(text) + .toList() + .slice(skipLength until skipLength + length) + .map { + val name = it.groupValues[1] + val type = it.groupValues[2] + + AccountEntity( + userId = userId, + type = type, + name = name + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/data/service/repo/UserService.kt b/app/src/main/java/com/rosan/accounts/data/service/repo/UserService.kt new file mode 100644 index 0000000..f972ebb --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/data/service/repo/UserService.kt @@ -0,0 +1,17 @@ +package com.rosan.accounts.data.service.repo + +import com.rosan.accounts.data.service.entity.AccountAuthenticatorEntity +import com.rosan.accounts.data.service.entity.AccountEntity +import com.rosan.accounts.data.service.entity.UserEntity + +interface UserService { + suspend fun removeUser(user: UserEntity): Boolean + + suspend fun removeUser(userId: Int): Boolean + + suspend fun getUsers(): List + + suspend fun getAccountAuthenticators(userId: Int): List + + suspend fun getAccounts(userId: Int): List +} diff --git a/app/src/main/java/com/rosan/accounts/di/init/app_modules.kt b/app/src/main/java/com/rosan/accounts/di/init/app_modules.kt new file mode 100644 index 0000000..eac4a1d --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/di/init/app_modules.kt @@ -0,0 +1,9 @@ +package com.rosan.accounts.di.init + +import com.rosan.accounts.di.serviceModule +import com.rosan.accounts.di.viewModelModule + +val appModules = listOf( + viewModelModule, + serviceModule +) \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/di/service_module.kt b/app/src/main/java/com/rosan/accounts/di/service_module.kt new file mode 100644 index 0000000..4756bbe --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/di/service_module.kt @@ -0,0 +1,12 @@ +package com.rosan.accounts.di + +import com.rosan.accounts.data.service.model.ShizukuUserService +import com.rosan.accounts.data.service.repo.UserService +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val serviceModule = module { + single { + ShizukuUserService(androidContext()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/di/viewmodel_module.kt b/app/src/main/java/com/rosan/accounts/di/viewmodel_module.kt new file mode 100644 index 0000000..f8838f9 --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/di/viewmodel_module.kt @@ -0,0 +1,16 @@ +package com.rosan.accounts.di + +import com.rosan.accounts.ui.page.account_manager.AccountManagerViewModel +import com.rosan.accounts.ui.page.user_manager.UserManagerViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val viewModelModule = module { + viewModel { + UserManagerViewModel() + } + + viewModel { + AccountManagerViewModel(get()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/activity/MainActivity.kt b/app/src/main/java/com/rosan/accounts/ui/activity/MainActivity.kt new file mode 100644 index 0000000..b863ed5 --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/activity/MainActivity.kt @@ -0,0 +1,26 @@ +package com.rosan.accounts.ui.activity + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.rosan.accounts.ui.page.main.MainPage +import com.rosan.accounts.ui.theme.AccountsTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AccountsTheme { + Surface( + modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background + ) { + MainPage() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerPage.kt b/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerPage.kt new file mode 100644 index 0000000..5cd29f9 --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerPage.kt @@ -0,0 +1,173 @@ +package com.rosan.accounts.ui.page.account_manager + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.ContentCopy +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +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.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import com.rosan.accounts.R +import com.rosan.accounts.data.common.utils.copy +import com.rosan.accounts.data.common.utils.toast +import org.json.JSONArray +import org.koin.androidx.compose.getViewModel +import org.koin.core.parameter.parametersOf + +@OptIn( + ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class, + ExperimentalFoundationApi::class +) +@Composable +fun AccountManagerPage( + userId: Int, + navController: NavController, + viewModel: AccountManagerViewModel = getViewModel { + parametersOf(userId) + } +) { + SideEffect { + viewModel.dispatch(AccountManagerViewAction.Load) + } + + val context = LocalContext.current + + Scaffold( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.displayCutout), + topBar = { + TopAppBar( + title = { + Text(stringResource(R.string.account_manager)) + }, + actions = { + IconButton(onClick = { + val array = JSONArray() + viewModel.state.authenticators.forEach { + array.put(it.auth.packageName) + } + context.copy(array.toString()) + context.toast(R.string.copied_format_hail) + }) { + Icon( + imageVector = Icons.TwoTone.ContentCopy, + contentDescription = null + ) + } + } + ) + }, + ) { + AnimatedContent( + viewModel.state.authenticators.isEmpty(), + modifier = Modifier + .fillMaxSize() + .padding(it) + ) { + if (it) { + Box( + modifier = Modifier.fillMaxSize() + ) { + Text( + stringResource(R.string.account_empty), + modifier = Modifier.align(Alignment.Center) + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(viewModel.state.authenticators, key = { + it.auth.type + }) { + var alpha by remember { + mutableStateOf(0f) + } + ItemWidget( + modifier = Modifier + .fillMaxWidth() +// .clip(RoundedCornerShape(8.dp)) + .animateItemPlacement() + .graphicsLayer( + alpha = animateFloatAsState( + targetValue = alpha, + animationSpec = spring(stiffness = 100f) + ).value + ), + authenticator = it + ) + SideEffect { + alpha = 1f + } + } + } + } + } + } +} + +@Composable +private fun ItemWidget( + modifier: Modifier = Modifier, + authenticator: AccountManagerViewState.Authenticator +) { + OutlinedCard(modifier = modifier) { + Row( + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + Image( + modifier = Modifier + .size(32.dp) + .align(Alignment.CenterVertically), + painter = rememberDrawablePainter(authenticator.auth.icon), + contentDescription = null + ) + Column { + Text(authenticator.auth.label, style = MaterialTheme.typography.titleMedium) + Text(authenticator.auth.type, style = MaterialTheme.typography.bodyMedium) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerViewAction.kt b/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerViewAction.kt new file mode 100644 index 0000000..96992cc --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerViewAction.kt @@ -0,0 +1,5 @@ +package com.rosan.accounts.ui.page.account_manager + +sealed class AccountManagerViewAction { + object Load : AccountManagerViewAction() +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerViewModel.kt b/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerViewModel.kt new file mode 100644 index 0000000..ba84252 --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerViewModel.kt @@ -0,0 +1,54 @@ +package com.rosan.accounts.ui.page.account_manager + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rosan.accounts.data.common.utils.replace +import com.rosan.accounts.data.service.repo.UserService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class AccountManagerViewModel( + private val userId: Int +) : ViewModel(), KoinComponent { + private val jobs = mutableMapOf() + + private val userService by inject() + + var state by mutableStateOf(AccountManagerViewState()) + private set + + fun dispatch(action: AccountManagerViewAction) { + when (action) { + AccountManagerViewAction.Load -> load() + } + } + + private fun load() { + jobs.replace("load") { + it?.cancel() + + viewModelScope.launch(Dispatchers.IO) { + while (true) { + val auths = userService.getAccountAuthenticators(userId) + val accounts = userService.getAccounts(userId) + state = state.copy( + authenticators = auths.map { auth -> + AccountManagerViewState.Authenticator( + auth = auth, + accounts = accounts.filter { auth.type == it.type } + ) + }.filter { it.accounts.isNotEmpty() } + ) + delay(3000) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerViewState.kt b/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerViewState.kt new file mode 100644 index 0000000..f39693d --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/page/account_manager/AccountManagerViewState.kt @@ -0,0 +1,13 @@ +package com.rosan.accounts.ui.page.account_manager + +import com.rosan.accounts.data.service.entity.AccountAuthenticatorEntity +import com.rosan.accounts.data.service.entity.AccountEntity + +data class AccountManagerViewState( + val authenticators: List = emptyList() +) { + data class Authenticator( + val auth: AccountAuthenticatorEntity, + val accounts: List + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/page/main/MainPage.kt b/app/src/main/java/com/rosan/accounts/ui/page/main/MainPage.kt new file mode 100644 index 0000000..2ce93cb --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/page/main/MainPage.kt @@ -0,0 +1,57 @@ +package com.rosan.accounts.ui.page.main + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.runtime.Composable +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.google.accompanist.navigation.animation.AnimatedNavHost +import com.google.accompanist.navigation.animation.composable +import com.google.accompanist.navigation.animation.rememberAnimatedNavController +import com.rosan.accounts.ui.page.account_manager.AccountManagerPage +import com.rosan.accounts.ui.page.user_manager.UserManagerPage + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun MainPage() { + val navController = rememberAnimatedNavController() + + AnimatedNavHost( + navController = navController, + startDestination = MainScreen.UserManager.route + ) { + composable(route = MainScreen.UserManager.route) { + UserManagerPage( + navController = navController + ) + } + composable( + route = MainScreen.AccountManager.route, + arguments = listOf( + navArgument("id") { + type = NavType.IntType + } + ), + enterTransition = { + slideIntoContainer( + AnimatedContentScope.SlideDirection.Up, + ) + }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentScope.SlideDirection.Down, + ) + } + ) { + val userId = it.arguments?.getInt("id") + if (userId == null) { + navController.navigateUp() + return@composable + } + AccountManagerPage( + userId = userId, + navController = navController + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/page/main/MainScreen.kt b/app/src/main/java/com/rosan/accounts/ui/page/main/MainScreen.kt new file mode 100644 index 0000000..11f4b8e --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/page/main/MainScreen.kt @@ -0,0 +1,10 @@ +package com.rosan.accounts.ui.page.main + +sealed class MainScreen(val route: String) { + object UserManager : MainScreen("user") + + object AccountManager : MainScreen("user/{id}/account") { + fun builder(id: Int): String = + "user/$id/account" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/page/user_manager/UserManagerPage.kt b/app/src/main/java/com/rosan/accounts/ui/page/user_manager/UserManagerPage.kt new file mode 100644 index 0000000..00cbb2f --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/page/user_manager/UserManagerPage.kt @@ -0,0 +1,212 @@ +package com.rosan.accounts.ui.page.user_manager + +import android.os.Process +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +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.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.rosan.accounts.R +import com.rosan.accounts.data.common.utils.id +import com.rosan.accounts.data.service.entity.UserEntity +import com.rosan.accounts.ui.page.main.MainScreen +import org.koin.androidx.compose.getViewModel + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class +) +@Composable +fun UserManagerPage( + navController: NavController, + viewModel: UserManagerViewModel = getViewModel() +) { + SideEffect { + viewModel.dispatch(UserManagerViewAction.Load) + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.displayCutout), + topBar = { + TopAppBar( + title = { + Text(stringResource(R.string.user_manager)) + } + ) + }, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(viewModel.state, key = { + it.id + }) { + var alpha by remember { + mutableStateOf(0f) + } + ItemWidget( + modifier = Modifier + .fillMaxWidth() + .animateItemPlacement() + .graphicsLayer( + alpha = animateFloatAsState( + targetValue = alpha, + animationSpec = spring(stiffness = 100f) + ).value + ), + viewModel = viewModel, + navController = navController, + user = it + ) + SideEffect { + alpha = 1f + } + } + } + } + } +} + +@Composable +private fun ItemWidget( + modifier: Modifier = Modifier, + viewModel: UserManagerViewModel, + navController: NavController, + user: UserEntity +) { + OutlinedCard( + modifier = modifier + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + user.id.toString(), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleMedium + ) + Text( + user.name ?: stringResource(R.string.user_name_default), + style = MaterialTheme.typography.titleMedium + ) + } + Row( + modifier = Modifier + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = { + navController.navigate(MainScreen.AccountManager.builder(user.id)) + }) { + Text(stringResource(R.string.account_manager)) + } + val curUserId = Process.myUserHandle().id + if (curUserId != user.id) { + var showing by remember { + mutableStateOf(false) + } + TextButton(onClick = { showing = true }) { + Text(stringResource(R.string.remove)) + } + DeleteUserDialog( + viewModel = viewModel, + showing = showing, + onDismissRequest = { + showing = false + }, + user = user + ) + } + } + } + } +} + +@Composable +private fun DeleteUserDialog( + viewModel: UserManagerViewModel, + showing: Boolean, + onDismissRequest: () -> Unit, + user: UserEntity +) { + if (!showing) return + AlertDialog(onDismissRequest = onDismissRequest, icon = { + Icon(imageVector = Icons.TwoTone.Warning, contentDescription = null) + }, title = { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + user.id.toString(), + color = MaterialTheme.colorScheme.primary + ) + Text(user.name ?: stringResource(R.string.user_name_default)) + } + }, text = { + Text(stringResource(R.string.delete_user_warning)) + }, confirmButton = { + TextButton(onClick = { + viewModel.dispatch(UserManagerViewAction.Remove(user)) + onDismissRequest() + }) { + Text(stringResource(R.string.delete_user_confirm)) + } + }, dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.delete_user_cancel)) + } + }) +} diff --git a/app/src/main/java/com/rosan/accounts/ui/page/user_manager/UserManagerViewAction.kt b/app/src/main/java/com/rosan/accounts/ui/page/user_manager/UserManagerViewAction.kt new file mode 100644 index 0000000..86cf7e0 --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/page/user_manager/UserManagerViewAction.kt @@ -0,0 +1,9 @@ +package com.rosan.accounts.ui.page.user_manager + +import com.rosan.accounts.data.service.entity.UserEntity + +sealed class UserManagerViewAction { + object Load : UserManagerViewAction() + + data class Remove(val user: UserEntity) : UserManagerViewAction() +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/page/user_manager/UserManagerViewModel.kt b/app/src/main/java/com/rosan/accounts/ui/page/user_manager/UserManagerViewModel.kt new file mode 100644 index 0000000..f456831 --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/page/user_manager/UserManagerViewModel.kt @@ -0,0 +1,51 @@ +package com.rosan.accounts.ui.page.user_manager + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rosan.accounts.data.common.utils.replace +import com.rosan.accounts.data.service.entity.UserEntity +import com.rosan.accounts.data.service.repo.UserService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class UserManagerViewModel : ViewModel(), KoinComponent { + private val jobs = mutableMapOf() + + private val userService by inject() + + var state by mutableStateOf(emptyList()) + private set + + fun dispatch(action: UserManagerViewAction) { + when (action) { + UserManagerViewAction.Load -> load() + is UserManagerViewAction.Remove -> remove(action.user) + } + } + + private fun load() { + jobs.replace("load") { + it?.cancel() + + viewModelScope.launch(Dispatchers.IO) { + while (true) { + state = userService.getUsers().sortedBy { it.id } + delay(1500) + } + } + } + } + + private fun remove(user: UserEntity) { + viewModelScope.launch { + userService.removeUser(user) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rosan/accounts/ui/theme/Theme.kt b/app/src/main/java/com/rosan/accounts/ui/theme/Theme.kt index f753ab1..64de9b0 100644 --- a/app/src/main/java/com/rosan/accounts/ui/theme/Theme.kt +++ b/app/src/main/java/com/rosan/accounts/ui/theme/Theme.kt @@ -2,6 +2,7 @@ package com.rosan.accounts.ui.theme import android.app.Activity import android.os.Build +import android.view.WindowManager import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme @@ -10,6 +11,7 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -54,17 +56,24 @@ fun AccountsTheme( else -> LightColorScheme } val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.surface.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme - window.navigationBarColor = colorScheme.surface.toArgb() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - window.navigationBarDividerColor = colorScheme.surface.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = - !darkTheme - } + SideEffect { + val window = (view.context as Activity).window + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + window.attributes.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + + WindowCompat.setDecorFitsSystemWindows(window, false) + + window.statusBarColor = Color.Transparent.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = + !darkTheme + + window.navigationBarColor = Color.Transparent.toArgb() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + window.navigationBarDividerColor = Color.Transparent.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = + !darkTheme } MaterialTheme( diff --git a/app/src/main/java/com/rosan/accounts/ui/widget/PositionDialog.kt b/app/src/main/java/com/rosan/accounts/ui/widget/PositionDialog.kt new file mode 100644 index 0000000..d29369b --- /dev/null +++ b/app/src/main/java/com/rosan/accounts/ui/widget/PositionDialog.kt @@ -0,0 +1,229 @@ +package com.rosan.accounts.ui.widget + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +fun PositionDialog( + properties: DialogProperties = DialogProperties(), + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + leftIcon: @Composable (() -> Unit)? = null, + centerIcon: @Composable (() -> Unit)? = null, + rightIcon: @Composable (() -> Unit)? = null, + leftTitle: @Composable (() -> Unit)? = null, + centerTitle: @Composable (() -> Unit)? = null, + rightTitle: @Composable (() -> Unit)? = null, + leftSubtitle: @Composable (() -> Unit)? = null, + centerSubtitle: @Composable (() -> Unit)? = null, + rightSubtitle: @Composable (() -> Unit)? = null, + leftText: @Composable (() -> Unit)? = null, + centerText: @Composable (() -> Unit)? = null, + rightText: @Composable (() -> Unit)? = null, + leftContent: @Composable (() -> Unit)? = null, + centerContent: @Composable (() -> Unit)? = null, + rightContent: @Composable (() -> Unit)? = null, + leftButton: @Composable (() -> Unit)? = null, + centerButton: @Composable (() -> Unit)? = null, + rightButton: @Composable (() -> Unit)? = null +) { + Dialog(onDismissRequest = onDismissRequest, properties = properties) { + Box(modifier = Modifier + .fillMaxSize() + .pointerInput(null) { + detectTapGestures(onTap = { + onDismissRequest() + }) + }) { + Box(modifier = Modifier + .align(Alignment.Center) + .pointerInput(null) { + detectTapGestures(onTap = {}) + }) { + Surface( + modifier = modifier, + shape = shape, + color = containerColor, + tonalElevation = tonalElevation + ) { + Box( + modifier = Modifier + .sizeIn(minWidth = MinWidth, maxHeight = MaxWidth) + .padding(DialogPadding) + ) { + // set the button always in bottom + var buttonHeightPx by remember { + mutableStateOf(0) + } + val buttonHeight = (buttonHeightPx / LocalDensity.current.density).dp + Box(modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { + buttonHeightPx = it.height + }) { + PositionChildWidget( + leftButton, centerButton, rightButton + ) { button -> + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + val textStyle = MaterialTheme.typography.labelLarge + ProvideTextStyle(value = textStyle) { + Box( + modifier = Modifier.padding(ButtonPadding) + ) { + button?.invoke() + } + } + } + } + } + + Column( + modifier = Modifier.padding(bottom = animateDpAsState(targetValue = buttonHeight).value) + ) { + PositionChildWidget( + leftIcon, centerIcon, rightIcon + ) { icon -> + CompositionLocalProvider(LocalContentColor provides iconContentColor) { + Box( + modifier = Modifier + .padding(IconPadding) + .align(Alignment.CenterHorizontally) + ) { + icon?.invoke() + } + } + } + PositionChildWidget( + leftTitle, centerTitle, rightTitle + ) { title -> + CompositionLocalProvider(LocalContentColor provides titleContentColor) { + ProvideTextStyle(MaterialTheme.typography.headlineSmall) { + Box( + modifier = Modifier + .padding(TitlePadding) + .align(Alignment.CenterHorizontally) + ) { + title?.invoke() + } + } + } + } + PositionChildWidget( + leftSubtitle, centerSubtitle, rightSubtitle + ) { subtitle -> + CompositionLocalProvider(LocalContentColor provides titleContentColor) { + val textStyle = MaterialTheme.typography.bodyMedium + ProvideTextStyle(textStyle) { + Box( + modifier = Modifier + .padding(SubtitlePadding) + .align(Alignment.CenterHorizontally) + ) { + subtitle?.invoke() + } + } + } + } + val contentMode = + leftContent != null || centerContent != null || rightContent != null + PositionChildWidget( + if (contentMode) leftContent else leftText, + if (contentMode) centerContent else centerText, + if (contentMode) rightContent else rightText + ) { text -> + CompositionLocalProvider(LocalContentColor provides textContentColor) { + val textStyle = MaterialTheme.typography.bodyMedium + ProvideTextStyle(textStyle) { + Box( + modifier = Modifier + .weight(weight = 1f, fill = false) + .padding(if (contentMode) ContentPadding else TextPadding) + ) { + text?.invoke() + } + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun PositionChildWidget( + left: @Composable (() -> Unit)? = null, + center: @Composable (() -> Unit)? = null, + right: @Composable (() -> Unit)? = null, + parent: @Composable ((child: @Composable (() -> Unit)?) -> Unit) +) { + if (left == null && center == null && right == null) return + Box(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.align(Alignment.TopStart)) { + parent.invoke(left) + } + Box(modifier = Modifier.align(Alignment.TopCenter)) { + parent.invoke(center) + } + Box(modifier = Modifier.align(Alignment.TopEnd)) { + parent.invoke(right) + } + } +} + +private val ButtonsMainAxisSpacing = 8.dp +private val ButtonsCrossAxisSpacing = 12.dp + +private val DialogSinglePadding = 24.dp + +private val DialogPadding = PaddingValues(top = DialogSinglePadding, bottom = DialogSinglePadding) +private val IconPadding = + PaddingValues.Absolute(left = DialogSinglePadding, right = DialogSinglePadding, bottom = 12.dp) +private val TitlePadding = + PaddingValues.Absolute(left = DialogSinglePadding, right = DialogSinglePadding, bottom = 12.dp) +private val SubtitlePadding = + PaddingValues.Absolute(left = DialogSinglePadding, right = DialogSinglePadding, bottom = 12.dp) +private val TextPadding = + PaddingValues.Absolute(left = DialogSinglePadding, right = DialogSinglePadding, bottom = 12.dp) +private val ContentPadding = PaddingValues.Absolute(bottom = 12.dp) +private val ButtonPadding = PaddingValues(horizontal = DialogSinglePadding) + +private val MinWidth = 280.dp +private val MaxWidth = 560.dp \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..03b4489 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,14 @@ + + + 用户管理 + 未命名 + + 确定要删除此用户吗?\n所有处于此用户空间的应用和数据都会消失!!! + 是的,我确定! + + 取消 + 移除 + 账号管理 + 没有账户存在 + 复制成功,请在【雹】中导入! + \ 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 9f7e2ff..3f0cf5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,16 @@ - Accounts + Accounts + + User Manager + Untitled + + Are you sure you want to delete this user space?\nAll applications and data in this user space will be lost!!! + Yes, I\'m sure! + No + + Remove + + Account Manager + No account exists + copied, import it in Hail! \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index bb2ab92..8daf508 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,8 @@ - \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4314313..fd3d478 100644 --- a/build.gradle +++ b/build.gradle @@ -2,5 +2,5 @@ plugins { id 'com.android.application' version '8.0.2' apply false id 'com.android.library' version '8.0.2' apply false - id 'org.jetbrains.kotlin.android' version '1.7.20' apply false + id 'org.jetbrains.kotlin.android' version '1.8.20' apply false } \ No newline at end of file diff --git a/hidden-api/build.gradle b/hidden-api/build.gradle index e374ce2..f850f6c 100644 --- a/hidden-api/build.gradle +++ b/hidden-api/build.gradle @@ -12,4 +12,6 @@ android { } dependencies { + def annotation = "1.6.0" + compileOnly "androidx.annotation:annotation:$annotation" } \ No newline at end of file diff --git a/hidden-api/src/main/java/android/accounts/IAccountManager.java b/hidden-api/src/main/java/android/accounts/IAccountManager.java index 0d6e83a..8e849a0 100644 --- a/hidden-api/src/main/java/android/accounts/IAccountManager.java +++ b/hidden-api/src/main/java/android/accounts/IAccountManager.java @@ -1,12 +1,22 @@ package android.accounts; import android.os.Binder; +import android.os.Build; import android.os.IBinder; import android.os.IInterface; +import androidx.annotation.DeprecatedSinceApi; +import androidx.annotation.RequiresApi; + public interface IAccountManager extends IInterface { AuthenticatorDescription[] getAuthenticatorTypes(int userId); + @RequiresApi(Build.VERSION_CODES.M) + Account[] getAccountsAsUser(String accountType, int userId, String opPackageName); + + @DeprecatedSinceApi(api = Build.VERSION_CODES.M) + Account[] getAccountsAsUser(String accountType, int userId); + abstract class Stub extends Binder implements IAccountManager { public static IAccountManager asInterface(IBinder obj) { throw new UnsupportedOperationException(); diff --git a/hidden-api/src/main/java/android/content/pm/IPackageManager.java b/hidden-api/src/main/java/android/content/pm/IPackageManager.java index 35d86b5..6cbcea7 100644 --- a/hidden-api/src/main/java/android/content/pm/IPackageManager.java +++ b/hidden-api/src/main/java/android/content/pm/IPackageManager.java @@ -1,12 +1,18 @@ package android.content.pm; import android.os.Binder; +import android.os.Build; import android.os.IBinder; import android.os.IInterface; +import androidx.annotation.DeprecatedSinceApi; +import androidx.annotation.RequiresApi; + public interface IPackageManager extends IInterface { + @RequiresApi(Build.VERSION_CODES.TIRAMISU) PackageInfo getPackageInfo(String packageName, long flags, int userId); + @DeprecatedSinceApi(api = Build.VERSION_CODES.TIRAMISU) PackageInfo getPackageInfo(String packageName, int flags, int userId); abstract class Stub extends Binder implements IPackageManager { diff --git a/hidden-api/src/main/java/android/content/pm/UserInfo.java b/hidden-api/src/main/java/android/content/pm/UserInfo.java new file mode 100644 index 0000000..f094d22 --- /dev/null +++ b/hidden-api/src/main/java/android/content/pm/UserInfo.java @@ -0,0 +1,37 @@ +package android.content.pm; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class UserInfo implements Parcelable { + public int id; + + public @Nullable String name; + + protected UserInfo(Parcel in) { + } + + public static final Creator CREATOR = new Creator() { + @Override + public UserInfo createFromParcel(Parcel in) { + return new UserInfo(in); + } + + @Override + public UserInfo[] newArray(int size) { + return new UserInfo[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + } +} diff --git a/hidden-api/src/main/java/android/os/IUserManager.java b/hidden-api/src/main/java/android/os/IUserManager.java index 98c9cc8..0df37c1 100644 --- a/hidden-api/src/main/java/android/os/IUserManager.java +++ b/hidden-api/src/main/java/android/os/IUserManager.java @@ -1,6 +1,19 @@ package android.os; +import android.content.pm.UserInfo; + +import androidx.annotation.DeprecatedSinceApi; +import androidx.annotation.RequiresApi; + +import java.util.List; + public interface IUserManager extends IInterface { + @RequiresApi(Build.VERSION_CODES.R) + List getUsers(boolean excludePartial, boolean excludeDying, boolean excludePreCreated); + + @DeprecatedSinceApi(api = Build.VERSION_CODES.R) + List getUsers(boolean excludeDying); + boolean removeUser(int userHandle); abstract class Stub extends Binder implements IUserManager { diff --git a/hidden-api/src/main/java/android/os/ServiceManager.java b/hidden-api/src/main/java/android/os/ServiceManager.java new file mode 100644 index 0000000..9250605 --- /dev/null +++ b/hidden-api/src/main/java/android/os/ServiceManager.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package android.os; + +import java.util.Map; + +/** + * @hide + */ +public final class ServiceManager { + public static IBinder getService(String name) { + throw new RuntimeException("STUB"); + } + + /** + * Returns a reference to a service with the given name, or throws + * {@link NullPointerException} if none is found. + * + * @hide + */ + public static IBinder getServiceOrThrow(String name) throws ServiceNotFoundException { + throw new RuntimeException("STUB"); + } + + /** + * Place a new @a service called @a name into the service + * manager. + * + * @param name the name of the new service + * @param service the service object + */ + public static void addService(String name, IBinder service) { + throw new RuntimeException("STUB"); + } + + /** + * Place a new @a service called @a name into the service + * manager. + * + * @param name the name of the new service + * @param service the service object + * @param allowIsolated set to true to allow isolated sandboxed processes + * to access this service + */ + public static void addService(String name, IBinder service, boolean allowIsolated) { + throw new RuntimeException("STUB"); + } + + /** + * Retrieve an existing service called @a name from the + * service manager. Non-blocking. + */ + public static IBinder checkService(String name) { + throw new RuntimeException("STUB"); + } + + /** + * Return a list of all currently running services. + * + * @return an array of all currently running services, or null in + * case of an exception + */ + public static String[] listServices() { + throw new RuntimeException("STUB"); + } + + /** + * This is only intended to be called when the process is first being brought + * up and bound by the activity manager. There is only one thread in the process + * at that time, so no locking is done. + * + * @param cache the cache of service references + * @hide + */ + public static void initServiceCache(Map cache) { + throw new RuntimeException("STUB"); + } + + /** + * Exception thrown when no service published for given name. This might be + * thrown early during boot before certain services have published + * themselves. + * + * @hide + */ + public static class ServiceNotFoundException extends Exception { + public ServiceNotFoundException(String name) { + super("No service published for: " + name); + } + } +} \ No newline at end of file