commit 407e69d184b7e1759d00ea9277455374657d4838 parent 2047f32440e1f384f4a7efc61b88757747111d9e Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 30 Oct 2023 19:57:33 +0100 feat(manager): root installer Diffstat:
21 files changed, 1379 insertions(+), 1192 deletions(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.1.2" apksig = "8.0.2" +libsu = "5.2.1" guava = "32.1.3-jre" jsoup = "1.16.2" kotlin = "1.9.0" @@ -40,6 +41,7 @@ apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" } bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov-jdk18on" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } coil-video = { module = "io.coil-kt:coil-video", version.ref = "coil-compose" } +libsu = { module = "com.github.topjohnwu.libsu:core", version.ref = "libsu" } coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } dexlib2 = { group = "org.smali", name = "dexlib2", version.ref = "dexlib2" } ffmpeg-kit = { group = "com.arthenica", name = "ffmpeg-kit-full-gpl", version.ref = "ffmpeg-kit" } diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts @@ -72,6 +72,7 @@ configurations { dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + implementation(libs.libsu) implementation(libs.guava) implementation(libs.apksig) implementation(libs.gson) diff --git a/manager/proguard-rules.pro b/manager/proguard-rules.pro @@ -1,3 +1,3 @@ -dontwarn com.google.errorprone.annotations.** -dontwarn com.google.auto.value.** --keep enum me.rhunk.snapenhance.manager.** { *; }- \ No newline at end of file +-keep class me.rhunk.snapenhance.manager.ui.tab.** { *; }+ \ 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 @@ -18,4 +18,7 @@ class SharedConfig( var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", "me.rhunk.snapenhance")?.takeIf { it.isNotEmpty() } ?: "me.rhunk.snapenhance" set(value) = sharedPreferences.edit().putString("snapEnhancePackageName", value).apply() + + var useRootInstaller get() = sharedPreferences.getBoolean("useRootInstaller", false) + set(value) = sharedPreferences.edit().putBoolean("useRootInstaller", value).apply() } \ 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 @@ -9,10 +9,13 @@ import androidx.compose.material3.* import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.navigation.compose.rememberNavController +import com.topjohnwu.superuser.Shell +import me.rhunk.snapenhance.manager.BuildConfig import me.rhunk.snapenhance.manager.data.SharedConfig -import me.rhunk.snapenhance.manager.ui.tab.HomeTab -import me.rhunk.snapenhance.manager.ui.tab.SettingsTab -import me.rhunk.snapenhance.manager.ui.tab.download.InstallPackageTab +import me.rhunk.snapenhance.manager.ui.tab.Tab +import me.rhunk.snapenhance.manager.ui.tab.impl.HomeTab +import me.rhunk.snapenhance.manager.ui.tab.impl.SettingsTab +import me.rhunk.snapenhance.manager.ui.tab.impl.download.InstallPackageTab class MainActivity : ComponentActivity() { companion object{ @@ -22,6 +25,11 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Shell.enableVerboseLogging = BuildConfig.DEBUG; + Shell.setDefaultBuilder(Shell.Builder.create() + .setFlags(Shell.FLAG_REDIRECT_STDERR) + .setTimeout(10) + ); val tabs = primaryTabs.mapNotNull { runCatching { it.java.constructors.first().newInstance() as Tab }.getOrNull() }.toMutableList().apply { 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 @@ -20,6 +20,7 @@ import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState +import me.rhunk.snapenhance.manager.ui.tab.Tab import kotlin.reflect.KClass 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 @@ -1,56 +0,0 @@ -package me.rhunk.snapenhance.manager.ui - -import android.os.Bundle -import android.widget.Toast -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.currentBackStackEntry?.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() {} - - fun toast(message: String) { - activity.runOnUiThread { - Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() - } - } -}- \ 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 @@ -1,141 +0,0 @@ -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.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.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.lspatch.config.Constants -import me.rhunk.snapenhance.manager.ui.Tab -import me.rhunk.snapenhance.manager.ui.tab.download.SEDownloadTab -import me.rhunk.snapenhance.manager.ui.tab.download.SnapchatPatchTab - -class HomeTab : Tab("home", true, icon = Icons.Default.Home) { - override fun init(activity: ComponentActivity) { - super.init(activity) - registerNestedTab(SEDownloadTab::class) - registerNestedTab(SnapchatPatchTab::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() - .clickable { - navigation.navigateTo(SEDownloadTab::class) - } - .padding(16.dp), - verticalAlignment = 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 = 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)) - } - } - } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - navigation.navigateTo(SnapchatPatchTab::class) - } - .padding(16.dp), - verticalAlignment = 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.spacedBy(5.dp, Alignment.End), - verticalAlignment = 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) - } - } ?: 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, 0) - }.getOrNull() - snapEnhanceInfo = runCatching { - context.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName, 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 @@ -1,117 +0,0 @@ -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/Tab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/Tab.kt @@ -0,0 +1,57 @@ +package me.rhunk.snapenhance.manager.ui.tab + +import android.os.Bundle +import android.widget.Toast +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 me.rhunk.snapenhance.manager.ui.Navigation +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.currentBackStackEntry?.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() {} + + fun toast(message: String) { + activity.runOnUiThread { + Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallPackageTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallPackageTab.kt @@ -1,190 +0,0 @@ -package me.rhunk.snapenhance.manager.ui.tab.download - -import android.content.Intent -import android.net.Uri -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.* -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.StrokeCap -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.ui.Tab -import me.rhunk.snapenhance.manager.ui.tab.HomeTab -import okhttp3.OkHttpClient -import okhttp3.Request -import java.io.File - - -class InstallPackageTab : 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(url: String, progress: (Float) -> Unit): File? { - val urlScheme = Uri.parse(url).scheme - if (urlScheme != "https" && urlScheme != "http") { - val file = File(url) - val dest = File(activity.externalCacheDirs.first(), file.name).also { - it.deleteOnExit() - } - if (dest.exists()) return file - file.copyTo(dest) - return dest - } - - val endpoint = Request.Builder().url(url).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 = File.createTempFile("artifact", ".apk", activity.externalCacheDirs.first()).also { - it.deleteOnExit() - } - 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.toFloat() / totalSize.toFloat()) - } - } - 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 { mutableFloatStateOf(-1f) } - var downloadedFile by remember { mutableStateOf<File?>(null) } - - LaunchedEffect(Unit) { - uninstallPackageCallback = null - installPackageCallback = null - } - - val downloadPath = getArguments()?.getString("downloadPath") ?: return - val appPackage = getArguments()?.getString("appPackage") ?: return - val shouldUninstall = getArguments()?.getBoolean("uninstall") ?: false - - Column( - modifier = Modifier.fillMaxSize().padding(16.dp), - 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 ...") - LinearProgressIndicator(progress = downloadProgress, Modifier.fillMaxWidth().height(4.dp), strokeCap = StrokeCap.Round) - } - InstallStage.UNINSTALLING -> { - Text(text = "Uninstalling app $appPackage...") - } - InstallStage.INSTALLING -> { - Text(text = "Installing ...") - } - InstallStage.DONE -> { - LaunchedEffect(Unit) { - navigation.navigateTo(HomeTab::class, noHistory = true) - Toast.makeText(context, "Successfully installed $appPackage!", Toast.LENGTH_SHORT).show() - } - } - InstallStage.ERROR -> Text(text = "Failed to install $appPackage. 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 { - 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) { - runCatching { - downloadedFile = downloadArtifact(downloadPath) { 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() - } - }.onFailure { - it.printStackTrace() - installStage = InstallStage.ERROR - downloadedFile?.delete() - } - } - } - } -} diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/LSPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/LSPatchTab.kt @@ -1,197 +0,0 @@ -package me.rhunk.snapenhance.manager.ui.tab.download - -import android.os.Bundle -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.manager.data.APKMirror -import me.rhunk.snapenhance.manager.data.DownloadItem -import me.rhunk.snapenhance.manager.lspatch.LSPatch -import me.rhunk.snapenhance.manager.ui.Tab -import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog -import okio.use -import java.io.File - -class LSPatchTab : Tab("lspatch") { - private lateinit var downloadItem: DownloadItem - private var snapEnhanceModule: File? = null - private var patchedApk by mutableStateOf<File?>(null) - private val apkMirror = APKMirror() - - private fun patch(log: (Any?) -> Unit, onProgress: (Float) -> Unit) { - log("Fetching download link for ${downloadItem.title}...") - val downloadLink = apkMirror.fetchDownloadLink(downloadItem.downloadPage) ?: run { - log("== Failed to fetch download link ==") - return - } - - log("Downloading apk...") - - val downloadResponse = apkMirror.okhttpClient.newCall( - okhttp3.Request.Builder() - .url(downloadLink) - .build() - ).execute() - - if (!downloadResponse.isSuccessful) { - log("== Failed to download apk ==") - log("Response code: ${downloadResponse.code}") - return - } - - val apkFile = sharedConfig.apkCache.resolve("${downloadItem.hash}.apk") - apkFile.outputStream().use { outputStream -> - runCatching { - downloadResponse.body.byteStream().use { inputStream -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var read: Int - var totalRead = 0L - val totalSize = downloadResponse.body.contentLength() - while (inputStream.read(buffer).also { read = it } != -1) { - outputStream.write(buffer, 0, read) - totalRead += read - onProgress(totalRead.toFloat() / totalSize.toFloat()) - } - } - }.onFailure { - log("== Failed to download apk ==") - log(it) - return - } - } - - apkFile.renameTo(File(activity.externalCacheDir!!, "base.apk")) - - log("== Downloaded apk ==") - snapEnhanceModule?.let { module -> - val lsPatch = LSPatch(activity, mapOf( - sharedConfig.snapEnhancePackageName to module, - ), printLog = { - log("[LSPatch] $it") - }) - - log("== Patching apk ==") - val outputFiles = lsPatch.patchSplits(listOf(apkFile)) - - patchedApk = outputFiles["base.apk"] ?: run { - log("== Failed to patch apk ==") - return - } - return@let - } - patchedApk = apkFile - } - - @Composable - @Suppress("DEPRECATION") - override fun Content() { - this.downloadItem = remember { getArguments()?.getParcelable<DownloadItem>("downloadItem") } ?: return - this.snapEnhanceModule = remember { - getArguments()?.getString("modulePath")?.let { - File(it) - } - } - - val coroutineScope = rememberCoroutineScope() - var showDowngradeNoticeDialog by remember { mutableStateOf(false) } - - var status by remember { mutableStateOf("") } - var progress by remember { mutableFloatStateOf(-1f) } - - LaunchedEffect(this.downloadItem.hash) { - patchedApk = null - coroutineScope.launch(Dispatchers.IO) { - runCatching { - patch(log = { - coroutineScope.launch { - status += when (it) { - is Throwable -> it.message + "\n" + it.stackTraceToString() - else -> it.toString() - } + "\n" - } - }) { - progress = it - } - }.onFailure { - coroutineScope.launch { - status += it.message + "\n" + it.stackTraceToString() - } - } - } - } - - val scrollState = rememberScrollState() - - fun triggerInstallation(shouldUninstall: Boolean) { - navigation.navigateTo(InstallPackageTab::class, args = Bundle().apply { - putString("downloadPath", patchedApk?.absolutePath) - putString("appPackage", sharedConfig.snapchatPackageName) - putBoolean("uninstall", shouldUninstall) - }) - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Card( - modifier = Modifier - .weight(1f) - .padding(10.dp), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - ) { - Text(text = status, overflow = TextOverflow.Visible, modifier = Modifier.padding(10.dp)) - } - } - if (progress != -1f) { - LinearProgressIndicator(progress = progress, modifier = Modifier.height(10.dp), strokeCap = StrokeCap.Round) - } - - if (patchedApk != null) { - Button(modifier = Modifier.fillMaxWidth(), onClick = { - triggerInstallation(true) - }) { - Text(text = "Uninstall & Install") - } - - Button(modifier = Modifier.fillMaxWidth(), onClick = { - showDowngradeNoticeDialog = true - }) { - Text(text = "Update") - } - } - - LaunchedEffect(status) { - scrollState.scrollTo(scrollState.maxValue) - } - } - - if (showDowngradeNoticeDialog) { - Dialog(onDismissRequest = { showDowngradeNoticeDialog = false }) { - DowngradeNoticeDialog(onDismiss = { showDowngradeNoticeDialog = false }, onSuccess = { - triggerInstallation(false) - }) - } - } - } -}- \ No newline at end of file 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 @@ -1,245 +0,0 @@ -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.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.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.data.download.SEArtifact -import me.rhunk.snapenhance.manager.data.download.SEVersion -import me.rhunk.snapenhance.manager.ui.Tab -import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog -import okhttp3.OkHttpClient -import okhttp3.Request -import java.text.SimpleDateFormat -import java.util.Locale - - -class SEDownloadTab : Tab("se_download") { - 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) - } - - @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 isAppInstalled = remember { runCatching { activity.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName, 0) != null }.getOrNull() != null } - - var showDowngradeNotice by remember { mutableStateOf(false) } - - fun triggerPackageInstallation(shouldUninstall: Boolean) { - navigation.navigateTo(InstallPackageTab::class, Bundle().apply { - putString("downloadPath", selectedArtifact?.downloadUrl) - putString("appPackage", sharedConfig.snapEnhancePackageName) - putBoolean("uninstall", shouldUninstall) - }, noHistory = true) - } - - 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 = "Choose SnapEnhance version") - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - 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/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SnapchatPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SnapchatPatchTab.kt @@ -1,235 +0,0 @@ -package me.rhunk.snapenhance.manager.ui.tab.download - -import android.os.Bundle -import androidx.activity.ComponentActivity -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.Check -import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.manager.R -import me.rhunk.snapenhance.manager.data.APKMirror -import me.rhunk.snapenhance.manager.data.DownloadItem -import me.rhunk.snapenhance.manager.ui.Tab -import me.rhunk.snapenhance.manager.ui.components.ConfirmationDialog - -@OptIn(ExperimentalMaterial3Api::class) -class SnapchatPatchTab : Tab("snapchat_download") { - private val apkMirror = APKMirror() - private val cachedDownloadItems = mutableListOf<DownloadItem>() - private var currentPage by mutableIntStateOf(1) - - override fun init(activity: ComponentActivity) { - super.init(activity) - registerNestedTab(LSPatchTab::class) - } - - @Composable - override fun TopBar() { - var deleteAllDialog by remember { mutableStateOf(false) } - IconButton(onClick = { deleteAllDialog = true }) { - Icon(imageVector = Icons.Default.DeleteForever, contentDescription = null) - } - - if (deleteAllDialog) { - AlertDialog(onDismissRequest = { deleteAllDialog = false }) { - ConfirmationDialog(title = "Are you sure you want to delete all downloads?", onDismiss = { deleteAllDialog = false }) { - deleteAllDialog = false - runCatching { - sharedConfig.apkCache.listFiles()?.forEach { it.deleteRecursively() } - }.onFailure { - toast("Failed to delete downloads") - it.printStackTrace() - } - toast("Done!") - } - } - } - } - - @Composable - private fun DownloadItemRow(item: DownloadItem, onSelected: () -> Unit = {}) { - ElevatedCard( - modifier = Modifier.padding(10.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(5.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.padding(10.dp), - ) { - Icon(painter = painterResource(R.drawable.sclogo), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(40.dp)) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - Text(item.shortTitle) - if (!item.isBeta) { - Text("Recommended", color = MaterialTheme.colorScheme.tertiary) - } - } - Row( - modifier = Modifier.padding(5.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - FilledIconButton(onClick = { onSelected() }) { - Icon(imageVector = Icons.Default.Check, contentDescription = null) - } - } - } - } - } - - - @Composable - private fun SelectSnapchatVersionDialog(onSelected: (DownloadItem) -> Unit = {}) { - val coroutineScope = rememberCoroutineScope() - var isFetching by remember { mutableStateOf(false) } - val downloadItems = remember { cachedDownloadItems.toMutableStateList() } - - LazyColumn { - items(downloadItems, key = { it.hash }) { item -> - DownloadItemRow(item) { - onSelected(item) - } - } - item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.alpha(if (isFetching) 1f else 0f)) - } - SideEffect { - if (isFetching) return@SideEffect - isFetching = true - coroutineScope.launch { - runCatching { - withContext(Dispatchers.IO) { - apkMirror.fetchSnapchatVersions(currentPage)?.let { - withContext(Dispatchers.Main) { - cachedDownloadItems.addAll(it) - downloadItems.addAll(it) - } - } - } - }.onFailure { - it.printStackTrace() - } - ++currentPage - isFetching = false - } - } - } - } - } - - @Composable - override fun Content() { - var showSelectSnapchatVersionDialog by remember { mutableStateOf(false) } - var selectedSnapchatVersion by remember { mutableStateOf(null as DownloadItem?) } - val installedSnapEnhanceVersion = remember { runCatching { activity.packageManager.getPackageInfo( - sharedConfig.snapEnhancePackageName, 0) }.getOrNull() } - - if (showSelectSnapchatVersionDialog) { - AlertDialog(onDismissRequest = { showSelectSnapchatVersionDialog = false }) { - SelectSnapchatVersionDialog { - selectedSnapchatVersion = it - showSelectSnapchatVersionDialog = false - } - } - } - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text("Select a version to download and patch") - - ElevatedCard( - modifier = Modifier.padding(10.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Icon(painter = painterResource(R.drawable.sclogo), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(40.dp)) - if (selectedSnapchatVersion == null) { - Text(text = "Snapchat") - } - Text(text = selectedSnapchatVersion?.shortTitle ?: "Not Selected") - Button(onClick = { showSelectSnapchatVersionDialog = true }) { - Text("Choose") - } - } - } - - ElevatedCard( - modifier = Modifier.padding(10.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = "SnapEnhance") - Text(text = installedSnapEnhanceVersion?.versionName ?: "Not installed") - } - } - - Column( - modifier = Modifier.padding(top = 10.dp, bottom = 10.dp, start = 20.dp, end = 20.dp), - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - Button( - modifier = Modifier.fillMaxWidth(), - enabled = selectedSnapchatVersion != null && installedSnapEnhanceVersion != null, - onClick = { - navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { - putParcelable("downloadItem", selectedSnapchatVersion) - putString("modulePath", installedSnapEnhanceVersion?.applicationInfo?.sourceDir) - }, noHistory = true) - } - ) { - Text("Download & Patch") - } - - Button( - modifier = Modifier.fillMaxWidth(), - enabled = selectedSnapchatVersion != null, - onClick = { - navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { - putParcelable("downloadItem", selectedSnapchatVersion) - }, noHistory = true) - } - ) { - Text("Install/Restore Original Snapchat") - } - } - } - } -}- \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt @@ -0,0 +1,146 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl + +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.Check +import androidx.compose.material.icons.filled.Close +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.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.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.lspatch.config.Constants +import me.rhunk.snapenhance.manager.ui.tab.Tab +import me.rhunk.snapenhance.manager.ui.tab.impl.download.SEDownloadTab +import me.rhunk.snapenhance.manager.ui.tab.impl.download.SnapchatPatchTab + +class HomeTab : Tab("home", true, icon = Icons.Default.Home) { + override fun init(activity: ComponentActivity) { + super.init(activity) + registerNestedTab(SEDownloadTab::class) + registerNestedTab(SnapchatPatchTab::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() + .clickable { + navigation.navigateTo(SEDownloadTab::class) + } + .padding(16.dp), + verticalAlignment = 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 = 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)) + } + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + navigation.navigateTo(SnapchatPatchTab::class) + } + .padding(16.dp), + verticalAlignment = 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.spacedBy(10.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + snapchatAppInfo?.let { appInfo -> + val isLSPatched = appInfo.applicationInfo.appComponentFactory == Constants.PROXY_APP_COMPONENT_FACTORY + if (isLSPatched) { + Icon(imageVector = Icons.Default.Check, contentDescription = null) + Text(text = "Patched", fontSize = 16.sp) + } else { + Icon(imageVector = Icons.Default.Close, contentDescription = null) + Text(text = "Not patched", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } + } ?: run { + Text(text = "Not installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } + + } + } + + } + } + + SideEffect { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + snapchatAppInfo = runCatching { + context.packageManager.getPackageInfo(sharedConfig.snapchatPackageName, 0) + }.getOrNull() + snapEnhanceInfo = runCatching { + context.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName, 0) + }.getOrNull() + } + } + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt @@ -0,0 +1,150 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl + +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.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 + private fun ConfigBooleanRow(getValue: () -> Boolean, setValue: (Boolean) -> Unit, label: String) { + var value by remember { mutableStateOf(getValue()) } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + value = !value + setValue(value) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(text = label, fontSize = 16.sp) + } + Checkbox(checked = value, onCheckedChange = { + value = it + setValue(it) + }, modifier = Modifier.padding(5.dp)) + } + } + + @Composable + override fun Content() { + Column { + Spacer(modifier = Modifier.height(16.dp)) + ConfigEditRow( + getValue = { sharedConfig.snapchatPackageName }, + setValue = { sharedConfig.snapchatPackageName = it }, + label = "Override Snapchat package name" + ) + ConfigEditRow( + getValue = { sharedConfig.snapEnhancePackageName }, + setValue = { sharedConfig.snapEnhancePackageName = it }, + label = "Override SnapEnhance package name" + ) + ConfigBooleanRow( + getValue = { sharedConfig.useRootInstaller }, + setValue = { sharedConfig.useRootInstaller = it }, + label = "Use root installer" + ) + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt @@ -0,0 +1,224 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.data.download.InstallStage +import me.rhunk.snapenhance.manager.ui.tab.Tab +import me.rhunk.snapenhance.manager.ui.tab.impl.HomeTab +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + + +class InstallPackageTab : Tab("install_app") { + private lateinit var installPackageIntentLauncher: ActivityResultLauncher<Intent> + private lateinit var uninstallPackageIntentLauncher: ActivityResultLauncher<Intent> + private var uninstallPackageCallback: ((resultCode: Int) -> Unit)? = null + private var installPackageCallback: ((resultCode: Int) -> Unit)? = null + + private val hasRoot get() = sharedConfig.useRootInstaller + + override fun init(activity: ComponentActivity) { + super.init(activity) + installPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + installPackageCallback?.invoke(it.resultCode) + } + uninstallPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + uninstallPackageCallback?.invoke(it.resultCode) + } + } + + private fun downloadArtifact(url: String, progress: (Float) -> Unit): File? { + val urlScheme = Uri.parse(url).scheme + if (urlScheme != "https" && urlScheme != "http") { + val file = File(url) + val dest = File(activity.externalCacheDirs.first(), file.name).also { + it.deleteOnExit() + } + if (dest.exists()) return file + file.copyTo(dest) + return dest + } + + val endpoint = Request.Builder().url(url).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 = File.createTempFile("artifact", ".apk", activity.externalCacheDirs.first()).also { + it.deleteOnExit() + } + 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.toFloat() / totalSize.toFloat()) + } + } + 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 { mutableFloatStateOf(-1f) } + var downloadedFile by remember { mutableStateOf<File?>(null) } + + LaunchedEffect(Unit) { + uninstallPackageCallback = null + installPackageCallback = null + } + + val downloadPath = getArguments()?.getString("downloadPath") ?: return + val appPackage = getArguments()?.getString("appPackage") ?: return + val shouldUninstall = getArguments()?.getBoolean("uninstall") ?: false + + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + 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 ...") + LinearProgressIndicator(progress = downloadProgress, Modifier.fillMaxWidth().height(4.dp), strokeCap = StrokeCap.Round) + } + InstallStage.UNINSTALLING -> { + Text(text = "Uninstalling app $appPackage...") + } + InstallStage.INSTALLING -> { + Text(text = "Installing ...") + } + InstallStage.DONE -> { + LaunchedEffect(Unit) { + navigation.navigateTo(HomeTab::class, noHistory = true) + Toast.makeText(context, "Successfully installed $appPackage!", Toast.LENGTH_SHORT).show() + } + } + InstallStage.ERROR -> Text(text = "Failed to install $appPackage. Check logcat for more details.") + } + } + + fun uninstallPackageRoot(): Boolean { + val result = Shell.su("pm uninstall $appPackage").exec() + if (result.isSuccess) { + return true + } + toast("Root uninstall failed: ${result.out}") + return false + } + + fun installPackageRoot(): Boolean { + val result = Shell.su( + "cp \"${downloadedFile!!.absolutePath}\" /data/local/tmp/", + "pm install -r \"/data/local/tmp/${downloadedFile!!.name}\"", + "rm /data/local/tmp/${downloadedFile!!.name}" + ).exec() + if (result.isSuccess) { + installStage = InstallStage.DONE + return true + } + toast("Root install failed: ${result.out}") + return false + } + + fun installPackage() { + installStage = InstallStage.INSTALLING + if (hasRoot && installPackageRoot()) { + downloadedFile?.delete() + return + } + installPackageCallback = resultCallbacks@{ code -> + installStage = if (code != ComponentActivity.RESULT_OK) { + InstallStage.ERROR + } else { + InstallStage.DONE + } + downloadedFile?.delete() + } + + installPackageIntentLauncher.launch(Intent(Intent.ACTION_INSTALL_PACKAGE).apply { + 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) { + runCatching { + downloadedFile = downloadArtifact(downloadPath) { downloadProgress = it } ?: run { + installStage = InstallStage.ERROR + return@launch + } + if (shouldUninstall) { + installStage = InstallStage.UNINSTALLING + if (hasRoot && uninstallPackageRoot()) { + installPackage() + return@launch + } + 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() + } + }.onFailure { + it.printStackTrace() + installStage = InstallStage.ERROR + downloadedFile?.delete() + } + } + } + } +} diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt @@ -0,0 +1,202 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.os.Bundle +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.data.APKMirror +import me.rhunk.snapenhance.manager.data.DownloadItem +import me.rhunk.snapenhance.manager.lspatch.LSPatch +import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog +import me.rhunk.snapenhance.manager.ui.tab.Tab +import okio.use +import java.io.File + +class LSPatchTab : Tab("lspatch") { + private var localItemFile: File? = null + private var downloadItem: DownloadItem? = null + private var snapEnhanceModule: File? = null + private var patchedApk by mutableStateOf<File?>(null) + private val apkMirror = APKMirror() + + private fun patch(log: (Any?) -> Unit, onProgress: (Float) -> Unit) { + var apkFile: File? = localItemFile + + downloadItem?.let { + log("Fetching download link for ${it.title}...") + val downloadLink = apkMirror.fetchDownloadLink(it.downloadPage) ?: run { + log("== Failed to fetch download link ==") + return + } + log("Downloading apk...") + + val downloadResponse = apkMirror.okhttpClient.newCall( + okhttp3.Request.Builder() + .url(downloadLink) + .build() + ).execute() + + if (!downloadResponse.isSuccessful) { + log("== Failed to download apk ==") + log("Response code: ${downloadResponse.code}") + return + } + + apkFile = sharedConfig.apkCache.resolve("${it.hash}.apk") + apkFile!!.outputStream().use { outputStream -> + runCatching { + downloadResponse.body.byteStream().use { inputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read: Int + var totalRead = 0L + val totalSize = downloadResponse.body.contentLength() + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + totalRead += read + onProgress(totalRead.toFloat() / totalSize.toFloat()) + } + } + }.onFailure { throwable -> + log("== Failed to download apk ==") + log(throwable) + return + } + } + + apkFile!!.renameTo(File(activity.externalCacheDir!!, "base.apk")) + } + + log("== Downloaded apk ==") + snapEnhanceModule?.let { module -> + val lsPatch = LSPatch(activity, mapOf( + sharedConfig.snapEnhancePackageName to module, + ), printLog = { + log("[LSPatch] $it") + }) + + log("== Patching apk ==") + val outputFiles = lsPatch.patchSplits(listOf(apkFile!!)) + + patchedApk = outputFiles["base.apk"] ?: run { + log("== Failed to patch apk ==") + return + } + return + } + patchedApk = apkFile + } + + @Composable + @Suppress("DEPRECATION") + override fun Content() { + this.localItemFile = remember { getArguments()?.getString("localItemFile")?.let { File(it) } } + this.downloadItem = remember { getArguments()?.getParcelable("downloadItem") } + this.snapEnhanceModule = remember { + getArguments()?.getString("modulePath")?.let { + File(it) + } + } + + val coroutineScope = rememberCoroutineScope() + var showDowngradeNoticeDialog by remember { mutableStateOf(false) } + + var status by remember { mutableStateOf("") } + var progress by remember { mutableFloatStateOf(-1f) } + + LaunchedEffect(this.snapEnhanceModule) { + patchedApk = null + coroutineScope.launch(Dispatchers.IO) { + runCatching { + patch(log = { + coroutineScope.launch { + status += when (it) { + is Throwable -> it.message + "\n" + it.stackTraceToString() + else -> it.toString() + } + "\n" + } + }) { + progress = it + } + }.onFailure { + coroutineScope.launch { + status += it.message + "\n" + it.stackTraceToString() + } + } + } + } + + val scrollState = rememberScrollState() + + fun triggerInstallation(shouldUninstall: Boolean) { + navigation.navigateTo(InstallPackageTab::class, args = Bundle().apply { + putString("downloadPath", patchedApk?.absolutePath) + putString("appPackage", sharedConfig.snapchatPackageName) + putBoolean("uninstall", shouldUninstall) + }) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Card( + modifier = Modifier + .weight(1f) + .padding(10.dp), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + Text(text = status, overflow = TextOverflow.Visible, modifier = Modifier.padding(10.dp)) + } + } + if (progress != -1f) { + LinearProgressIndicator(progress = progress, modifier = Modifier.height(10.dp), strokeCap = StrokeCap.Round) + } + + if (patchedApk != null) { + Button(modifier = Modifier.fillMaxWidth(), onClick = { + triggerInstallation(true) + }) { + Text(text = "Uninstall & Install") + } + + Button(modifier = Modifier.fillMaxWidth(), onClick = { + showDowngradeNoticeDialog = true + }) { + Text(text = "Update") + } + } + + LaunchedEffect(status) { + scrollState.scrollTo(scrollState.maxValue) + } + } + + if (showDowngradeNoticeDialog) { + Dialog(onDismissRequest = { showDowngradeNoticeDialog = false }) { + DowngradeNoticeDialog(onDismiss = { showDowngradeNoticeDialog = false }, onSuccess = { + triggerInstallation(false) + }) + } + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt @@ -0,0 +1,245 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.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.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.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.data.download.SEArtifact +import me.rhunk.snapenhance.manager.data.download.SEVersion +import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog +import me.rhunk.snapenhance.manager.ui.tab.Tab +import okhttp3.OkHttpClient +import okhttp3.Request +import java.text.SimpleDateFormat +import java.util.Locale + + +class SEDownloadTab : Tab("se_download") { + 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) + } + + @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 isAppInstalled = remember { runCatching { activity.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName, 0) != null }.getOrNull() != null } + + var showDowngradeNotice by remember { mutableStateOf(false) } + + fun triggerPackageInstallation(shouldUninstall: Boolean) { + navigation.navigateTo(InstallPackageTab::class, Bundle().apply { + putString("downloadPath", selectedArtifact?.downloadUrl) + putString("appPackage", sharedConfig.snapEnhancePackageName) + putBoolean("uninstall", shouldUninstall) + }, noHistory = true) + } + + 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 = "Choose SnapEnhance version") + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + 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/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt @@ -0,0 +1,328 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.manager.R +import me.rhunk.snapenhance.manager.data.APKMirror +import me.rhunk.snapenhance.manager.data.DownloadItem +import me.rhunk.snapenhance.manager.lspatch.config.Constants +import me.rhunk.snapenhance.manager.ui.components.ConfirmationDialog +import me.rhunk.snapenhance.manager.ui.tab.Tab +import java.io.File +import java.util.zip.ZipFile + +@OptIn(ExperimentalMaterial3Api::class) +class SnapchatPatchTab : Tab("snapchat_download") { + private val apkMirror = APKMirror() + private val cachedDownloadItems = mutableListOf<DownloadItem>() + private var currentPage by mutableIntStateOf(1) + + override fun init(activity: ComponentActivity) { + super.init(activity) + registerNestedTab(LSPatchTab::class) + } + + @Composable + override fun TopBar() { + var deleteAllDialog by remember { mutableStateOf(false) } + IconButton(onClick = { deleteAllDialog = true }) { + Icon(imageVector = Icons.Default.DeleteForever, contentDescription = null) + } + + if (deleteAllDialog) { + AlertDialog(onDismissRequest = { deleteAllDialog = false }) { + ConfirmationDialog(title = "Are you sure you want to delete all downloads?", onDismiss = { deleteAllDialog = false }) { + deleteAllDialog = false + runCatching { + sharedConfig.apkCache.listFiles()?.forEach { it.deleteRecursively() } + }.onFailure { + toast("Failed to delete downloads") + it.printStackTrace() + } + toast("Done!") + } + } + } + } + + @Composable + private fun DownloadItemRow(item: DownloadItem, onSelected: () -> Unit = {}) { + ElevatedCard( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.padding(10.dp), + ) { + Icon(painter = painterResource(R.drawable.sclogo), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(40.dp)) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text(item.shortTitle) + if (!item.isBeta) { + Text("Recommended", color = MaterialTheme.colorScheme.tertiary) + } + } + Row( + modifier = Modifier.padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + FilledIconButton(onClick = { onSelected() }) { + Icon(imageVector = Icons.Default.Check, contentDescription = null) + } + } + } + } + } + + + @Composable + private fun SelectSnapchatVersionDialog(onSelected: (DownloadItem) -> Unit = {}) { + val coroutineScope = rememberCoroutineScope() + var isFetching by remember { mutableStateOf(false) } + val downloadItems = remember { cachedDownloadItems.toMutableStateList() } + + LazyColumn { + items(downloadItems, key = { it.hash }) { item -> + DownloadItemRow(item) { + onSelected(item) + } + } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.alpha(if (isFetching) 1f else 0f)) + } + SideEffect { + if (isFetching) return@SideEffect + isFetching = true + coroutineScope.launch { + runCatching { + withContext(Dispatchers.IO) { + apkMirror.fetchSnapchatVersions(currentPage)?.let { + withContext(Dispatchers.Main) { + cachedDownloadItems.addAll(it) + downloadItems.addAll(it) + } + } + } + }.onFailure { + it.printStackTrace() + } + ++currentPage + isFetching = false + } + } + } + } + } + + @Composable + override fun Content() { + var showSelectSnapchatVersionDialog by remember { mutableStateOf(false) } + var showRestoreMenuDialog by remember { mutableStateOf(false) } + + var selectedSnapchatVersion by remember { mutableStateOf(null as DownloadItem?) } + val installedSnapEnhanceVersion = remember { runCatching { activity.packageManager.getPackageInfo( + sharedConfig.snapEnhancePackageName, 0) }.getOrNull() } + + val installedSnapchatPackage = remember { runCatching { activity.packageManager.getPackageInfo( + sharedConfig.snapchatPackageName, 0) }.getOrNull() } + val isInstalledSnapchatPatched = remember { installedSnapchatPackage?.applicationInfo?.appComponentFactory == Constants.PROXY_APP_COMPONENT_FACTORY } + val isSnapchatNotSplitConfig = remember { + installedSnapchatPackage?.applicationInfo?.let { it.splitSourceDirs == null || it.splitSourceDirs?.isEmpty() == true } ?: false + } + + if (showRestoreMenuDialog) { + fun triggerSnapchatInstallation(shouldUninstall: Boolean) { + val apkFile = File(installedSnapchatPackage?.applicationInfo?.sourceDir ?: return).also { + if (!it.exists()) return + } + toast("Extracting origin apk") + val originApk = File.createTempFile("origin", ".apk", activity.externalCacheDirs.first()).also { + it.deleteOnExit() + } + ZipFile(apkFile).let { zipFile -> + zipFile.getEntry("assets/lspatch/origin.apk")?.apply { + originApk.outputStream().use { output -> + zipFile.getInputStream(this).copyTo(output) + } + } ?: run { + toast("Failed to extract origin apk") + return + } + } + + showRestoreMenuDialog = false + + navigation.navigateTo(InstallPackageTab::class, args = Bundle().apply { + putString("downloadPath", originApk.absolutePath) + putString("appPackage", sharedConfig.snapchatPackageName) + putBoolean("uninstall", shouldUninstall) + }, noHistory = true) + } + + AlertDialog(onDismissRequest = { showRestoreMenuDialog = false }) { + Card { + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Button(onClick = { + triggerSnapchatInstallation(true) + }) { + Text("Uninstall & Install") + } + Button(onClick = { + triggerSnapchatInstallation(false) + }) { + Text("Update") + } + } + } + } + } + + + if (showSelectSnapchatVersionDialog) { + AlertDialog(onDismissRequest = { showSelectSnapchatVersionDialog = false }) { + SelectSnapchatVersionDialog { + selectedSnapchatVersion = it + showSelectSnapchatVersionDialog = false + } + } + } + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text("Patch Snapchat") + + ElevatedCard( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painter = painterResource(R.drawable.sclogo), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(40.dp)) + if (selectedSnapchatVersion == null) { + Text(text = "Snapchat") + } + Text(text = selectedSnapchatVersion?.shortTitle ?: "Not Selected") + Button(onClick = { showSelectSnapchatVersionDialog = true }) { + Text("Choose") + } + } + } + + ElevatedCard( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "SnapEnhance") + Text(text = installedSnapEnhanceVersion?.versionName ?: "Not installed") + } + } + + Column( + modifier = Modifier.padding(top = 10.dp, bottom = 10.dp, start = 20.dp, end = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = selectedSnapchatVersion != null && installedSnapEnhanceVersion != null, + onClick = { + navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { + putParcelable("downloadItem", selectedSnapchatVersion) + putString("modulePath", installedSnapEnhanceVersion?.applicationInfo?.sourceDir) + }, noHistory = true) + } + ) { + Text("Download & Patch") + } + + if (isSnapchatNotSplitConfig) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { + putString("localItemFile", installedSnapchatPackage?.applicationInfo?.sourceDir ?: return@apply) + putString("modulePath", installedSnapEnhanceVersion?.applicationInfo?.sourceDir ?: return@apply) + }, noHistory = true) + } + ) { + Text("Patch from existing installation") + } + } + + if (isInstalledSnapchatPatched) { + Text("Restore Snapchat", modifier = Modifier.padding(20.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + enabled = selectedSnapchatVersion != null, + onClick = { + navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { + putParcelable("downloadItem", selectedSnapchatVersion) + }, noHistory = true) + } + ) { + Text("Install/Restore Original Snapchat") + } + + if (isSnapchatNotSplitConfig) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { showRestoreMenuDialog = true } + ) { + Text("Restore Snapchat from existing installation") + } + } + } + } + } + } +}+ \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts @@ -12,6 +12,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } }