commit b65109447553a317ff8acb1f67dd053d1da77519
parent abdb95c402c3f3b3adf94d8808ef92dbd70543a6
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Mon, 23 Oct 2023 18:50:36 +0200

feat(manager): SE build downloader

Diffstat:
Mmanager/build.gradle.kts | 2++
Mmanager/src/main/AndroidManifest.xml | 13+++++++++++++
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt | 16++++++++++++++++
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/InstallStage.kt | 10++++++++++
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEArtifact.kt | 12++++++++++++
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEVersion.kt | 8++++++++
Mmanager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt | 122+++++++++++++++++++++++++++----------------------------------------------------
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Navigation.kt | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Tab.kt | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/HomeTab.kt | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/SettingsTab.kt | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallAppTab.kt | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amanager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SEDownloadTab.kt | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amanager/src/main/res/xml/provider_paths.xml | 4++++
14 files changed, 989 insertions(+), 81 deletions(-)

diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts @@ -3,6 +3,7 @@ import com.android.build.gradle.internal.api.BaseVariantOutputImpl plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinAndroid) + id("kotlin-parcelize") } android { @@ -74,6 +75,7 @@ dependencies { implementation(libs.guava) implementation(libs.apksig) implementation(libs.gson) + implementation(libs.okhttp) implementation(libs.androidx.material3) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.navigation.compose) diff --git a/manager/src/main/AndroidManifest.xml b/manager/src/main/AndroidManifest.xml @@ -5,10 +5,13 @@ <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" /> <application android:label="SE Manager" + tools:targetApi="34" + android:enableOnBackInvokedCallback="true" android:icon="@android:drawable/ic_input_add"> <activity android:name=".ui.MainActivity" android:exported="true"> <intent-filter> @@ -16,5 +19,15 @@ <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> + + <provider + android:name="androidx.core.content.FileProvider" + android:authorities="me.rhunk.snapenhance.manager.provider" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/provider_paths" /> + </provider> </application> </manifest> \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.manager.data + +import android.content.Context + +class SharedConfig( + context: Context +) { + private val sharedPreferences = context.getSharedPreferences("snapenhance", Context.MODE_PRIVATE) + + var snapchatPackageName get() = sharedPreferences.getString("snapchatPackageName", "com.snapchat.android")?.takeIf { it.isNotEmpty() } + set(value) = sharedPreferences.edit().putString("snapchatPackageName", value).apply() + + var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", "me.rhunk.snapenhance")?.takeIf { it.isNotEmpty() } + set(value) = sharedPreferences.edit().putString("snapEnhancePackageName", value).apply() +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/InstallStage.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/InstallStage.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.manager.data.download + +enum class InstallStage { + DOWNLOADING, + UNINSTALLING, + INSTALLING, + DONE, + ERROR; +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEArtifact.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEArtifact.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.manager.data.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class SEArtifact( + val fileName: String, + val size: Long, + val downloadUrl: String, +) : Parcelable+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEVersion.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEVersion.kt @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.manager.data.download + +data class SEVersion( + val versionName: String, + val releaseDate: String, + val downloadAssets: Map<String, SEArtifact>, +)+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt @@ -4,106 +4,66 @@ import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -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.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateListOf +import androidx.compose.material3.* import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.manager.BuildConfig -import me.rhunk.snapenhance.manager.lspatch.LSPatch -import java.io.File -import java.io.PrintWriter -import java.io.StringWriter +import androidx.navigation.compose.rememberNavController +import me.rhunk.snapenhance.manager.data.SharedConfig +import me.rhunk.snapenhance.manager.ui.tab.HomeTab +import me.rhunk.snapenhance.manager.ui.tab.SettingsTab class MainActivity : ComponentActivity() { + companion object{ + private val primaryTabs = listOf(HomeTab::class, SettingsTab::class) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val tabs = primaryTabs.mapNotNull { + runCatching { it.java.constructors.first().newInstance() as Tab }.getOrNull() + }.toMutableList().apply { + forEach { it.init(this@MainActivity) } + fun addNestedTabsRecursively(tabs: List<Tab>) { + tabs.forEach { tab -> + add(tab) + addNestedTabsRecursively(tab.nestedTabs) + } + } + toList().forEach { addNestedTabsRecursively(it.nestedTabs) } + } setContent { - val coroutineScope = rememberCoroutineScope() MaterialTheme( colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (isSystemInDarkTheme()) dynamicDarkColorScheme(LocalContext.current) else dynamicLightColorScheme(LocalContext.current) - } else MaterialTheme.colorScheme + } else darkColorScheme() ) { - val context = LocalContext.current - val logs = remember { mutableStateListOf<String>() } - fun printLog(data: Any) { - when (data) { - is Throwable -> { - logs += data.message.toString() - logs += StringWriter().apply { - data.printStackTrace(PrintWriter(this)) - }.toString() - } - else -> logs += data.toString() - } - } + val navHostController = rememberNavController() + val sharedConfig = remember { SharedConfig(this) } - val scrollState = rememberLazyListState(0) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp), - modifier = Modifier.padding(10.dp) - ) { - Text(text = "SE Manager") - - Button(onClick = { - coroutineScope.launch(Dispatchers.IO) { - runCatching { - val lspatch = LSPatch( - context, - mapOf( - BuildConfig.APPLICATION_ID to File(context.packageManager.getPackageInfo( - BuildConfig.APPLICATION_ID, 0).applicationInfo.sourceDir) - ) - ) { printLog(it) } - lspatch.patch( - File(context.packageManager.getPackageInfo("com.snapchat.android", 0).applicationInfo.sourceDir), - File(context.filesDir, "patched.apk") - ) - }.onFailure { printLog(it) } + val navigation = remember { + Navigation( + navHostController = navHostController, + tabs = tabs, + defaultTab = HomeTab::class + ).also { + tabs.forEach { tab -> + tab.navigation = it + tab.sharedConfig = sharedConfig } - }) { - Text(text = "Test patch apk") } + } - LazyColumn( - state = scrollState, - modifier = Modifier.fillMaxWidth().padding(5.dp).height(500.dp).border(1.dp, color = Color.Black), - content = { - items(logs) { - Text(text = it, modifier = Modifier.padding(2.dp)) - } - } - ) - - LaunchedEffect(logs.size) { - scrollState.scrollToItem((logs.size - 1).coerceAtLeast(0)) - } + Scaffold( + bottomBar = { navigation.BottomBar() }, + topBar = { navigation.TopBar() }, + floatingActionButton = { navigation.FloatingActionButtons() }, + floatingActionButtonPosition = FabPosition.End, + ) { + navigation.NavigationHost(it) } } } diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Navigation.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Navigation.kt @@ -0,0 +1,120 @@ +package me.rhunk.snapenhance.manager.ui + +import android.os.Bundle +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import kotlin.reflect.KClass + + +class Navigation( + val navHostController: NavHostController, + private val tabs: List<Tab>, + private val defaultTab: KClass<out Tab> +) { + + @Composable + fun TopBar() { + + } + + @Composable + fun FloatingActionButtons() { + + } + + fun navigateTo(tab: KClass<out Tab>) { + navHostController.navigate(tabs.first { it::class == tab }.route) + } + + fun navigateTo(tab: KClass<out Tab>, noHistory: Boolean) { + navHostController.navigate(tabs.first { it::class == tab }.route) { + restoreState = false + launchSingleTop = true + popUpTo(navHostController.graph.findStartDestination().id) { + saveState = true + } + } + } + + fun navigateTo(tab: KClass<out Tab>, args: Bundle) { + navHostController.currentBackStackEntry?.savedStateHandle?.apply { + set("args", args) + } + navigateTo(tab) + } + + @Composable + fun NavigationHost( + innerPadding: PaddingValues + ) { + NavHost( + navHostController, + startDestination = tabs.first { it::class == defaultTab }.route, + Modifier.padding(innerPadding), + enterTransition = { fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(200)) } + ) { + tabs.forEach { tab -> + tab.build(this) + } + } + } + + + @Composable + fun BottomBar() { + NavigationBar { + val navBackStackEntry by navHostController.currentBackStackEntryAsState() + + remember { tabs.filter { it.isPrimary } }.forEach { tab -> + val tabSubRoutes = remember { tab.nestedTabs.map { it.route } } + NavigationBarItem( + selected = navBackStackEntry?.destination?.hierarchy?.any { it.route == tab.route || tabSubRoutes.contains(it.route) } == true, + alwaysShowLabel = false, + modifier = Modifier.fillMaxHeight(), + icon = { + Icon(imageVector = tab.icon!!, contentDescription = null) + }, + label = { + Text( + textAlign = TextAlign.Center, + softWrap = false, + fontSize = 12.sp, + modifier = Modifier.wrapContentWidth(unbounded = true), + text = tab.route + ) + }, + onClick = { + navHostController.navigate(tab.route) { + popUpTo(navHostController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Tab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Tab.kt @@ -0,0 +1,49 @@ +package me.rhunk.snapenhance.manager.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import me.rhunk.snapenhance.manager.data.SharedConfig +import kotlin.reflect.KClass + +open class Tab( + val route: String, + val isPrimary: Boolean = false, + val icon: ImageVector? = null, +) { + lateinit var activity: ComponentActivity + val nestedTabs = mutableListOf<Tab>() + + fun getNestedTab(tab: KClass<out Tab>) = nestedTabs.firstOrNull { it::class == tab } + + fun registerNestedTab(tab: KClass<out Tab>) = nestedTabs.add((tab.java.constructors.first().newInstance() as Tab).also { + it.init(activity) + }) + + lateinit var navigation: Navigation + lateinit var sharedConfig: SharedConfig + + fun getArguments() = navigation.navHostController.previousBackStackEntry?.savedStateHandle?.get<Bundle>("args") + + open fun init(activity: ComponentActivity) { + this.activity = activity + } + + open fun build(navGraphBuilder: NavGraphBuilder) { + navGraphBuilder.composable(route) { + Content() + } + } + + @Composable + open fun TopBar() {} + + @Composable + open fun FloatingActionButtons() {} + + @Composable + open fun Content() {} +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/HomeTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/HomeTab.kt @@ -0,0 +1,139 @@ +package me.rhunk.snapenhance.manager.ui.tab + +import android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE +import android.content.pm.PackageInfo +import androidx.activity.ComponentActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.BuildConfig +import me.rhunk.snapenhance.manager.lspatch.config.Constants +import me.rhunk.snapenhance.manager.ui.Tab +import me.rhunk.snapenhance.manager.ui.tab.download.SEDownloadTab + +class HomeTab : Tab("home", true, icon = Icons.Default.Home) { + override fun init(activity: ComponentActivity) { + super.init(activity) + registerNestedTab(SEDownloadTab::class) + } + + @Composable + override fun Content() { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var snapchatAppInfo by remember { mutableStateOf(null as PackageInfo?) } + var snapEnhanceInfo by remember { mutableStateOf(null as PackageInfo?) } + + Column { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Column { + Text(text = "Snapchat", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) + snapchatAppInfo?.let { + Text(text = "${it.versionName} (${it.longVersionCode})", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + snapchatAppInfo?.let { appInfo -> + val isLSPatched = appInfo.applicationInfo.appComponentFactory == Constants.PROXY_APP_COMPONENT_FACTORY + if (isLSPatched) { + Text(text = "Patched", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } + OutlinedButton(onClick = { + + }) { + Text(text = if (isLSPatched) "Repatch" else "Patch") + } + } ?: run { + Text(text = "Not installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } + } + } + + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + navigation.navigateTo(SEDownloadTab::class) + } + .padding(16.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Column { + Text(text = "SnapEnhance", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) + snapEnhanceInfo?.let { + Text(text = "${it.versionName} (${it.longVersionCode}) - ${if ((it.applicationInfo.flags and FLAG_DEBUGGABLE) != 0) "Debug" else "Release"}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + snapEnhanceInfo?.let { + Text(text = "Installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } ?: run { + Text(text = "Not installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } + + Icon(imageVector = Icons.AutoMirrored.Default.OpenInNew, contentDescription = null, Modifier.padding(10.dp)) + } + } + } + } + + SideEffect { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + snapchatAppInfo = runCatching { + context.packageManager.getPackageInfo(sharedConfig.snapchatPackageName ?: "com.snapchat.android", 0) + }.getOrNull() + snapEnhanceInfo = runCatching { + context.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName ?: BuildConfig.APPLICATION_ID, 0) + }.getOrNull() + } + } + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/SettingsTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/SettingsTab.kt @@ -0,0 +1,117 @@ +package me.rhunk.snapenhance.manager.ui.tab + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import me.rhunk.snapenhance.manager.ui.Tab + +class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Settings) { + @Composable + private fun ConfigEditRow(getValue: () -> String?, setValue: (String) -> Unit, label: String) { + var showDialog by remember { mutableStateOf(false) } + + if (showDialog) { + val focusRequester = remember { FocusRequester() } + + Dialog(onDismissRequest = { + showDialog = false + }) { + Card { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text(text = label) + var textFieldValue by remember { mutableStateOf((getValue() ?: "").let { + TextFieldValue(it, TextRange(it.length)) + }) } + + TextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + }, + modifier = Modifier + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + } + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround + ) { + Button(onClick = { + showDialog = false + }) { + Text(text = "Cancel") + } + Button(onClick = { + setValue(textFieldValue.text) + showDialog = false + }) { + Text(text = "Save") + } + } + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showDialog = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(text = label, fontSize = 16.sp) + Text(text = getValue() ?: "(Not specified)", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon(imageVector = Icons.Default.Edit, contentDescription = null, modifier = Modifier.padding(16.dp)) + } + } + + @Composable + override fun Content() { + Column { + Spacer(modifier = Modifier.height(16.dp)) + ConfigEditRow( + getValue = { sharedConfig.snapchatPackageName }, + setValue = { sharedConfig.snapchatPackageName = it }, + label = "Snapchat package name" + ) + ConfigEditRow( + getValue = { sharedConfig.snapEnhancePackageName }, + setValue = { sharedConfig.snapEnhancePackageName = it }, + label = "SnapEnhance package name" + ) + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallAppTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallAppTab.kt @@ -0,0 +1,176 @@ +package me.rhunk.snapenhance.manager.ui.tab.download + +import android.content.Intent +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.data.download.InstallStage +import me.rhunk.snapenhance.manager.data.download.SEArtifact +import me.rhunk.snapenhance.manager.ui.Tab +import me.rhunk.snapenhance.manager.ui.tab.HomeTab +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + + +class InstallAppTab : Tab("install_app") { + private lateinit var installPackageIntentLauncher: ActivityResultLauncher<Intent> + private lateinit var uninstallPackageIntentLauncher: ActivityResultLauncher<Intent> + private var uninstallPackageCallback: ((resultCode: Int, data: Intent?) -> Unit)? = null + private var installPackageCallback: ((resultCode: Int, data: Intent?) -> Unit)? = null + + override fun init(activity: ComponentActivity) { + super.init(activity) + installPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + installPackageCallback?.invoke(it.resultCode, it.data) + } + uninstallPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + uninstallPackageCallback?.invoke(it.resultCode, it.data) + } + } + + + private fun downloadArtifact(artifact: SEArtifact, progress: (Int) -> Unit): File? { + val endpoint = Request.Builder().url(artifact.downloadUrl).build() + val response = OkHttpClient().newCall(endpoint).execute() + if (!response.isSuccessful) throw Throwable("Failed to download artifact: ${response.code}") + + return response.body.byteStream().use { input -> + val file = activity.externalCacheDirs.first().resolve(artifact.fileName) + runCatching { + file.outputStream().use { output -> + val buffer = ByteArray(4 * 1024) + var read: Int + var totalRead = 0L + val totalSize = response.body.contentLength() + while (input.read(buffer).also { read = it } != -1) { + output.write(buffer, 0, read) + totalRead += read + progress((totalRead * 100 / totalSize).toInt()) + } + } + file + }.getOrNull() + } + } + + + @Composable + @Suppress("DEPRECATION") + override fun Content() { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + var installStage by remember { mutableStateOf(InstallStage.DOWNLOADING) } + var downloadProgress by remember { mutableIntStateOf(0) } + var downloadedFile by remember { mutableStateOf<File?>(null) } + + LaunchedEffect(Unit) { + uninstallPackageCallback = null + installPackageCallback = null + } + + val artifact = getArguments()?.getParcelable<SEArtifact>("artifact") ?: return + val appPackage = getArguments()?.getString("appPackage") ?: return + val shouldUninstall = getArguments()?.getBoolean("uninstall") ?: false + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + if (installStage != InstallStage.DONE && installStage != InstallStage.ERROR) { + CircularProgressIndicator() + } + } + + when (installStage) { + InstallStage.DOWNLOADING -> { + Text(text = "Downloading ${artifact.fileName}... ($downloadProgress%)") + } + InstallStage.UNINSTALLING -> { + Text(text = "Uninstalling app $appPackage...") + } + InstallStage.INSTALLING -> { + Text(text = "Installing ${artifact.fileName}...") + } + InstallStage.DONE -> { + LaunchedEffect(Unit) { + navigation.navigateTo(HomeTab::class, noHistory = true) + Toast.makeText(context, "${artifact.fileName} installed successfully!", Toast.LENGTH_SHORT).show() + } + } + InstallStage.ERROR -> Text(text = "Failed to install ${artifact.fileName}. Check logcat for more details.") + } + } + + fun installPackage() { + installStage = InstallStage.INSTALLING + installPackageCallback = resultCallbacks@{ code, _ -> + installStage = if (code != ComponentActivity.RESULT_OK) { + InstallStage.ERROR + } else { + InstallStage.DONE + } + downloadedFile?.delete() + } + + installPackageIntentLauncher.launch(Intent(Intent.ACTION_INSTALL_PACKAGE).apply { + println(context) + data = FileProvider.getUriForFile(context, "me.rhunk.snapenhance.manager.provider", downloadedFile!!) + setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_RETURN_RESULT, true) + }) + } + + + LaunchedEffect(Unit) { + coroutineScope.launch(Dispatchers.IO) { + downloadedFile = downloadArtifact(artifact) { + downloadProgress = it + } ?: run { + installStage = InstallStage.ERROR + return@launch + } + if (shouldUninstall) { + installStage = InstallStage.UNINSTALLING + val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE).apply { + data = "package:$appPackage".toUri() + putExtra(Intent.EXTRA_RETURN_RESULT, true) + } + uninstallPackageCallback = resultCallback@{ resultCode, _ -> + if (resultCode != ComponentActivity.RESULT_OK) { + installStage = InstallStage.ERROR + downloadedFile?.delete() + return@resultCallback + } + installPackage() + } + uninstallPackageIntentLauncher.launch(intent) + } else { + installPackage() + } + } + } + } +} diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SEDownloadTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SEDownloadTab.kt @@ -0,0 +1,277 @@ +package me.rhunk.snapenhance.manager.ui.tab.download + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.BuildConfig +import me.rhunk.snapenhance.manager.data.download.SEArtifact +import me.rhunk.snapenhance.manager.data.download.SEVersion +import me.rhunk.snapenhance.manager.ui.Tab +import okhttp3.OkHttpClient +import okhttp3.Request +import java.text.SimpleDateFormat +import java.util.Locale + + +class SEDownloadTab : Tab("se_downloads") { + private fun fetchSEReleases(): List<SEVersion>? { + return runCatching { + val endpoint = + Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases") + .build() + val response = OkHttpClient().newCall(endpoint).execute() + if (!response.isSuccessful) return null + + val releases = JsonParser.parseString(response.body.string()).asJsonArray.also { + if (it.size() == 0) return null + } + val isoDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()) + + releases.map { releaseObject -> + val release = releaseObject.asJsonObject + val versionName = release.getAsJsonPrimitive("tag_name").asString + val releaseDate = release.getAsJsonPrimitive("published_at").asString.let { time -> + isoDateFormat.parse(time)?.let { date -> + SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(date) + } ?: time + } + val downloadAssets = release.getAsJsonArray("assets").associate { asset -> + val assetObject = asset.asJsonObject + SEArtifact( + fileName = assetObject.getAsJsonPrimitive("name").asString, + size = assetObject.getAsJsonPrimitive("size").asLong, + downloadUrl = assetObject.getAsJsonPrimitive("browser_download_url").asString + ).let { it.fileName to it } + } + SEVersion(versionName, releaseDate, downloadAssets) + } + }.onFailure { + it.printStackTrace() + }.getOrNull() + } + + override fun init(activity: ComponentActivity) { + super.init(activity) + registerNestedTab(InstallAppTab::class) + } + + @Composable + private fun DowngradeNoticeDialog(onDismiss: () -> Unit, onSuccess: () -> Unit) { + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(text = "Downgrade Notice", fontSize = 24.sp) + Text(text = "You are about to update the app. If you're installing an older version over a newer one, make sure you have CorePatch installed. Otherwise, you will need to uninstall and install.", fontSize = 12.sp) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + Button(onClick = onDismiss) { + Text(text = "Cancel") + } + Button(onClick = onSuccess) { + Text(text = "Continue") + } + } + } + } + + @Composable + override fun Content() { + val coroutineScope = rememberCoroutineScope() + val snapEnhanceReleases = remember { + mutableStateOf(null as List<SEVersion>?) + } + + var selectedVersion by remember { mutableStateOf(null as SEVersion?) } + var selectedArtifact by remember { mutableStateOf(null as SEArtifact?) } + val appPackageName = remember { sharedConfig.snapEnhancePackageName ?: BuildConfig.APPLICATION_ID } + val isAppInstalled = remember { runCatching { activity.packageManager.getPackageInfo(appPackageName, 0) != null }.getOrNull() != null } + + var showDowngradeNotice by remember { mutableStateOf(false) } + + fun triggerPackageInstallation(shouldUninstall: Boolean) { + navigation.navigateTo(InstallAppTab::class, Bundle().apply { + putParcelable("artifact", selectedArtifact) + putString( + "appPackage", + sharedConfig.snapEnhancePackageName ?: BuildConfig.APPLICATION_ID + ) + putBoolean("uninstall", shouldUninstall) + }) + } + + if (showDowngradeNotice) { + Dialog(onDismissRequest = { showDowngradeNotice = false }) { + DowngradeNoticeDialog(onDismiss = { showDowngradeNotice = false }, onSuccess = { + triggerPackageInstallation(false) + }) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "SnapEnhance Builds") + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.8f), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + if (snapEnhanceReleases.value == null) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + CircularProgressIndicator() + } + } + } + items(snapEnhanceReleases.value ?: listOf()) { version -> + OutlinedCard( + shape = MaterialTheme.shapes.small, + modifier = Modifier + .clickable { + selectedArtifact = + if (selectedVersion != version) null else selectedArtifact + selectedVersion = if (selectedVersion == version) null else version + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(text = version.versionName, fontSize = 24.sp) + Text(text = "Release ${version.releaseDate}", fontSize = 12.sp) + } + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "${version.downloadAssets.size} assets", fontSize = 12.sp) + } + } + } + + selectedVersion?.takeIf { it == version }?.let { selVersion -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + selVersion.downloadAssets.values.forEach { artifact -> + Row( + modifier = Modifier + .fillMaxWidth() + .border( + shape = MaterialTheme.shapes.medium, + width = 1.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + .clickable { + selectedArtifact = + if (selectedArtifact == artifact) null else artifact + } + .background( + if (selectedArtifact == artifact) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = Icons.Default.Android, contentDescription = null, modifier = Modifier.padding(start = 2.dp, end = 2.dp)) + Column( + modifier = Modifier + .padding(start = 13.dp) + ) { + Text(text = artifact.fileName, fontSize = 15.sp) + Text( + text = "${artifact.size / 1024 / 1024} MB", + fontSize = 10.sp + ) + } + } + } + } + } + } + } + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isAppInstalled) { + Button( + onClick = { + triggerPackageInstallation(true) + }, + enabled = selectedVersion != null && selectedArtifact != null, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Uninstall & Install") + } + } + Button( + onClick = { + if (isAppInstalled) { + showDowngradeNotice = true + } else { + triggerPackageInstallation(false) + } + }, + enabled = selectedVersion != null && selectedArtifact != null, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = if (isAppInstalled) "Update" else "Install") + } + } + } + + LaunchedEffect(Unit) { + coroutineScope.launch(Dispatchers.IO) { + snapEnhanceReleases.value = fetchSEReleases() + } + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/res/xml/provider_paths.xml b/manager/src/main/res/xml/provider_paths.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<paths> + <external-path name="external_files" path="."/> +</paths>