From b65109447553a317ff8acb1f67dd053d1da77519 Mon Sep 17 00:00:00 2001
From: rhunk <101876869+rhunk@users.noreply.github.com>
Date: Mon, 23 Oct 2023 18:50:36 +0200
Subject: [PATCH] feat(manager): SE build downloader
---
manager/build.gradle.kts | 2 +
manager/src/main/AndroidManifest.xml | 13 +
.../snapenhance/manager/data/SharedConfig.kt | 15 +
.../manager/data/download/InstallStage.kt | 9 +
.../manager/data/download/SEArtifact.kt | 11 +
.../manager/data/download/SEVersion.kt | 7 +
.../snapenhance/manager/ui/MainActivity.kt | 122 +++-----
.../snapenhance/manager/ui/Navigation.kt | 120 ++++++++
.../me/rhunk/snapenhance/manager/ui/Tab.kt | 49 ++++
.../snapenhance/manager/ui/tab/HomeTab.kt | 139 +++++++++
.../snapenhance/manager/ui/tab/SettingsTab.kt | 117 ++++++++
.../manager/ui/tab/download/InstallAppTab.kt | 176 +++++++++++
.../manager/ui/tab/download/SEDownloadTab.kt | 277 ++++++++++++++++++
manager/src/main/res/xml/provider_paths.xml | 4 +
14 files changed, 980 insertions(+), 81 deletions(-)
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/InstallStage.kt
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEArtifact.kt
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEVersion.kt
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Navigation.kt
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Tab.kt
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/HomeTab.kt
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/SettingsTab.kt
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallAppTab.kt
create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SEDownloadTab.kt
create mode 100644 manager/src/main/res/xml/provider_paths.xml
diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts
index 9ceba5b0..42c6af7b 100644
--- 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
index 465a69fc..9bc11bc6 100644
--- a/manager/src/main/AndroidManifest.xml
+++ b/manager/src/main/AndroidManifest.xml
@@ -5,10 +5,13 @@
+
@@ -16,5 +19,15 @@
+
+
+
+
\ 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
new file mode 100644
index 00000000..08557352
--- /dev/null
+++ 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
new file mode 100644
index 00000000..e93d8e3b
--- /dev/null
+++ 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
new file mode 100644
index 00000000..9193328a
--- /dev/null
+++ 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
new file mode 100644
index 00000000..8fe6a338
--- /dev/null
+++ 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,
+)
\ 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
index 57fb877a..90ce2d7e 100644
--- 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) {
+ 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() }
- fun printLog(data: Any) {
- when (data) {
- is Throwable -> {
- logs += data.message.toString()
- logs += StringWriter().apply {
- data.printStackTrace(PrintWriter(this))
- }.toString()
+ val navHostController = rememberNavController()
+ val sharedConfig = remember { SharedConfig(this) }
+
+
+ val navigation = remember {
+ Navigation(
+ navHostController = navHostController,
+ tabs = tabs,
+ defaultTab = HomeTab::class
+ ).also {
+ tabs.forEach { tab ->
+ tab.navigation = it
+ tab.sharedConfig = sharedConfig
}
- else -> logs += data.toString()
}
}
- val scrollState = rememberLazyListState(0)
-
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(20.dp),
- modifier = Modifier.padding(10.dp)
+ Scaffold(
+ bottomBar = { navigation.BottomBar() },
+ topBar = { navigation.TopBar() },
+ floatingActionButton = { navigation.FloatingActionButtons() },
+ floatingActionButtonPosition = FabPosition.End,
) {
- 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) }
- }
- }) {
- 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))
- }
+ 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
new file mode 100644
index 00000000..762a9aae
--- /dev/null
+++ 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,
+ private val defaultTab: KClass
+) {
+
+ @Composable
+ fun TopBar() {
+
+ }
+
+ @Composable
+ fun FloatingActionButtons() {
+
+ }
+
+ fun navigateTo(tab: KClass) {
+ navHostController.navigate(tabs.first { it::class == tab }.route)
+ }
+
+ fun navigateTo(tab: KClass, 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, 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
new file mode 100644
index 00000000..bdea5288
--- /dev/null
+++ 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()
+
+ fun getNestedTab(tab: KClass) = nestedTabs.firstOrNull { it::class == tab }
+
+ fun registerNestedTab(tab: KClass) = 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("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
new file mode 100644
index 00000000..3537b09f
--- /dev/null
+++ 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
new file mode 100644
index 00000000..f41d29de
--- /dev/null
+++ 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
new file mode 100644
index 00000000..07827971
--- /dev/null
+++ 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
+ private lateinit var uninstallPackageIntentLauncher: ActivityResultLauncher
+ 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(null) }
+
+ LaunchedEffect(Unit) {
+ uninstallPackageCallback = null
+ installPackageCallback = null
+ }
+
+ val artifact = getArguments()?.getParcelable("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
new file mode 100644
index 00000000..3fb0803f
--- /dev/null
+++ 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? {
+ 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?)
+ }
+
+ 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
new file mode 100644
index 00000000..8d13fa17
--- /dev/null
+++ b/manager/src/main/res/xml/provider_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+