mirror of
https://github.com/BillyOutlast/GrindrPlus.git
synced 2026-07-01 15:43:22 -04:00
GrindrPlus: Implement the manager
Change-Id: I67fbd295277a70a705e36146c3d17a7f07ab607f Co-authored-by: Giovanni Mazzone <giovannimariamazzone@gmail.com> Co-authored-by: R0rt1z2 <me@r0rt1z2.com> Change-Id: Ia72b5ff2aee1485f3d6b16398d2ac58aa71f372a Took 25 seconds
This commit is contained in:
+77
-3
@@ -1,17 +1,19 @@
|
||||
import java.io.ByteArrayOutputStream
|
||||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
import java.net.URL
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||
alias(libs.plugins.googleKsp)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
android {
|
||||
val grindrVersions = listOf("25.3.0")
|
||||
|
||||
namespace = "com.grindrplus"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
val gitCommitHash = getGitCommitHash() ?: "unknown"
|
||||
@@ -32,6 +34,10 @@ android {
|
||||
"TARGET_GRINDR_VERSIONS",
|
||||
grindrVersions.joinToString(prefix = "{", separator = ", ", postfix = "}") { "\"$it\"" }
|
||||
)
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -67,7 +73,8 @@ android {
|
||||
applicationVariants.configureEach {
|
||||
outputs.configureEach {
|
||||
val sanitizedVersionName = versionName.replace(Regex("[^a-zA-Z0-9._-]"), "_").trim('_')
|
||||
(this as BaseVariantOutputImpl).outputFileName = "GPlus_v${sanitizedVersionName}-${name}.apk"
|
||||
(this as BaseVariantOutputImpl).outputFileName =
|
||||
"GPlus_v${sanitizedVersionName}-${name}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,14 +88,81 @@ dependencies {
|
||||
implementation(libs.square.okhttp)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.runtime.android)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
compileOnly(fileTree("libs") { include("*.jar") })
|
||||
implementation(fileTree("libs") { include("lspatch.jar") })
|
||||
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
|
||||
implementation(composeBom)
|
||||
|
||||
// Material Design 3
|
||||
implementation("androidx.compose.material3:material3")
|
||||
|
||||
// Android Studio Preview support
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
implementation("androidx.compose.material:material-icons-core")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.activity:activity-compose:1.10.0")
|
||||
compileOnly("org.bouncycastle:bcprov-jdk18on:1.80")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.9")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.1.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.1.0")
|
||||
implementation("com.github.jeziellago:compose-markdown:0.5.7")
|
||||
implementation("com.github.Rattlyy:plausible-android-sdk:54ea1a1359")
|
||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||
implementation("com.github.tonyofrancis.Fetch:fetch2:3.4.1")
|
||||
implementation("com.github.tonyofrancis.Fetch:fetch2okhttp:3.4.1")
|
||||
}
|
||||
|
||||
tasks.register("setupLSPatch") {
|
||||
doLast {
|
||||
val jarUrl =
|
||||
Regex("https:\\/\\/nightly\\.link\\/JingMatrix\\/LSPatch\\/workflows\\/main\\/master\\/lspatch-debug-[^.]+\\.zip").find(
|
||||
URL("https://nightly.link/JingMatrix/LSPatch/workflows/main/master?preview").readText()
|
||||
)!!.value
|
||||
|
||||
exec {
|
||||
commandLine = listOf("mkdir", "-p", "/tmp/lspatch")
|
||||
}
|
||||
|
||||
exec {
|
||||
commandLine = listOf("wget", jarUrl, "-O", "/tmp/lspatch/lspatch.zip")
|
||||
}
|
||||
|
||||
exec {
|
||||
commandLine = listOf("unzip", "-o", "/tmp/lspatch/lspatch.zip", "-d", "/tmp/lspatch")
|
||||
}
|
||||
|
||||
val jarPath =
|
||||
File("/tmp/lspatch").listFiles()?.find { it.name.contains("jar-") }?.absolutePath
|
||||
|
||||
exec {
|
||||
commandLine = listOf("unzip", "-o", jarPath, "assets/lspatch/so*", "-d", "src/main/")
|
||||
}
|
||||
|
||||
exec {
|
||||
commandLine = listOf("mv", jarPath, "./libs/lspatch.jar")
|
||||
}
|
||||
|
||||
exec {
|
||||
commandLine = listOf("zip", "-d", "./libs/lspatch.jar", "com/google/common/util/concurrent/ListenableFuture.class")
|
||||
}
|
||||
|
||||
exec {
|
||||
commandLine = listOf("zip", "-d", "./libs/lspatch.jar", "com/google/errorprone/annotations/*")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getGitCommitHash(): String? {
|
||||
return try {
|
||||
if (exec { commandLine = "git rev-parse --is-inside-work-tree".split(" ") }.exitValue == 0) {
|
||||
if (exec {
|
||||
commandLine = "git rev-parse --is-inside-work-tree".split(" ")
|
||||
}.exitValue == 0) {
|
||||
val output = ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine = "git rev-parse --short HEAD".split(" ")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,39 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true">
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<meta-data
|
||||
android:name="xposedmodule"
|
||||
android:value="true" />
|
||||
android:name="xposedmodule"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="xposeddescription"
|
||||
android:value="Xposed Module to enhance Grindr" />
|
||||
android:name="xposeddescription"
|
||||
android:value="Xposed Module to enhance Grindr" />
|
||||
<meta-data
|
||||
android:name="xposedminversion"
|
||||
android:value="93" />
|
||||
android:name="xposedminversion"
|
||||
android:value="93" />
|
||||
<meta-data
|
||||
android:name="xposedscope"
|
||||
android:value="com.grindrapp.android" />
|
||||
android:name="xposedscope"
|
||||
android:value="com.grindrapp.android" />
|
||||
|
||||
<service
|
||||
android:name=".bridge.BridgeService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
android:name=".bridge.BridgeService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.GET_CONTENT" />
|
||||
<category android:name="android.intent.category.OPENABLE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".manager.MainActivity"
|
||||
android:theme="@style/Theme.Material3.DayNight.NoActionBar"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -3,4 +3,6 @@ package com.grindrplus.bridge;
|
||||
interface IBridgeService {
|
||||
String getTranslation(String locale);
|
||||
List<String> getAvailableTranslations();
|
||||
String getConfig();
|
||||
void setConfig(String config);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -27,6 +27,7 @@ import de.robv.android.xposed.XposedHelpers.getObjectField
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -34,6 +35,8 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@@ -52,7 +55,7 @@ object GrindrPlus {
|
||||
lateinit var database: Database
|
||||
private set
|
||||
lateinit var bridgeClient: BridgeClient
|
||||
private set
|
||||
internal set
|
||||
lateinit var coroutineHelper: CoroutineHelper
|
||||
private set
|
||||
lateinit var instanceManager: InstanceManager
|
||||
@@ -97,7 +100,8 @@ object GrindrPlus {
|
||||
|
||||
private val userAgent = "e6.x" // search for 'grindr3/'
|
||||
private val userSession = "sa.T" // search for 'com.grindrapp.android.storage.UserSessionImpl$1'
|
||||
private val deviceInfo = "V3.t" // search for 'AdvertisingIdClient.Info("00000000-0000-0000-0000-000000000000", true)'
|
||||
private val deviceInfo =
|
||||
"V3.t" // search for 'AdvertisingIdClient.Info("00000000-0000-0000-0000-000000000000", true)'
|
||||
private val profileRepo = "com.grindrapp.android.persistence.repository.ProfileRepo"
|
||||
private val ioScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
@@ -111,8 +115,13 @@ object GrindrPlus {
|
||||
)
|
||||
|
||||
this.context = application // do not use .applicationContext as it's null at this point
|
||||
|
||||
val newModule = File(context.filesDir, "grindrplus.dex")
|
||||
File(modulePath).copyTo(newModule, true)
|
||||
newModule.setReadOnly()
|
||||
|
||||
this.classLoader =
|
||||
DexClassLoader(modulePath, context.cacheDir.absolutePath, null, context.classLoader)
|
||||
DexClassLoader(newModule.absolutePath, null, null, context.classLoader)
|
||||
this.logger = logger
|
||||
this.newDatabase = NewDatabase.create(context)
|
||||
this.database = Database(context, context.filesDir.absolutePath + "/grindrplus.db")
|
||||
@@ -178,7 +187,10 @@ object GrindrPlus {
|
||||
|
||||
private fun init() {
|
||||
logger.log("Initializing GrindrPlus...")
|
||||
Config.initialize(context)
|
||||
|
||||
bridgeClient = BridgeClient(context).apply {
|
||||
connect {
|
||||
Config.initialize(context)
|
||||
|
||||
// bridgeClient = BridgeClient(context).apply {
|
||||
// connect {
|
||||
@@ -192,18 +204,20 @@ object GrindrPlus {
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Emergency reset of the database if the flag is set.
|
||||
*/
|
||||
if ((Config.get("reset_database", false) as Boolean)) {
|
||||
logger.log("Resetting database...")
|
||||
database.deleteDatabase()
|
||||
Config.put("reset_database", false)
|
||||
/**
|
||||
* Emergency reset of the database if the flag is set.
|
||||
*/
|
||||
if ((Config.get("reset_database", false) as Boolean)) {
|
||||
logger.log("Resetting database...")
|
||||
database.deleteDatabase()
|
||||
Config.put("reset_database", false)
|
||||
}
|
||||
|
||||
Config.put("xposed_version", XposedBridge.getXposedVersion())
|
||||
|
||||
hookManager.init()
|
||||
}
|
||||
}
|
||||
|
||||
Config.put("xposed_version", XposedBridge.getXposedVersion())
|
||||
|
||||
hookManager.init()
|
||||
}
|
||||
|
||||
fun runOnMainThread(appContext: Context? = null, block: (Context) -> Unit) {
|
||||
|
||||
@@ -11,18 +11,20 @@ import android.os.IBinder
|
||||
import com.grindrplus.BuildConfig
|
||||
import com.grindrplus.GrindrPlus
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class BridgeClient(private val context: Context) : ServiceConnection {
|
||||
private var isBound = false
|
||||
public var isBound = false
|
||||
private var bridgeService: IBridgeService? = null
|
||||
private var onConnectedCallback: (() -> Unit)? = null
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
this.bridgeService = IBridgeService.Stub.asInterface(binder)
|
||||
isBound = true
|
||||
GrindrPlus.logger.log("Successfully connected to the bridge service!")
|
||||
println("Successfully connected to the bridge service!")
|
||||
this.onConnectedCallback?.invoke()
|
||||
}
|
||||
|
||||
@@ -32,7 +34,8 @@ class BridgeClient(private val context: Context) : ServiceConnection {
|
||||
GrindrPlus.logger.log("Disconnected from the bridge service!")
|
||||
}
|
||||
|
||||
fun connect(onConnected: () -> Unit) {
|
||||
fun connect(onConnectedCallback: (() -> Unit)? = null) {
|
||||
println("connecting...")
|
||||
runCatching {
|
||||
val intent = Intent().setClassName(
|
||||
BuildConfig.APPLICATION_ID,
|
||||
@@ -58,9 +61,10 @@ class BridgeClient(private val context: Context) : ServiceConnection {
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
GrindrPlus.logger.log("Failed to bind to the bridge service: ${it.message}")
|
||||
println("Failed to bind to the bridge service: ${it.message}")
|
||||
throw it
|
||||
}.onSuccess {
|
||||
onConnectedCallback = onConnected
|
||||
this.onConnectedCallback = onConnectedCallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +72,7 @@ class BridgeClient(private val context: Context) : ServiceConnection {
|
||||
if (isBound) {
|
||||
context.unbindService(this)
|
||||
isBound = false
|
||||
GrindrPlus.logger.log("Unbound from the bridge service!")
|
||||
println("Unbound from the bridge service!")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +83,7 @@ class BridgeClient(private val context: Context) : ServiceConnection {
|
||||
*/
|
||||
fun getTranslation(locale: String): JSONObject? {
|
||||
if (!isBound) {
|
||||
GrindrPlus.logger.log("Cannot get translation, service is not bound!")
|
||||
println("Cannot get translation, service is not bound!")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -94,10 +98,32 @@ class BridgeClient(private val context: Context) : ServiceConnection {
|
||||
*/
|
||||
fun getAvailableTranslations(): List<String> {
|
||||
if (!isBound) {
|
||||
GrindrPlus.logger.log("Cannot get available translations, service is not bound!")
|
||||
println("Cannot get available translations, service is not bound!")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return bridgeService?.getAvailableTranslations() ?: emptyList()
|
||||
}
|
||||
|
||||
fun getConfig(): JSONObject? {
|
||||
if (!isBound) {
|
||||
println("Cannot get config, service is not bound!")
|
||||
return null
|
||||
}
|
||||
|
||||
return bridgeService?.getConfig()?.let {
|
||||
JSONObject(it)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun setConfig(config: JSONObject) {
|
||||
if (!isBound) {
|
||||
println("Cannot set config, service is not bound!")
|
||||
return
|
||||
}
|
||||
|
||||
bridgeService?.setConfig(config.toString(4))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.RemoteException
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
class BridgeService : Service() {
|
||||
@@ -39,5 +40,26 @@ class BridgeService : Service() {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConfig(): String {
|
||||
println("Called getConfig on ${getExternalFilesDir(null)}")
|
||||
val configFile = File(getExternalFilesDir(null), "grindrplus.json");
|
||||
return try {
|
||||
configFile.readText()
|
||||
} catch (e: Exception) {
|
||||
Log.e("BridgeService", "Error reading config file", e)
|
||||
"{}"
|
||||
}
|
||||
}
|
||||
|
||||
override fun setConfig(config: String?) {
|
||||
println("Called setConfig on ${getExternalFilesDir(null)}")
|
||||
val configFile = File(getExternalFilesDir(null), "grindrplus.json");
|
||||
try {
|
||||
configFile.writeText(config ?: "{}")
|
||||
} catch (e: Exception) {
|
||||
Log.e("BridgeService", "Error writing config file", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,111 +2,103 @@ package com.grindrplus.core
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.grindrplus.GrindrPlus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.newSingleThreadContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
|
||||
object Config {
|
||||
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
|
||||
private val scope = CoroutineScope(newSingleThreadContext("Config"))
|
||||
private lateinit var configFile: File
|
||||
private lateinit var config: JSONObject
|
||||
private var localConfig = JSONObject()
|
||||
|
||||
fun initialize(context: Context) {
|
||||
configFile = File(context.filesDir, "grindrplus.json")
|
||||
if (!configFile.exists()) {
|
||||
try {
|
||||
configFile.createNewFile()
|
||||
val initialConfig = JSONObject().put("hooks", JSONObject())
|
||||
writeConfig(initialConfig)
|
||||
} catch (e: IOException) {
|
||||
Log.e("GrindrPlus", "Failed to create config file", e)
|
||||
fun initialize(context: Context?) {
|
||||
println("Called initialize")
|
||||
if (context != null) {
|
||||
configFile = File(context.filesDir, "grindrplus.json")
|
||||
if (configFile.exists()) {
|
||||
File(
|
||||
context.filesDir,
|
||||
"pre-migration-config-backup-should-be-empty.json"
|
||||
).writeText(readRemoteConfig().toString())
|
||||
writeRemoteConfig(JSONObject(configFile.readText()))
|
||||
configFile.delete()
|
||||
}
|
||||
}
|
||||
config = readConfig(configFile)
|
||||
|
||||
localConfig = readRemoteConfig()
|
||||
}
|
||||
|
||||
private fun readConfig(file: File): JSONObject {
|
||||
private fun readRemoteConfig(): JSONObject {
|
||||
return try {
|
||||
JSONObject(file.readText())
|
||||
val value = GrindrPlus.bridgeClient.getConfig()
|
||||
println("Called readRemoteConfig, isNull: ${value == null}")
|
||||
value ?: JSONObject().put("hooks", JSONObject())
|
||||
} catch (e: Exception) {
|
||||
Log.e("GrindrPlus", "Error reading config file", e)
|
||||
JSONObject().put("hooks", JSONObject())
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeConfig(json: JSONObject) {
|
||||
private fun writeRemoteConfig(json: JSONObject) {
|
||||
try {
|
||||
FileOutputStream(configFile).use { fos ->
|
||||
fos.write(json.toString(4).toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
println("Called writeRemoteConfig")
|
||||
GrindrPlus.bridgeClient.setConfig(json)
|
||||
} catch (e: IOException) {
|
||||
Log.e("GrindrPlus", "Failed to write config file", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetConfig(shouldResetDb: Boolean = false) {
|
||||
config = JSONObject().put("hooks", JSONObject())
|
||||
if (shouldResetDb) {
|
||||
config.put("reset_database", true)
|
||||
}
|
||||
scope.launch { writeConfig(config) }
|
||||
}
|
||||
|
||||
fun importFromJson(jsonString: String) {
|
||||
try {
|
||||
val newConfig = JSONObject(jsonString)
|
||||
config = newConfig
|
||||
scope.launch { writeConfig(newConfig) }
|
||||
} catch (e: Exception) {
|
||||
Log.e("GrindrPlus", "Failed to import config", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun put(name: String, value: Any) {
|
||||
config.put(name, value)
|
||||
scope.launch { writeConfig(config) }
|
||||
println("called put on $name")
|
||||
localConfig.put(name, value)
|
||||
writeRemoteConfig(localConfig)
|
||||
}
|
||||
|
||||
fun get(name: String, default: Any): Any {
|
||||
return config.opt(name) ?: default.also { put(name, default) }
|
||||
}
|
||||
|
||||
fun getConfigJson(): String {
|
||||
return config.toString(4)
|
||||
val get = localConfig.opt(name)
|
||||
println("called get: $name val $get")
|
||||
return get ?: default.also { put(name, default) }
|
||||
}
|
||||
|
||||
fun setHookEnabled(hookName: String, enabled: Boolean) {
|
||||
val hooks = config.optJSONObject("hooks") ?: JSONObject().also { config.put("hooks", it) }
|
||||
val hooks =
|
||||
localConfig.optJSONObject("hooks") ?: JSONObject().also { localConfig.put("hooks", it) }
|
||||
hooks.optJSONObject(hookName)?.put("enabled", enabled)
|
||||
scope.launch { writeConfig(config) }
|
||||
writeRemoteConfig(localConfig)
|
||||
}
|
||||
|
||||
fun isHookEnabled(hookName: String): Boolean {
|
||||
val hooks = config.optJSONObject("hooks") ?: return false
|
||||
val hooks = localConfig.optJSONObject("hooks") ?: return false
|
||||
return hooks.optJSONObject(hookName)?.getBoolean("enabled") ?: false
|
||||
}
|
||||
|
||||
fun initHookSettings(name: String, description: String, state: Boolean) {
|
||||
if (config.optJSONObject("hooks")?.optJSONObject(name) == null) {
|
||||
suspend fun initHookSettings(name: String, description: String, state: Boolean) {
|
||||
if (localConfig.optJSONObject("hooks")?.optJSONObject(name) == null) {
|
||||
val hooks =
|
||||
config.optJSONObject("hooks") ?: JSONObject().also { config.put("hooks", it) }
|
||||
localConfig.optJSONObject("hooks") ?: JSONObject().also {
|
||||
localConfig.put(
|
||||
"hooks",
|
||||
it
|
||||
)
|
||||
}
|
||||
hooks.put(name, JSONObject().apply {
|
||||
put("description", description)
|
||||
put("enabled", state)
|
||||
})
|
||||
writeConfig(config)
|
||||
|
||||
writeRemoteConfig(localConfig)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHooksSettings(): Map<String, Pair<String, Boolean>> {
|
||||
val hooks = config.optJSONObject("hooks") ?: return emptyMap()
|
||||
val hooks = readRemoteConfig().optJSONObject("hooks") ?: return emptyMap()
|
||||
val map = mutableMapOf<String, Pair<String, Boolean>>()
|
||||
|
||||
val keys = hooks.keys()
|
||||
|
||||
@@ -48,7 +48,8 @@ class ModSettings : Hook(
|
||||
.java.getMethod("getValue").invoke(settingsViewBindingLazy)
|
||||
|
||||
var settingsRoot =
|
||||
param.thisObject()::class.java.getMethod("E").invoke(param.thisObject()) // good luck
|
||||
param.thisObject()::class.java.getMethod("E")
|
||||
.invoke(param.thisObject()) // good luck
|
||||
settingsRoot::class.java.declaredFields.reversed().forEach { field ->
|
||||
field.isAccessible = true
|
||||
val fieldValue = field.get(settingsRoot)
|
||||
@@ -94,7 +95,8 @@ class ModSettings : Hook(
|
||||
}
|
||||
|
||||
val modHeader = TextView(activity).apply {
|
||||
text = "GrindrPlus"
|
||||
text =
|
||||
"GrindrPlus v${BuildConfig.VERSION_NAME} for Grindr v${BuildConfig.TARGET_GRINDR_VERSIONS.joinToString()}"
|
||||
layoutParams = settingsExampleHeader.layoutParams
|
||||
setPadding(
|
||||
settingsExampleHeader.paddingLeft, settingsExampleHeader.paddingTop,
|
||||
@@ -109,53 +111,53 @@ class ModSettings : Hook(
|
||||
setTextColor(settingsExampleHeader.currentTextColor)
|
||||
}
|
||||
|
||||
val modSubContainer = LinearLayout(activity).apply {
|
||||
layoutParams = settingsExampleSubContainer.layoutParams
|
||||
orientation = settingsExampleSubContainer.orientation
|
||||
setPadding(
|
||||
settingsExampleSubContainer.paddingLeft,
|
||||
settingsExampleSubContainer.paddingTop,
|
||||
settingsExampleSubContainer.paddingRight,
|
||||
settingsExampleSubContainer.paddingBottom
|
||||
)
|
||||
id = View.generateViewId()
|
||||
textAlignment = settingsExampleSubContainer.textAlignment
|
||||
}
|
||||
|
||||
val modSubContainerTextView = TextView(activity).apply {
|
||||
text = "Mod Settings"
|
||||
layoutParams = settingsExampleSubContainerTextView.layoutParams
|
||||
setPadding(
|
||||
settingsExampleSubContainerTextView.paddingLeft,
|
||||
settingsExampleSubContainerTextView.paddingTop,
|
||||
settingsExampleSubContainerTextView.paddingRight,
|
||||
settingsExampleSubContainerTextView.paddingBottom
|
||||
)
|
||||
textSize = 16f
|
||||
setTypeface(
|
||||
settingsExampleSubContainerTextView.typeface,
|
||||
settingsExampleSubContainerTextView.typeface.style
|
||||
)
|
||||
setTextColor(settingsExampleSubContainerTextView.currentTextColor)
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
modSubContainer.setOnClickListener {
|
||||
val hooksFragmentInstance =
|
||||
findClass(settingsFragment).constructors.first().newInstance()
|
||||
val supportFragmentManager = getSupportFragmentManager.invoke(activity)
|
||||
val fragmentTransaction = beginTransaction.invoke(supportFragmentManager)
|
||||
addFragmentTransaction.invoke(
|
||||
fragmentTransaction,
|
||||
android.R.id.content,
|
||||
hooksFragmentInstance
|
||||
)
|
||||
commitFragmentTransaction.invoke(fragmentTransaction)
|
||||
}
|
||||
|
||||
modSubContainer.addView(modSubContainerTextView, 0)
|
||||
// val modSubContainer = LinearLayout(activity).apply {
|
||||
// layoutParams = settingsExampleSubContainer.layoutParams
|
||||
// orientation = settingsExampleSubContainer.orientation
|
||||
// setPadding(
|
||||
// settingsExampleSubContainer.paddingLeft,
|
||||
// settingsExampleSubContainer.paddingTop,
|
||||
// settingsExampleSubContainer.paddingRight,
|
||||
// settingsExampleSubContainer.paddingBottom
|
||||
// )
|
||||
// id = View.generateViewId()
|
||||
// textAlignment = settingsExampleSubContainer.textAlignment
|
||||
// }
|
||||
//
|
||||
// val modSubContainerTextView = TextView(activity).apply {
|
||||
// text = "Mod Settings"
|
||||
// layoutParams = settingsExampleSubContainerTextView.layoutParams
|
||||
// setPadding(
|
||||
// settingsExampleSubContainerTextView.paddingLeft,
|
||||
// settingsExampleSubContainerTextView.paddingTop,
|
||||
// settingsExampleSubContainerTextView.paddingRight,
|
||||
// settingsExampleSubContainerTextView.paddingBottom
|
||||
// )
|
||||
// textSize = 16f
|
||||
// setTypeface(
|
||||
// settingsExampleSubContainerTextView.typeface,
|
||||
// settingsExampleSubContainerTextView.typeface.style
|
||||
// )
|
||||
// setTextColor(settingsExampleSubContainerTextView.currentTextColor)
|
||||
// visibility = View.VISIBLE
|
||||
// }
|
||||
//
|
||||
// modSubContainer.setOnClickListener {
|
||||
// val hooksFragmentInstance =
|
||||
// findClass(settingsFragment).constructors.first().newInstance()
|
||||
// val supportFragmentManager = getSupportFragmentManager.invoke(activity)
|
||||
// val fragmentTransaction = beginTransaction.invoke(supportFragmentManager)
|
||||
// addFragmentTransaction.invoke(
|
||||
// fragmentTransaction,
|
||||
// android.R.id.content,
|
||||
// hooksFragmentInstance
|
||||
// )
|
||||
// commitFragmentTransaction.invoke(fragmentTransaction)
|
||||
// }
|
||||
//
|
||||
// modSubContainer.addView(modSubContainerTextView, 0)
|
||||
modContainer.addView(modHeader, 0)
|
||||
modContainer.addView(modSubContainer, 1)
|
||||
//modContainer.addView(modSubContainer, 1)
|
||||
settingsScrollingContentLayout.addView(modContainer, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.grindrplus.manager
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.grindrplus.manager.utils.DownloadResult
|
||||
import com.grindrplus.manager.utils.StorageUtils
|
||||
import com.grindrplus.manager.utils.SessionInstaller
|
||||
import com.grindrplus.manager.utils.download
|
||||
import com.grindrplus.manager.utils.newKeystore
|
||||
import com.grindrplus.manager.utils.unzip
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.lsposed.patch.LSPatch
|
||||
import org.lsposed.patch.util.Logger
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.zip.ZipFile
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class Installation(
|
||||
private val context: Context,
|
||||
modVer: String,
|
||||
private val modUrl: String,
|
||||
private val grindrUrl: String,
|
||||
) {
|
||||
private val keyStore by lazy {
|
||||
File(context.cacheDir, "keystore.jks").also {
|
||||
if (!it.exists()) {
|
||||
try {
|
||||
newKeystore(it)
|
||||
} catch (e: Exception) {
|
||||
showToast("Failed to create keystore: ${e.localizedMessage}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val folder = context.getExternalFilesDir(null)
|
||||
?: throw IOException("External files directory not available")
|
||||
private val unzipFolder = File(folder, "splitApks/").also { it.mkdirs() }
|
||||
private val outputDir = File(folder, "LSPatchOutput/").also { it.mkdirs() }
|
||||
private val modFile = File(folder, "mod-$modVer.zip")
|
||||
private val xapkFile = File(folder, "grindr-$modVer.xapk")
|
||||
|
||||
suspend fun install(print: (String) -> Unit, progress: (Float) -> Unit) = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
plausible?.pageView("app://grindrplus/install")
|
||||
|
||||
val time = measureTimeMillis {
|
||||
stepWithProgress("Checking storage space", print) {
|
||||
checkStorageSpace(print)
|
||||
}
|
||||
|
||||
stepWithProgress("Downloading Grindr APK", print) {
|
||||
downloadGrindrApk(print, progress)
|
||||
}
|
||||
|
||||
stepWithProgress("Downloading Mod", print) {
|
||||
downloadMod(print, progress)
|
||||
}
|
||||
|
||||
stepWithProgress("Patching Grindr APK", print) {
|
||||
patchGrindrApk(print, progress)
|
||||
}
|
||||
}
|
||||
|
||||
print("Patching completed successfully in ${time / 1000 / 60}m${time / 1000}s!")
|
||||
plausible?.event(
|
||||
"install_success",
|
||||
"app://grindrplus/install_success",
|
||||
props = mapOf("time" to time)
|
||||
)
|
||||
|
||||
stepWithProgress("Installing Grindr APK", print) {
|
||||
installGrindrApk(print)
|
||||
}
|
||||
|
||||
showToast("Installation completed successfully!")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
print("Installation was cancelled")
|
||||
showToast("Installation was cancelled")
|
||||
plausible?.event("install_cancelled", "app://grindrplus/install_cancelled")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val errorMsg = "Installation failed: ${e.localizedMessage}"
|
||||
plausible?.event(
|
||||
"install_failed",
|
||||
"app://grindrplus/install_failure",
|
||||
props = mapOf("error" to e.message)
|
||||
)
|
||||
print(errorMsg)
|
||||
showToast(errorMsg)
|
||||
cleanupOnFailure()
|
||||
throw e
|
||||
}
|
||||
|
||||
private fun checkStorageSpace(print: (String) -> Unit) {
|
||||
val requiredSpace = 200 * 1024 * 1024 // 200MB as a safe minimum
|
||||
val availableSpace = StorageUtils.getAvailableSpace(folder)
|
||||
|
||||
print("Available storage space: ${availableSpace / 1024 / 1024}MB")
|
||||
|
||||
if (availableSpace < requiredSpace) {
|
||||
throw IOException("Not enough storage space. Need ${requiredSpace / 1024 / 1024}MB, but only ${availableSpace / 1024 / 1024}MB available.")
|
||||
}
|
||||
|
||||
print("Storage space check passed")
|
||||
}
|
||||
|
||||
private suspend fun genericDownload(
|
||||
file: File,
|
||||
url: String,
|
||||
print: (String) -> Unit,
|
||||
progress: (Float) -> Unit,
|
||||
fileType: String,
|
||||
): Boolean {
|
||||
print("Downloading $fileType file...")
|
||||
|
||||
if (file.exists() && file.length() > 0) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
ZipFile(file).close()
|
||||
}
|
||||
|
||||
print("Existing $fileType file found, skipping download")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("Download").w(e, "Existing file ${file.name} is corrupt, redownloading")
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
val result = download(context, file, url, print)
|
||||
|
||||
if (!result.success || !file.exists() || file.length() <= 0) {
|
||||
throw IOException("Failed to download $fileType, reason ${result.reason}")
|
||||
}
|
||||
|
||||
val sizeMB = file.length() / 1024 / (if (fileType == "mod") 1 else 1024)
|
||||
print("$fileType download completed (${sizeMB}${if (fileType == "mod") "KB" else "MB"})")
|
||||
progress(0f)
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun downloadGrindrApk(print: (String) -> Unit, progress: (Float) -> Unit) {
|
||||
genericDownload(xapkFile, grindrUrl, print, progress, "Grindr apk")
|
||||
|
||||
try {
|
||||
print("Cleaning extraction directory...")
|
||||
unzipFolder.listFiles()?.forEach { it.delete() }
|
||||
|
||||
print("Extracting XAPK file...")
|
||||
xapkFile.unzip(unzipFolder)
|
||||
|
||||
val apkFiles =
|
||||
unzipFolder.listFiles()?.filter { it.name.endsWith(".apk") } ?: emptyList()
|
||||
if (apkFiles.isEmpty()) {
|
||||
throw IOException("No APK files found in the XAPK archive")
|
||||
}
|
||||
|
||||
print("Successfully extracted ${apkFiles.size} APK files")
|
||||
|
||||
apkFiles.forEachIndexed { index, file ->
|
||||
print(" ${index + 1}. ${file.name} (${file.length() / 1024}KB)")
|
||||
}
|
||||
|
||||
progress(0f)
|
||||
} catch (e: Exception) {
|
||||
throw IOException("Failed to extract XAPK file: ${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadMod(print: (String) -> Unit, progress: (Float) -> Unit) {
|
||||
genericDownload(modFile, modUrl, print, progress, "mod")
|
||||
}
|
||||
|
||||
private suspend fun patchGrindrApk(print: (String) -> Unit, progress: (Float) -> Unit) {
|
||||
try {
|
||||
print("Cleaning output directory...")
|
||||
outputDir.listFiles()?.forEach { it.delete() }
|
||||
|
||||
val apkFiles = unzipFolder.listFiles()
|
||||
?.filter { it.name.endsWith(".apk") && it.exists() && it.length() > 0 }
|
||||
?.map { it.absolutePath }
|
||||
?.toTypedArray()
|
||||
|
||||
if (apkFiles.isNullOrEmpty()) {
|
||||
throw IOException("No valid APK files found to patch")
|
||||
}
|
||||
|
||||
print("Starting LSPatch process with ${apkFiles.size} APK files")
|
||||
|
||||
val logger = object : Logger() {
|
||||
override fun d(message: String?) {
|
||||
message?.let { print("DEBUG: $it") }
|
||||
}
|
||||
|
||||
override fun i(message: String?) {
|
||||
message?.let { print("INFO: $it") }
|
||||
}
|
||||
|
||||
override fun e(message: String?) {
|
||||
message?.let { print("ERROR: $it") }
|
||||
}
|
||||
}
|
||||
|
||||
print("Using mod file: ${modFile.absolutePath}")
|
||||
print("Using keystore: ${keyStore.absolutePath}")
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
LSPatch(
|
||||
logger,
|
||||
*apkFiles,
|
||||
"-o", outputDir.absolutePath,
|
||||
"-l", "2",
|
||||
"-f",
|
||||
"-v",
|
||||
"-m", modFile.absolutePath,
|
||||
"-k", keyStore.absolutePath,
|
||||
"password",
|
||||
"alias",
|
||||
"password"
|
||||
).doCommandLine()
|
||||
}
|
||||
|
||||
val patchedFiles = outputDir.listFiles()
|
||||
if (patchedFiles.isNullOrEmpty()) {
|
||||
throw IOException("Patching failed - no output files generated")
|
||||
}
|
||||
|
||||
print("Patching completed successfully")
|
||||
print("Generated ${patchedFiles.size} patched files")
|
||||
|
||||
patchedFiles.forEachIndexed { index, file ->
|
||||
print(" ${index + 1}. ${file.name} (${file.length() / 1024}KB)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IOException("Failed to patch APK: ${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun installGrindrApk(print: (String) -> Unit) {
|
||||
val patchedFiles = outputDir.listFiles()?.toList() ?: emptyList()
|
||||
|
||||
if (patchedFiles.isEmpty()) {
|
||||
throw IOException("No patched APK files found for installation")
|
||||
}
|
||||
|
||||
val filteredApks =
|
||||
patchedFiles.filter { it.name.endsWith(".apk") && it.exists() && it.length() > 0 }
|
||||
if (filteredApks.isEmpty()) {
|
||||
throw IOException("No valid APK files found for installation")
|
||||
}
|
||||
|
||||
print("Starting installation of ${filteredApks.size} APK files")
|
||||
filteredApks.forEachIndexed { index, file ->
|
||||
print(" Installing (${index + 1}/${filteredApks.size}): ${file.name}")
|
||||
}
|
||||
|
||||
print("Launching installer...")
|
||||
val success = SessionInstaller().installApks(
|
||||
context,
|
||||
filteredApks,
|
||||
false,
|
||||
log = print,
|
||||
callback = { success, string ->
|
||||
if (success) {
|
||||
print("APK installation completed successfully")
|
||||
} else {
|
||||
print("APK installation failed: $string")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!success) {
|
||||
throw IOException("Installation failed")
|
||||
}
|
||||
|
||||
print("APK installation completed successfully")
|
||||
}
|
||||
|
||||
private fun cleanupOnFailure() {
|
||||
try {
|
||||
unzipFolder.listFiles()?.forEach { it.delete() }
|
||||
outputDir.listFiles()?.forEach { it.delete() }
|
||||
|
||||
if (xapkFile.exists() && xapkFile.length() <= 100) xapkFile.delete()
|
||||
if (modFile.exists() && modFile.length() <= 100) modFile.delete()
|
||||
} catch (e: Exception) {
|
||||
// we dont care about these anyway
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stepWithProgress(
|
||||
name: String,
|
||||
print: (String) -> Unit,
|
||||
action: suspend () -> Unit,
|
||||
) {
|
||||
try {
|
||||
print("===== STEP: $name =====")
|
||||
action()
|
||||
print("===== COMPLETED: $name =====")
|
||||
} catch (e: Exception) {
|
||||
print("===== FAILED: $name =====")
|
||||
throw IOException("$name failed: ${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.grindrplus.manager
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement.Center
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.PhotoAlbum
|
||||
import androidx.compose.material.icons.rounded.Science
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.grindrplus.GrindrPlus
|
||||
import com.grindrplus.bridge.BridgeClient
|
||||
import com.grindrplus.core.Config
|
||||
import com.grindrplus.manager.ui.HomeScreen
|
||||
import com.grindrplus.manager.ui.InstallPage
|
||||
import com.grindrplus.manager.ui.SettingsScreen
|
||||
import com.grindrplus.manager.ui.theme.GrindrPlusTheme
|
||||
import com.grindrplus.utils.HookManager
|
||||
import com.onebusaway.plausible.android.AndroidResourcePlausibleConfig
|
||||
import com.onebusaway.plausible.android.NetworkFirstPlausibleClient
|
||||
import com.onebusaway.plausible.android.Plausible
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import timber.log.Timber
|
||||
import timber.log.Timber.DebugTree
|
||||
|
||||
internal val activityScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
internal const val TAG = "GrindrPlus"
|
||||
internal const val DATA_URL =
|
||||
"https://raw.githubusercontent.com/R0rt1z2/GrindrPlus/refs/heads/master/manifest.json"
|
||||
|
||||
sealed class MainNavItem(
|
||||
val icon: ImageVector? = null,
|
||||
var title: String,
|
||||
val composable: @Composable PaddingValues.(Activity) -> Unit,
|
||||
) {
|
||||
data object Settings :
|
||||
MainNavItem(Icons.Filled.Settings, "Settings", { SettingsScreen(this) })
|
||||
|
||||
data object InstallPage :
|
||||
MainNavItem(Icons.Rounded.Download, "Install", { InstallPage(it, this) })
|
||||
|
||||
data object Home : MainNavItem(Icons.Rounded.Home, "Home", { HomeScreen(this) })
|
||||
data object Albums : MainNavItem(Icons.Rounded.PhotoAlbum, "Albums", { ComingSoon() })
|
||||
data object Experiments : MainNavItem(Icons.Rounded.Science, "Experiments", { ComingSoon() })
|
||||
|
||||
companion object {
|
||||
val VALUES by lazy { listOf(Settings, InstallPage, Home, Albums, Experiments) }
|
||||
}
|
||||
}
|
||||
|
||||
var plausible: Plausible? = null
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
Timber.plant(DebugTree())
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = false
|
||||
isAppearanceLightNavigationBars = false
|
||||
}
|
||||
|
||||
setContent {
|
||||
var serviceBound by remember { mutableStateOf(false) }
|
||||
var firstLaunchDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
GrindrPlus.bridgeClient = BridgeClient(this@MainActivity)
|
||||
GrindrPlus.bridgeClient.connect {
|
||||
Config.initialize(null)
|
||||
HookManager().registerHooks(false)
|
||||
serviceBound = true
|
||||
|
||||
if (Config.get("analytics", true) as Boolean) {
|
||||
val config = AndroidResourcePlausibleConfig(this@MainActivity).also {
|
||||
it.domain = "grindrplus.lol"
|
||||
it.host = "https://plausible.gmmz.dev/api/"
|
||||
it.enable = true
|
||||
}
|
||||
|
||||
plausible = Plausible(
|
||||
config = config,
|
||||
client = NetworkFirstPlausibleClient(config)
|
||||
)
|
||||
|
||||
plausible?.enable(true)
|
||||
plausible?.pageView("app://grindrplus/home")
|
||||
}
|
||||
|
||||
if (Config.get("first_launch", true) as Boolean) {
|
||||
firstLaunchDialog = true
|
||||
Config.put("first_launch", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!serviceBound) {
|
||||
return@setContent
|
||||
}
|
||||
|
||||
GrindrPlusTheme(
|
||||
dynamicColor = Config.get("material_you", false) as Boolean,
|
||||
) {
|
||||
if (firstLaunchDialog) {
|
||||
Dialog(
|
||||
onDismissRequest = { firstLaunchDialog = false }) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Center
|
||||
) {
|
||||
Text(
|
||||
text = "Welcome to GrindrPlus!",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text =
|
||||
"We collect totally anonymous data to improve the app.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text =
|
||||
"You can disable this in the settings.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Data collected:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "• App opens\n• Installation success/failure\n• Eventual failure reason",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { firstLaunchDialog = false },
|
||||
modifier = Modifier.align(CenterHorizontally).padding(top = 16.dp)
|
||||
) {
|
||||
Text("Ok, got it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return@GrindrPlusTheme
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
},
|
||||
content = { innerPadding ->
|
||||
NavHost(
|
||||
navController,
|
||||
startDestination = MainNavItem.Home.toString()
|
||||
) {
|
||||
for (item in MainNavItem.VALUES) {
|
||||
composable(item.toString()) {
|
||||
item.composable(
|
||||
innerPadding,
|
||||
this@MainActivity
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
BottomAppBar(modifier = Modifier) {
|
||||
var selectedItem by remember { mutableIntStateOf(0) }
|
||||
var currentRoute by remember { mutableStateOf(MainNavItem.Home.toString()) }
|
||||
|
||||
MainNavItem.VALUES.forEachIndexed { index, navigationItem ->
|
||||
if (navigationItem.toString() == currentRoute) {
|
||||
selectedItem = index
|
||||
}
|
||||
}
|
||||
|
||||
NavigationBar {
|
||||
MainNavItem.VALUES.forEachIndexed { index, item ->
|
||||
NavigationBarItem(
|
||||
alwaysShowLabel = true,
|
||||
icon = {
|
||||
Icon(
|
||||
item.icon!!,
|
||||
contentDescription = item.title
|
||||
)
|
||||
},
|
||||
label = { Text(item.title) },
|
||||
selected = selectedItem == index,
|
||||
onClick = {
|
||||
selectedItem = index
|
||||
currentRoute = item.toString()
|
||||
navController.navigateItem(item)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
activityScope.cancel() // I always forget about this
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateItem(item: MainNavItem) {
|
||||
navigate(item.toString()) {
|
||||
graph.startDestinationRoute?.let { route ->
|
||||
popUpTo(route) {
|
||||
saveState = true
|
||||
}
|
||||
}
|
||||
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ComingSoon() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
verticalArrangement = Center
|
||||
) {
|
||||
Text("Coming soon!", fontSize = TextUnit(24f, TextUnitType.Sp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.grindrplus.manager.settings
|
||||
|
||||
data class SwitchSetting(
|
||||
override val id: String,
|
||||
override val title: String,
|
||||
val description: String? = null,
|
||||
val isChecked: Boolean,
|
||||
val onCheckedChange: (Boolean) -> Unit,
|
||||
) : Setting(id, title)
|
||||
|
||||
// Setting types
|
||||
sealed class Setting(open val id: String, open val title: String)
|
||||
|
||||
data class TextSetting(
|
||||
override val id: String,
|
||||
override val title: String,
|
||||
val description: String? = null,
|
||||
val value: String,
|
||||
val onValueChange: (String) -> Unit,
|
||||
val keyboardType: KeyboardType = KeyboardType.Text,
|
||||
val validator: ((String) -> String?)? = null,
|
||||
) : Setting(id, title)
|
||||
|
||||
data class ButtonSetting(
|
||||
override val id: String,
|
||||
override val title: String,
|
||||
val onClick: () -> Unit,
|
||||
) : Setting(id, title)
|
||||
|
||||
// Represents a group of settings
|
||||
data class SettingGroup(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val settings: List<Setting>,
|
||||
)
|
||||
|
||||
// Keyboard type enum
|
||||
enum class KeyboardType {
|
||||
Text, Number, Email, Password, Phone
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.grindrplus.manager.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.grindrplus.core.Config
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(
|
||||
private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _settingGroups = MutableStateFlow<List<SettingGroup>>(emptyList())
|
||||
val settingGroups: StateFlow<List<SettingGroup>> = _settingGroups
|
||||
|
||||
private val _isLoading = MutableStateFlow(true)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
init {
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
fun loadSettings() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
|
||||
try {
|
||||
// Get hooks from Config
|
||||
val hooks = Config.getHooksSettings()
|
||||
val hookSettings = hooks.filter {
|
||||
it.key != "Mod settings" &&
|
||||
it.key != "Persistent incognito" &&
|
||||
it.key != "Unlimited albums"
|
||||
}.map { (hookName, pair) ->
|
||||
SwitchSetting(
|
||||
id = hookName,
|
||||
title = hookName,
|
||||
description = pair.first,
|
||||
isChecked = pair.second,
|
||||
onCheckedChange = {
|
||||
viewModelScope.launch {
|
||||
Config.setHookEnabled(hookName, it)
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Create other settings
|
||||
val otherSettings = listOf(
|
||||
TextSetting(
|
||||
id = "command_prefix",
|
||||
title = "Command Prefix",
|
||||
description = "Change the command prefix (default: /)",
|
||||
value = Config.get("command_prefix", "/") as String,
|
||||
onValueChange = {
|
||||
viewModelScope.launch {
|
||||
Config.put("command_prefix", it)
|
||||
loadSettings()
|
||||
}
|
||||
},
|
||||
validator = { input ->
|
||||
when {
|
||||
input.isBlank() -> "Invalid command prefix"
|
||||
input.length > 1 -> "Command prefix must be a single character"
|
||||
!input.matches(Regex("[^a-zA-Z0-9]")) -> "Command prefix must be a special character"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
),
|
||||
TextSetting(
|
||||
id = "online_indicator",
|
||||
title = "Online indicator duration (mins)",
|
||||
description = "Control when the green dot disappears after inactivity",
|
||||
value = (Config.get("online_indicator", 5) as Number).toString(),
|
||||
onValueChange = {
|
||||
val value = it.toIntOrNull() ?: 5
|
||||
viewModelScope.launch {
|
||||
Config.put("online_indicator", value)
|
||||
loadSettings()
|
||||
}
|
||||
},
|
||||
keyboardType = KeyboardType.Number,
|
||||
validator = { input ->
|
||||
val value = input.toIntOrNull()
|
||||
if (value == null || value <= 0) "Duration must be a positive number" else null
|
||||
}
|
||||
),
|
||||
// More text settings...
|
||||
|
||||
// Toggle settings
|
||||
SwitchSetting(
|
||||
id = "force_old_anti_block_behavior",
|
||||
title = "Force old AntiBlock behavior",
|
||||
description = "Use the old AntiBlock behavior (don't use this, required for testing)",
|
||||
isChecked = Config.get("force_old_anti_block_behavior", false) as Boolean,
|
||||
onCheckedChange = {
|
||||
viewModelScope.launch {
|
||||
Config.put("force_old_anti_block_behavior", it)
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
),
|
||||
SwitchSetting(
|
||||
id = "anti_block_use_toasts",
|
||||
title = "Use toasts for AntiBlock hook",
|
||||
description = "Instead of receiving Android notifications, use toasts for block/unblock notifications",
|
||||
isChecked = Config.get("anti_block_use_toasts", false) as Boolean,
|
||||
onCheckedChange = {
|
||||
viewModelScope.launch {
|
||||
Config.put("anti_block_use_toasts", it)
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
val managerSettings = mutableListOf<Setting>(
|
||||
SwitchSetting(
|
||||
id = "analytics",
|
||||
title = "Opt-in analytics",
|
||||
description = "Help improve the app by sending anonymous usage data",
|
||||
isChecked = Config.get("analytics", true) as Boolean,
|
||||
onCheckedChange = {
|
||||
viewModelScope.launch {
|
||||
Config.put("analytics", it)
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
managerSettings += SwitchSetting(
|
||||
id = "material_you",
|
||||
title = "Enable dynamic colors",
|
||||
description = "Use Material You colors for the app; Restart the app to apply changes",
|
||||
isChecked = Config.get("material_you", false) as Boolean,
|
||||
onCheckedChange = {
|
||||
viewModelScope.launch {
|
||||
Config.put("material_you", it)
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Create setting groups
|
||||
_settingGroups.value = listOf(
|
||||
SettingGroup(
|
||||
id = "hooks",
|
||||
title = "Manage Hooks",
|
||||
settings = hookSettings
|
||||
),
|
||||
SettingGroup(
|
||||
id = "other",
|
||||
title = "Other Settings",
|
||||
settings = otherSettings
|
||||
),
|
||||
SettingGroup(
|
||||
id = "manager",
|
||||
title = "Manager Settings",
|
||||
settings = managerSettings
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) {
|
||||
return SettingsViewModel(context) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberViewModel(): SettingsViewModel {
|
||||
val context = LocalContext.current
|
||||
val factory = remember(context) { SettingsViewModelFactory(context) }
|
||||
return viewModel(factory = factory)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.grindrplus.manager.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.West
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Color.Companion.Red
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
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 androidx.core.net.toUri
|
||||
import coil3.compose.AsyncImage
|
||||
import com.grindrplus.manager.MainNavItem
|
||||
import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(innerPadding: PaddingValues) {
|
||||
data class Release(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val author: String,
|
||||
val avatarUrl: String,
|
||||
val publishedAt: Instant,
|
||||
)
|
||||
|
||||
var contributors = remember { mutableStateMapOf<String, String>() }
|
||||
var releases = remember { mutableStateMapOf<String, Release>() }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val client = OkHttpClient()
|
||||
val contributorsRequest = Request.Builder()
|
||||
.url("https://api.github.com/repos/R0rt1z2/GrindrPlus/contributors")
|
||||
.header("Accept", "application/vnd.github.v3+json")
|
||||
.build()
|
||||
|
||||
val releasesRequest = Request.Builder()
|
||||
.url("https://api.github.com/repos/R0rt1z2/GrindrPlus/releases")
|
||||
.header("Accept", "application/vnd.github.v3+json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val contributorsResponse = async { client.newCall(contributorsRequest).execute() }
|
||||
val releasesResponse = async { client.newCall(releasesRequest).execute() }
|
||||
|
||||
contributorsResponse.await().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val jsonArray = JSONArray(response.body?.string())
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val contributor = jsonArray.getJSONObject(i)
|
||||
if (contributor.getString("login").contains("bot")) continue
|
||||
contributors[contributor.getString("login")] =
|
||||
contributor.getString("avatar_url")
|
||||
}
|
||||
} else {
|
||||
errorMessage = "Failed to fetch contributors: ${response.message}"
|
||||
}
|
||||
}
|
||||
|
||||
releasesResponse.await().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val jsonArray = JSONArray(response.body?.string())
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val release = jsonArray.getJSONObject(i)
|
||||
val id = release.getString("id")
|
||||
val name = release.getString("name") ?: release.getString("tag_name")
|
||||
val description = release.getString("body") ?: "No description provided"
|
||||
val author = release.getJSONObject("author").getString("login")
|
||||
val avatarUrl = release.getJSONObject("author").getString("avatar_url")
|
||||
val publishedAt = Instant.parse(release.getString("published_at"))
|
||||
|
||||
releases[id] =
|
||||
Release(name, description, author, avatarUrl, publishedAt)
|
||||
}
|
||||
} else {
|
||||
errorMessage = "Failed to fetch releases: ${response.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("HomeScreen").e(e, "Failed to fetch data")
|
||||
errorMessage = "An error occurred: ${e.message}"
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "GrindrPlus",
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "Enhanced Features for Grindr",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = Red,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Contributors",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(contributors.size) { index ->
|
||||
val (login, avatarUrl) = contributors.entries.elementAt(contributors.size - index - 1)
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = "Avatar of $login",
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
"https://github.com/$login".toUri()
|
||||
).also { intent ->
|
||||
context.startActivity(intent)
|
||||
}
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
) {
|
||||
val sortedReleases = releases.entries.sortedByDescending { (_, release) -> release.publishedAt }
|
||||
|
||||
items(sortedReleases.size) { index ->
|
||||
val (_, release) = sortedReleases[index]
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = release.avatarUrl,
|
||||
contentDescription = "Avatar of ${release.author}",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
"${release.author} • ${release.name}", fontSize = 14.sp, fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
MarkdownText(
|
||||
markdown = release.description,
|
||||
syntaxHighlightColor = Color.Transparent,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
package com.grindrplus.manager.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.unit.dp
|
||||
import com.grindrplus.manager.DATA_URL
|
||||
import com.grindrplus.manager.Installation
|
||||
import com.grindrplus.manager.MainActivity
|
||||
import com.grindrplus.manager.TAG
|
||||
import com.grindrplus.manager.activityScope
|
||||
import com.grindrplus.manager.utils.ConsoleLogger
|
||||
import com.grindrplus.manager.utils.ConsoleOutput
|
||||
import com.grindrplus.manager.utils.ErrorHandler
|
||||
import com.grindrplus.manager.utils.LogEntry
|
||||
import com.grindrplus.manager.utils.LogType
|
||||
import com.grindrplus.manager.utils.StorageUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val logEntries = mutableStateListOf<LogEntry>()
|
||||
private var progress by mutableFloatStateOf(0f)
|
||||
|
||||
@Composable
|
||||
fun InstallPage(context: Activity, innerPadding: PaddingValues) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val versionData = remember { mutableStateListOf<Data>() }
|
||||
var selectedVersion by remember { mutableStateOf<Data?>(null) }
|
||||
var isInstalling by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
StorageUtils.cleanupOldInstallationFiles(context, true, null)
|
||||
}
|
||||
|
||||
addLog("Welcome to Grindr Plus Manager")
|
||||
addLog("Loading available versions...", LogType.INFO)
|
||||
|
||||
loadVersionData(
|
||||
onSuccess = { data ->
|
||||
versionData.clear()
|
||||
versionData.addAll(data)
|
||||
isLoading = false
|
||||
addLog("Found ${data.size} available versions", LogType.SUCCESS)
|
||||
},
|
||||
onError = { error ->
|
||||
errorMessage = error
|
||||
isLoading = false
|
||||
addLog("Failed to load version data: $error", LogType.ERROR)
|
||||
}
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
if (isLoading) {
|
||||
LoadingScreen()
|
||||
} else if (errorMessage != null) {
|
||||
ErrorScreen(errorMessage!!) {
|
||||
// Retry loading
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
activityScope.launch {
|
||||
addLog("Retrying version data load...", LogType.INFO)
|
||||
loadVersionData(
|
||||
onSuccess = { data ->
|
||||
versionData.clear()
|
||||
versionData.addAll(data)
|
||||
isLoading = false
|
||||
addLog(
|
||||
"Found ${data.size} available versions",
|
||||
LogType.SUCCESS
|
||||
)
|
||||
},
|
||||
onError = { error ->
|
||||
errorMessage = error
|
||||
isLoading = false
|
||||
addLog("Failed to load version data: $error", LogType.ERROR)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Warning message
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "⚠️ Do not close the app while installation is in progress ⚠️",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
// Version selector dropdown
|
||||
VersionSelector(
|
||||
versions = versionData,
|
||||
selectedVersion = selectedVersion,
|
||||
onVersionSelected = {
|
||||
selectedVersion = it
|
||||
addLog("Selected version ${it.modVer}", LogType.INFO)
|
||||
},
|
||||
isEnabled = !isInstalling,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
ConsoleOutput(
|
||||
logEntries = logEntries,
|
||||
modifier = Modifier.weight(0.5f),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
activityScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
StorageUtils.cleanupOldInstallationFiles(
|
||||
context, true, selectedVersion?.modVer
|
||||
)
|
||||
}
|
||||
addLog(
|
||||
"Cleaned up old installation files",
|
||||
LogType.SUCCESS
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
addLog(
|
||||
"Failed to clean up: ${e.localizedMessage}",
|
||||
LogType.ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isInstalling,
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(
|
||||
alpha = 0.38f
|
||||
)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
"Clean Up",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (selectedVersion == null) {
|
||||
showToast(context, "Please select a version first")
|
||||
return@Button
|
||||
}
|
||||
|
||||
startInstallation(
|
||||
selectedVersion!!,
|
||||
onStarted = { isInstalling = true },
|
||||
onCompleted = { isInstalling = false },
|
||||
context
|
||||
)
|
||||
},
|
||||
enabled = selectedVersion != null && !isInstalling,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(
|
||||
alpha = 0.12f
|
||||
),
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(
|
||||
alpha = 0.38f
|
||||
)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = if (isInstalling) "Installing..." else "Install",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingScreen() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Loading available versions...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorScreen(errorMessage: String, onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Error: $errorMessage",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VersionSelector(
|
||||
modifier: Modifier = Modifier,
|
||||
versions: List<Data>,
|
||||
selectedVersion: Data?,
|
||||
onVersionSelected: (Data) -> Unit,
|
||||
isEnabled: Boolean = true,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
// Dropdown anchor
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
if (isEnabled) {
|
||||
expanded = !expanded
|
||||
}
|
||||
}
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryEditable, true),
|
||||
readOnly = true,
|
||||
enabled = isEnabled,
|
||||
value = selectedVersion?.modVer ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Select a GrindrPlus version") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
|
||||
)
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (versions.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"No versions available",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
onClick = { expanded = false },
|
||||
enabled = false
|
||||
)
|
||||
} else {
|
||||
versions.forEach { version ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text("Version ${version.modVer}")
|
||||
},
|
||||
onClick = {
|
||||
onVersionSelected(version)
|
||||
expanded = false
|
||||
},
|
||||
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadVersionData(
|
||||
onSuccess: (List<Data>) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
activityScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(DATA_URL)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Failed to load data: ${response.code}")
|
||||
}
|
||||
|
||||
val responseBody = response.body?.string()
|
||||
?: throw IOException("Empty response body")
|
||||
|
||||
val data = parseVersionData(responseBody)
|
||||
withContext(Dispatchers.Main) {
|
||||
onSuccess(data)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error loading version data")
|
||||
withContext(Dispatchers.Main) {
|
||||
onError(e.localizedMessage ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseVersionData(jsonData: String): List<Data> {
|
||||
try {
|
||||
val result = mutableListOf<Data>()
|
||||
val jsonObject = JSONObject(jsonData)
|
||||
val keys = jsonObject.keys()
|
||||
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val jsonArray = jsonObject.getJSONArray(key)
|
||||
|
||||
if (jsonArray.length() >= 2) {
|
||||
result.add(
|
||||
Data(
|
||||
modVer = key,
|
||||
grindrUrl = jsonArray.getString(0),
|
||||
modUrl = jsonArray.getString(1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result.sortedByDescending { it.modVer }
|
||||
} catch (e: JSONException) {
|
||||
Timber.tag(TAG).e(e, "Error parsing JSON")
|
||||
throw IOException("Invalid data format: ${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startInstallation(
|
||||
version: Data,
|
||||
onStarted: () -> Unit,
|
||||
onCompleted: () -> Unit,
|
||||
context: Activity,
|
||||
) {
|
||||
onStarted()
|
||||
|
||||
addLog("Starting installation for version ${version.modVer}...", LogType.INFO)
|
||||
|
||||
activityScope.launch {
|
||||
try {
|
||||
val installation = Installation(
|
||||
context,
|
||||
version.modVer,
|
||||
version.modUrl,
|
||||
version.grindrUrl
|
||||
)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
installation.install(
|
||||
print = { output ->
|
||||
Timber.tag(TAG).d(output)
|
||||
val logType = ConsoleLogger.parseLogType(output)
|
||||
context.runOnUiThread {
|
||||
addLog(output, logType)
|
||||
}
|
||||
},
|
||||
|
||||
progress = {
|
||||
context.runOnUiThread {
|
||||
progress = it
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
addLog("Installation completed successfully!", LogType.SUCCESS)
|
||||
showToast(context, "Installation complete!")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Installation failed")
|
||||
val errorMessage = "ERROR: ${e.localizedMessage ?: "Unknown error"}"
|
||||
addLog(errorMessage, LogType.ERROR)
|
||||
showToast(context, "Installation failed: ${e.localizedMessage}")
|
||||
|
||||
ErrorHandler.logError(
|
||||
context,
|
||||
TAG,
|
||||
"Installation failed for version ${version.modVer}",
|
||||
e
|
||||
)
|
||||
} finally {
|
||||
onCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addLog(message: String, type: LogType = LogType.INFO) {
|
||||
if (message.contains("<>:")) {
|
||||
val prefix = message.split("<>:")[0]
|
||||
|
||||
logEntries.find { it.message.startsWith(prefix) }?.let {
|
||||
logEntries.remove(it)
|
||||
}
|
||||
}
|
||||
|
||||
val logEntry = ConsoleLogger.log(message.replace("<>:", ":"), type)
|
||||
logEntries.add(logEntry)
|
||||
}
|
||||
|
||||
private fun showToast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
data class Data(
|
||||
val modVer: String,
|
||||
val grindrUrl: String,
|
||||
val modUrl: String,
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.grindrplus.manager.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.grindrplus.manager.settings.ButtonSetting
|
||||
import com.grindrplus.manager.settings.KeyboardType
|
||||
import com.grindrplus.manager.settings.Setting
|
||||
import com.grindrplus.manager.settings.SettingGroup
|
||||
import com.grindrplus.manager.settings.SettingsViewModel
|
||||
import com.grindrplus.manager.settings.SwitchSetting
|
||||
import com.grindrplus.manager.settings.TextSetting
|
||||
import com.grindrplus.manager.settings.rememberViewModel
|
||||
|
||||
// Compose UI components
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
paddingValues: PaddingValues,
|
||||
viewModel: SettingsViewModel = rememberViewModel(),
|
||||
) {
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val settingGroups by viewModel.settingGroups.collectAsState()
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
SettingsContent(
|
||||
settingGroups = settingGroups,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsContent(
|
||||
settingGroups: List<SettingGroup>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
settingGroups.forEach { group ->
|
||||
item {
|
||||
SettingGroupCard(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingGroupCard(group: SettingGroup) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = group.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
group.settings.forEach { setting ->
|
||||
SettingItem(setting)
|
||||
if (setting != group.settings.last()) {
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingItem(setting: Setting) {
|
||||
when (setting) {
|
||||
is SwitchSetting -> SwitchSettingItem(setting)
|
||||
is TextSetting -> TextSettingItem(setting)
|
||||
is ButtonSetting -> ButtonSettingItem(setting)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwitchSettingItem(setting: SwitchSetting) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = setting.title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (setting.description != null) {
|
||||
Text(
|
||||
text = setting.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = setting.isChecked,
|
||||
onCheckedChange = setting.onCheckedChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextSettingItem(setting: TextSetting) {
|
||||
var text by remember { mutableStateOf(setting.value) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = setting.title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
if (setting.description != null) {
|
||||
Text(
|
||||
text = setting.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { value ->
|
||||
text = value
|
||||
errorMessage = setting.validator?.invoke(value)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = errorMessage != null,
|
||||
supportingText = {
|
||||
if (errorMessage != null) {
|
||||
Text(text = errorMessage!!)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = when (setting.keyboardType) {
|
||||
KeyboardType.Number -> androidx.compose.ui.text.input.KeyboardType.Number
|
||||
KeyboardType.Email -> androidx.compose.ui.text.input.KeyboardType.Email
|
||||
KeyboardType.Password -> androidx.compose.ui.text.input.KeyboardType.Password
|
||||
KeyboardType.Phone -> androidx.compose.ui.text.input.KeyboardType.Phone
|
||||
else -> androidx.compose.ui.text.input.KeyboardType.Text
|
||||
},
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (errorMessage == null) {
|
||||
setting.onValueChange(text)
|
||||
}
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
if (errorMessage == null) {
|
||||
setting.onValueChange(text)
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Done, contentDescription = "Save")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ButtonSettingItem(setting: ButtonSetting) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = setting.title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Button(onClick = setting.onClick) {
|
||||
Text("Open")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.grindrplus.manager.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
val GrindrYellow = Color(0xFFFFCC00)
|
||||
val GrindrDarkYellow = Color(0xFFA38300)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = GrindrYellow,
|
||||
onPrimary = Color(0xFF000000),
|
||||
primaryContainer = Color(0xFFFFE897),
|
||||
onPrimaryContainer = Color(0xFF241A00),
|
||||
|
||||
secondary = GrindrDarkYellow,
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFF2E1BB),
|
||||
onSecondaryContainer = Color(0xFF231B04),
|
||||
|
||||
tertiary = Color(0xFF47664A),
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFC8ECC9),
|
||||
onTertiaryContainer = Color(0xFF03210C),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF410002),
|
||||
background = Color(0xFFFFF8F1),
|
||||
onBackground = Color(0xFF1F1B13),
|
||||
surface = Color(0xFFFFF8F1),
|
||||
onSurface = Color(0xFF1F1B13),
|
||||
surfaceVariant = Color(0xFFEBE1CF),
|
||||
onSurfaceVariant = Color(0xFF4C4639),
|
||||
outline = Color(0xFF7E7667),
|
||||
outlineVariant = Color(0xFFCFC6B4),
|
||||
scrim = Color(0xFF000000),
|
||||
inverseSurface = Color(0xFF343027),
|
||||
inverseOnSurface = Color(0xFFF8F0E2),
|
||||
inversePrimary = GrindrDarkYellow,
|
||||
surfaceDim = Color(0xFFE1D9CC),
|
||||
surfaceBright = Color(0xFFFFF8F1),
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF),
|
||||
surfaceContainerLow = Color(0xFFFBF3E5),
|
||||
surfaceContainer = Color(0xFFF5EDDF),
|
||||
surfaceContainerHigh = Color(0xFFF0E7D9),
|
||||
surfaceContainerHighest = Color(0xFFEAE1D4)
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = GrindrYellow,
|
||||
onPrimary = Color(0xFF000000),
|
||||
primaryContainer = GrindrDarkYellow,
|
||||
onPrimaryContainer = Color(0xFFFFE897),
|
||||
|
||||
secondary = Color(0xFFD5C5A1),
|
||||
onSecondary = Color(0xFF50462A),
|
||||
secondaryContainer = GrindrDarkYellow,
|
||||
onSecondaryContainer = Color(0xFFF2E1BB),
|
||||
|
||||
tertiary = Color(0xFFADCFAE),
|
||||
onTertiary = Color(0xFF2F4D34),
|
||||
tertiaryContainer = Color(0xFF47664A),
|
||||
onTertiaryContainer = Color(0xFFC8ECC9),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
background = Color(0xFF1F1B13),
|
||||
onBackground = Color(0xFFF8F0E2),
|
||||
surface = Color(0xFF1F1B13),
|
||||
onSurface = Color(0xFFF8F0E2),
|
||||
surfaceVariant = Color(0xFF4C4639),
|
||||
onSurfaceVariant = Color(0xFFCFC6B4),
|
||||
outline = Color(0xFF999080),
|
||||
outlineVariant = Color(0xFF4C4639),
|
||||
scrim = Color(0xFF000000),
|
||||
inverseSurface = Color(0xFFF8F0E2),
|
||||
inverseOnSurface = Color(0xFF343027),
|
||||
inversePrimary = GrindrYellow,
|
||||
surfaceDim = Color(0xFF141009),
|
||||
surfaceBright = Color(0xFF3C372B),
|
||||
surfaceContainerLowest = Color(0xFF0F0C06),
|
||||
surfaceContainerLow = Color(0xFF1A160F),
|
||||
surfaceContainer = Color(0xFF1F1B13),
|
||||
surfaceContainerHigh = Color(0xFF29251C),
|
||||
surfaceContainerHighest = Color(0xFF342F25)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GrindrPlusTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.grindrplus.manager.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.grindrplus.manager.utils
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LogEntry(
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
var message: String,
|
||||
val type: LogType = LogType.INFO,
|
||||
)
|
||||
|
||||
enum class LogType {
|
||||
INFO, SUCCESS, WARNING, ERROR, DEBUG
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConsoleOutput(
|
||||
logEntries: List<LogEntry>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(logEntries.size) {
|
||||
if (logEntries.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(logEntries.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Logs",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${logEntries.size} entries",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
thickness = 1.dp
|
||||
)
|
||||
|
||||
if (logEntries.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No log entries yet",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.wrapContentHeight()
|
||||
) {
|
||||
items(logEntries) { entry ->
|
||||
LogEntryItem(entry)
|
||||
}
|
||||
|
||||
item {
|
||||
Divider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
thickness = 1.dp
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val shareText = logEntries.joinToString("\n") { it.message }
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, shareText)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "GrindrPlus Logs")
|
||||
}
|
||||
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
intent,
|
||||
"Share logs"
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share logs",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Share")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogEntryItem(entry: LogEntry) {
|
||||
val logColor = when (entry.type) {
|
||||
// TODO: Move to Theme.kt
|
||||
LogType.SUCCESS -> Color(0xFF4CAF50)
|
||||
LogType.WARNING -> Color(0xFFFFC107)
|
||||
LogType.ERROR -> Color(0xFFE91E63)
|
||||
LogType.DEBUG -> Color(0xFF9C27B0)
|
||||
LogType.INFO -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = entry.message,
|
||||
color = logColor,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object ConsoleLogger {
|
||||
fun log(message: String, type: LogType = LogType.INFO): LogEntry {
|
||||
return LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
message = message,
|
||||
type = type
|
||||
)
|
||||
}
|
||||
|
||||
fun parseLogType(message: String): LogType {
|
||||
return when {
|
||||
message.startsWith("ERROR:") || message.contains("error", ignoreCase = true) ||
|
||||
message.contains("failed", ignoreCase = true) || message.contains(
|
||||
"exception",
|
||||
ignoreCase = true
|
||||
) ->
|
||||
LogType.ERROR
|
||||
|
||||
message.startsWith("WARNING:") || message.contains("warning", ignoreCase = true) ||
|
||||
message.contains("stalled", ignoreCase = true) ->
|
||||
LogType.WARNING
|
||||
|
||||
message.startsWith("DEBUG:") || message.startsWith("LSPOSED D") ->
|
||||
LogType.DEBUG
|
||||
|
||||
message.contains("complete", ignoreCase = true) || message.contains(
|
||||
"success",
|
||||
ignoreCase = true
|
||||
) ||
|
||||
message.contains("finished", ignoreCase = true) || message.contains("100%") ->
|
||||
LogType.SUCCESS
|
||||
|
||||
else ->
|
||||
LogType.INFO
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.grindrplus.manager.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// TODO: Sync with module once everything's connected
|
||||
object ErrorHandler {
|
||||
private const val TAG = "ErrorHandler"
|
||||
private const val LOG_FILE_PREFIX = "grindrplus_log_"
|
||||
|
||||
fun logError(context: Context, tag: String, message: String, error: Throwable?) {
|
||||
Log.e(tag, message, error)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val logDir = File(context.getExternalFilesDir(null), "logs")
|
||||
if (!logDir.exists()) {
|
||||
logDir.mkdirs()
|
||||
}
|
||||
|
||||
cleanupOldLogs(logDir, 5)
|
||||
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val logFile = File(logDir, "$LOG_FILE_PREFIX${timestamp}.txt")
|
||||
|
||||
FileOutputStream(logFile, true).use { output ->
|
||||
val writer = PrintWriter(output)
|
||||
|
||||
writer.println("---- ERROR LOG ${timestamp} ----")
|
||||
writer.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
writer.println("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})")
|
||||
writer.println("Message: $message")
|
||||
|
||||
if (error != null) {
|
||||
writer.println("Exception: ${error.javaClass.name}: ${error.message}")
|
||||
val sw = StringWriter()
|
||||
val pw = PrintWriter(sw)
|
||||
error.printStackTrace(pw)
|
||||
writer.println(sw.toString())
|
||||
}
|
||||
|
||||
writer.println("----------------------")
|
||||
writer.println()
|
||||
writer.flush()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to write to error log", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showToast(context: Context, message: String, length: Int = Toast.LENGTH_SHORT) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
Toast.makeText(context, message, length).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupOldLogs(logDir: File, keepCount: Int) {
|
||||
try {
|
||||
val logFiles = logDir.listFiles { file ->
|
||||
file.isFile && file.name.startsWith(LOG_FILE_PREFIX)
|
||||
}
|
||||
|
||||
if (logFiles != null && logFiles.size > keepCount) {
|
||||
val sortedFiles = logFiles.sortedBy { it.lastModified() }
|
||||
|
||||
for (i in 0 until sortedFiles.size - keepCount) {
|
||||
sortedFiles[i].delete()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cleanup old logs", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
package com.grindrplus.manager.utils
|
||||
|
||||
import com.grindrplus.R
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.DownloadManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageInstaller.SessionParams
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.tonyodev.fetch2.Download
|
||||
import com.tonyodev.fetch2.Error
|
||||
import com.tonyodev.fetch2.Fetch
|
||||
import com.tonyodev.fetch2.FetchConfiguration
|
||||
import com.tonyodev.fetch2.FetchListener
|
||||
import com.tonyodev.fetch2.NetworkType
|
||||
import com.tonyodev.fetch2.Priority
|
||||
import com.tonyodev.fetch2.Request
|
||||
import com.tonyodev.fetch2core.DownloadBlock
|
||||
import com.tonyodev.fetch2okhttp.OkHttpDownloader
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.bouncycastle.asn1.x500.X500Name
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.Certificate
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Helper class for installing APK files using the PackageInstaller API
|
||||
*/
|
||||
class SessionInstaller {
|
||||
companion object {
|
||||
private const val TAG = "SessionInstaller"
|
||||
private const val ACTION_INSTALL_COMPLETE = "com.grindrplus.INSTALL_COMPLETE"
|
||||
private const val DEFAULT_BUFFER_SIZE = 8192
|
||||
}
|
||||
|
||||
/**
|
||||
* Install multiple APK files (split APKs) using PackageInstaller
|
||||
*
|
||||
* @param context The application context
|
||||
* @param apks List of APK files to install
|
||||
* @param silent Whether to install silently (requires privileged permissions)
|
||||
* @param callback Optional callback to report success/failure
|
||||
* @return True if installation was successful, false otherwise
|
||||
*/
|
||||
suspend fun installApks(
|
||||
context: Context,
|
||||
apks: List<File>,
|
||||
silent: Boolean = false,
|
||||
callback: ((success: Boolean, message: String) -> Unit)? = null,
|
||||
log: (String) -> Unit,
|
||||
): Boolean = suspendCoroutine { continuation ->
|
||||
if (apks.isEmpty()) {
|
||||
val message = "No APK files provided."
|
||||
Timber.tag(TAG).e(message)
|
||||
callback?.invoke(false, message)
|
||||
continuation.resumeWithException(IOException(message))
|
||||
return@suspendCoroutine
|
||||
}
|
||||
|
||||
// Validate all APK files exist
|
||||
val missingApks = apks.filter { !it.exists() || it.length() <= 0 }
|
||||
if (missingApks.isNotEmpty()) {
|
||||
val message =
|
||||
"Missing or empty APK files: ${missingApks.joinToString { it.absolutePath }}"
|
||||
Timber.tag(TAG).e(message)
|
||||
log("ERROR: $message")
|
||||
callback?.invoke(false, message)
|
||||
continuation.resumeWithException(IOException(message))
|
||||
return@suspendCoroutine
|
||||
}
|
||||
|
||||
val packageInstaller = context.packageManager.packageInstaller
|
||||
|
||||
// Create installation session
|
||||
val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
|
||||
setInstallReason(PackageManager.INSTALL_REASON_USER)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST)
|
||||
if (silent) {
|
||||
setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the session
|
||||
val sessionId = try {
|
||||
packageInstaller.createSession(params)
|
||||
} catch (e: IOException) {
|
||||
val message = "Failed to create install session: ${e.message}"
|
||||
Timber.tag(TAG).e(e, message)
|
||||
log("ERROR: $message")
|
||||
callback?.invoke(false, message)
|
||||
continuation.resumeWithException(e)
|
||||
return@suspendCoroutine
|
||||
}
|
||||
|
||||
// Process for completion
|
||||
val installCompleteReceiver = object : BroadcastReceiver() {
|
||||
@SuppressLint("UnsafeIntentLaunch")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
|
||||
val status = intent.getIntExtra(
|
||||
PackageInstaller.EXTRA_STATUS,
|
||||
PackageInstaller.STATUS_FAILURE
|
||||
)
|
||||
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
?: "Unknown status"
|
||||
|
||||
Timber.tag(TAG).d("Installation status: $status, message: $message")
|
||||
log("DEBUG: $message")
|
||||
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
callback?.invoke(true, "Installation successful")
|
||||
log("Installed!")
|
||||
continuation.resume(true)
|
||||
}
|
||||
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
Timber.tag(TAG).d("Installation requires user confirmation")
|
||||
log("DEBUG: Installation requires user confirmation")
|
||||
val confirmationIntent =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(
|
||||
Intent.EXTRA_INTENT,
|
||||
Intent::class.java
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
}
|
||||
if (confirmationIntent != null) {
|
||||
confirmationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(confirmationIntent)
|
||||
} else {
|
||||
val errorMsg = "Missing confirmation intent"
|
||||
log("ERROR: $errorMsg")
|
||||
Timber.tag(TAG).e(errorMsg)
|
||||
callback?.invoke(false, errorMsg)
|
||||
continuation.resumeWithException(IOException(errorMsg))
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val errorMsg = "Installation failed: $message (code: $status)"
|
||||
Timber.tag(TAG).e(errorMsg)
|
||||
callback?.invoke(false, errorMsg)
|
||||
continuation.resumeWithException(IOException(errorMsg))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error in broadcast receiver")
|
||||
callback?.invoke(false, "Error processing installation result: ${e.message}")
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val intent = Intent(ACTION_INSTALL_COMPLETE).apply {
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
installCompleteReceiver,
|
||||
IntentFilter(ACTION_INSTALL_COMPLETE),
|
||||
ContextCompat.RECEIVER_EXPORTED
|
||||
)
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, sessionId, intent, flags)
|
||||
|
||||
packageInstaller.openSession(sessionId).use { session ->
|
||||
for (apk in apks) {
|
||||
Timber.tag(TAG).d("Writing APK to session: ${apk.name} (${apk.length()} bytes)")
|
||||
|
||||
apk.inputStream().use { inputStream ->
|
||||
session.openWrite(apk.name, 0, apk.length()).use { outputStream ->
|
||||
inputStream.copyTo(outputStream, DEFAULT_BUFFER_SIZE)
|
||||
session.fsync(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag(TAG).d("Committing installation session...")
|
||||
session.commit(pendingIntent.intentSender)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(installCompleteReceiver)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
val message = "Installation failed: ${e.message}"
|
||||
Timber.tag(TAG).e(e, message)
|
||||
callback?.invoke(false, message)
|
||||
continuation.resumeWithException(e)
|
||||
return@suspendCoroutine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class DownloadResult(val success: Boolean, val reason: String?) {
|
||||
companion object {
|
||||
fun success() = DownloadResult(true, null)
|
||||
fun failure(reason: String) = DownloadResult(false, reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file using Android's DownloadManager
|
||||
* with proper error handling and progress monitoring
|
||||
*
|
||||
* @param context Android context
|
||||
* @param out Destination file
|
||||
* @param url URL to download from
|
||||
* @param print Callback to report download progress
|
||||
* @return True if download succeeded, false otherwise
|
||||
*/
|
||||
|
||||
suspend fun download(
|
||||
context: Context,
|
||||
out: File,
|
||||
url: String,
|
||||
printConsole: (String) -> Unit,
|
||||
): DownloadResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
out.parentFile?.mkdirs()
|
||||
|
||||
var lastUpdateTime = System.currentTimeMillis()
|
||||
var lastBytesDownloaded = 0L
|
||||
var averageSpeed = 0.0
|
||||
|
||||
val fetch = Fetch.getInstance(
|
||||
FetchConfiguration.Builder(context)
|
||||
.setDownloadConcurrentLimit(1)
|
||||
.enableLogging(true)
|
||||
.setHttpDownloader(
|
||||
OkHttpDownloader(
|
||||
getCustomTrustedOkHttpClient(context)
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
val request = Request(url, out.absolutePath).apply {
|
||||
priority = Priority.HIGH
|
||||
networkType = NetworkType.ALL
|
||||
}
|
||||
|
||||
return@withContext suspendCoroutine { continuation ->
|
||||
fetch.addListener(object : FetchListener {
|
||||
override fun onStarted(
|
||||
download: Download,
|
||||
downloadBlocks: List<DownloadBlock>,
|
||||
totalBlocks: Int,
|
||||
) {
|
||||
printConsole("Starting download...")
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
override fun onProgress(
|
||||
download: Download,
|
||||
etaInMilliSeconds: Long,
|
||||
downloadedBytesPerSecond: Long,
|
||||
) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val timeDelta = currentTime - lastUpdateTime
|
||||
|
||||
if (timeDelta > 0) {
|
||||
val bytesDelta = download.downloaded - lastBytesDownloaded
|
||||
val currentSpeed = bytesDelta.toDouble() / timeDelta / 1024 / 1024
|
||||
averageSpeed = if (averageSpeed == 0.0) currentSpeed
|
||||
else (averageSpeed * 0.7 + currentSpeed * 0.3)
|
||||
|
||||
val percentage = download.progress
|
||||
printConsole(
|
||||
"Download status<>: " +
|
||||
"$percentage% ${String.format("%.2f", averageSpeed)} Mb/s " +
|
||||
"(ETA:${etaInMilliSeconds.div(60000)}m${
|
||||
(etaInMilliSeconds.rem(
|
||||
60000
|
||||
)).div(1000)
|
||||
}s)"
|
||||
)
|
||||
|
||||
lastUpdateTime = currentTime
|
||||
lastBytesDownloaded = download.downloaded
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(download: Download, error: Error, throwable: Throwable?) {
|
||||
fetch.removeListener(this)
|
||||
fetch.close()
|
||||
if (out.exists()) out.delete()
|
||||
continuation.resume(DownloadResult.failure(error.name))
|
||||
}
|
||||
|
||||
override fun onCompleted(download: Download) {
|
||||
fetch.removeListener(this)
|
||||
fetch.close()
|
||||
printConsole("Completed download")
|
||||
|
||||
if (validateFile(out)) {
|
||||
continuation.resume(DownloadResult.success())
|
||||
} else {
|
||||
if (out.exists()) out.delete()
|
||||
continuation.resume(DownloadResult.failure("Downloaded file validation failed"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelled(download: Download) {
|
||||
fetch.removeListener(this)
|
||||
fetch.close()
|
||||
if (out.exists()) out.delete()
|
||||
continuation.resume(DownloadResult.failure("Download cancelled"))
|
||||
}
|
||||
|
||||
override fun onPaused(download: Download) {
|
||||
printConsole("Paused.")
|
||||
}
|
||||
|
||||
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {}
|
||||
override fun onRemoved(download: Download) {}
|
||||
override fun onDeleted(download: Download) {}
|
||||
override fun onResumed(download: Download) {}
|
||||
override fun onWaitingNetwork(download: Download) {}
|
||||
override fun onAdded(download: Download) {}
|
||||
override fun onDownloadBlockUpdated(
|
||||
download: Download,
|
||||
downloadBlock: DownloadBlock,
|
||||
totalBlocks: Int,
|
||||
) {
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
fetch.removeAll()
|
||||
fetch.enqueue(request)
|
||||
} catch (e: Exception) {
|
||||
fetch.close()
|
||||
if (out.exists()) out.delete()
|
||||
continuation.resume(DownloadResult.failure(e.message ?: "Unknown error"))
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
if (out.exists()) out.delete()
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (out.exists()) out.delete()
|
||||
return@withContext DownloadResult.failure(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a downloaded file is complete and not corrupted
|
||||
*/
|
||||
private fun validateFile(file: File): Boolean {
|
||||
if (!file.exists() || file.length() <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (file.name.endsWith(".zip") || file.name.endsWith(".xapk")) {
|
||||
try {
|
||||
ZipFile(file).close()
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("Download").e("Invalid ZIP file: ${e.localizedMessage}")
|
||||
file.delete()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun getCustomTrustedOkHttpClient(context: Context): OkHttpClient {
|
||||
// Load the default trust manager
|
||||
val defaultTrustManager =
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
|
||||
init(null as KeyStore?)
|
||||
}.trustManagers[0] as X509TrustManager
|
||||
|
||||
// Load custom certificate
|
||||
val certificateFactory = CertificateFactory.getInstance("X.509")
|
||||
val customCertificate = context.resources.openRawResource(R.raw.cert)
|
||||
.use { certificateFactory.generateCertificate(it) }
|
||||
|
||||
// Create keystore with custom certificate
|
||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(null, null)
|
||||
setCertificateEntry("custom", customCertificate)
|
||||
}
|
||||
|
||||
// Create custom trust manager
|
||||
val customTrustManager =
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
|
||||
init(keyStore)
|
||||
}.trustManagers[0] as X509TrustManager
|
||||
|
||||
// Combine both trust managers
|
||||
val combinedTrustManager = @SuppressLint("CustomX509TrustManager")
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
try {
|
||||
defaultTrustManager.checkClientTrusted(chain, authType)
|
||||
} catch (_: Exception) {
|
||||
customTrustManager.checkClientTrusted(chain, authType)
|
||||
}
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
try {
|
||||
defaultTrustManager.checkServerTrusted(chain, authType)
|
||||
} catch (_: Exception) {
|
||||
customTrustManager.checkServerTrusted(chain, authType)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> =
|
||||
defaultTrustManager.acceptedIssuers + customTrustManager.acceptedIssuers
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS").apply {
|
||||
init(null, arrayOf(combinedTrustManager), SecureRandom())
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.sslSocketFactory(sslContext.socketFactory, combinedTrustManager)
|
||||
.hostnameVerifier { _, _ -> true }
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unzips a file to the specified directory with proper error handling
|
||||
*
|
||||
* @param unzipLocationRoot The target directory (or null to use same directory)
|
||||
* @throws IOException If extraction fails
|
||||
*/
|
||||
fun File.unzip(unzipLocationRoot: File? = null) {
|
||||
if (!exists() || length() <= 0) {
|
||||
throw IOException("ZIP file doesn't exist or is empty: $absolutePath")
|
||||
}
|
||||
|
||||
val rootFolder =
|
||||
unzipLocationRoot ?: File(parentFile!!.absolutePath + File.separator + nameWithoutExtension)
|
||||
|
||||
if (!rootFolder.exists()) {
|
||||
if (!rootFolder.mkdirs()) {
|
||||
throw IOException("Failed to create output directory: ${rootFolder.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ZipFile(this).use { zip ->
|
||||
val entries = zip.entries().asSequence().toList()
|
||||
|
||||
if (entries.isEmpty()) {
|
||||
throw IOException("ZIP file is empty: $absolutePath")
|
||||
}
|
||||
|
||||
for (entry in entries) {
|
||||
val outputFile = File(rootFolder.absolutePath + File.separator + entry.name)
|
||||
|
||||
// cute zip slip vulnerability
|
||||
if (!outputFile.canonicalPath.startsWith(rootFolder.canonicalPath + File.separator)) {
|
||||
throw SecurityException("ZIP entry is outside of target directory: ${entry.name}")
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
if (!outputFile.exists() && !outputFile.mkdirs()) {
|
||||
throw IOException("Failed to create directory: ${outputFile.absolutePath}")
|
||||
}
|
||||
} else {
|
||||
outputFile.parentFile?.let {
|
||||
if (!it.exists() && !it.mkdirs()) {
|
||||
throw IOException("Failed to create parent directory: ${it.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
zip.getInputStream(entry).use { input ->
|
||||
outputFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (unzipLocationRoot != null && unzipLocationRoot.exists()) {
|
||||
unzipLocationRoot.deleteRecursively()
|
||||
}
|
||||
|
||||
when (e) {
|
||||
is SecurityException -> throw e
|
||||
else -> throw IOException("Failed to extract ZIP file: ${e.localizedMessage}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new keystore file with a self-signed certificate for APK signing
|
||||
*
|
||||
* @param out The output keystore file
|
||||
* @throws Exception If keystore creation fails
|
||||
*/
|
||||
@SuppressLint("NewApi")
|
||||
fun newKeystore(out: File) {
|
||||
try {
|
||||
val key = createKey()
|
||||
|
||||
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(null, "password".toCharArray())
|
||||
setKeyEntry(
|
||||
"alias",
|
||||
key.privateKey,
|
||||
"password".toCharArray(),
|
||||
arrayOf<Certificate>(key.publicKey)
|
||||
)
|
||||
store(out.outputStream(), "password".toCharArray())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (out.exists()) out.delete()
|
||||
throw IOException("Failed to create keystore: ${e.localizedMessage}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a key pair for signing APKs
|
||||
*/
|
||||
private fun createKey(): KeySet {
|
||||
try {
|
||||
var serialNumber: BigInteger
|
||||
|
||||
do serialNumber = SecureRandom().nextInt().toBigInteger()
|
||||
while (serialNumber < BigInteger.ZERO)
|
||||
|
||||
val x500Name = X500Name("CN=GrindrPlus")
|
||||
val pair = KeyPairGenerator.getInstance("RSA").run {
|
||||
initialize(2048)
|
||||
generateKeyPair()
|
||||
}
|
||||
|
||||
// Valid for 30 years
|
||||
val notBefore = Date(System.currentTimeMillis() - 1000L * 60L * 60L * 24L * 30L)
|
||||
val notAfter = Date(System.currentTimeMillis() + 1000L * 60L * 60L * 24L * 366L * 30L)
|
||||
|
||||
val builder = X509v3CertificateBuilder(
|
||||
x500Name,
|
||||
serialNumber,
|
||||
notBefore,
|
||||
notAfter,
|
||||
Locale.ENGLISH,
|
||||
x500Name,
|
||||
SubjectPublicKeyInfo.getInstance(pair.public.encoded)
|
||||
)
|
||||
|
||||
val signer = JcaContentSignerBuilder("SHA256withRSA").build(pair.private)
|
||||
|
||||
return KeySet(
|
||||
JcaX509CertificateConverter().getCertificate(builder.build(signer)),
|
||||
pair.private
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw IOException("Failed to create signing key: ${e.localizedMessage}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class to hold a key pair for APK signing
|
||||
*/
|
||||
class KeySet(val publicKey: X509Certificate, val privateKey: PrivateKey)
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.grindrplus.manager.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
object StorageUtils {
|
||||
private const val TAG = "StorageUtils"
|
||||
|
||||
fun getAvailableSpace(path: File): Long {
|
||||
return try {
|
||||
if (!path.exists()) {
|
||||
path.mkdirs()
|
||||
}
|
||||
|
||||
val stat = StatFs(path.path)
|
||||
val blockSize = stat.blockSizeLong
|
||||
val availableBlocks = stat.availableBlocksLong
|
||||
blockSize * availableBlocks
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking available space", e)
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanupOldInstallationFiles(
|
||||
context: Context,
|
||||
keepLatestVersion: Boolean = true,
|
||||
latestVersion: String? = null
|
||||
) {
|
||||
try {
|
||||
val folder = context.getExternalFilesDir(null) ?: return
|
||||
val threeDaysAgo = System.currentTimeMillis() - (3 * 24 * 60 * 60 * 1000)
|
||||
|
||||
val splitApksDir = File(folder, "splitApks/")
|
||||
if (splitApksDir.exists() && splitApksDir.isDirectory) {
|
||||
if (splitApksDir.lastModified() < threeDaysAgo) {
|
||||
splitApksDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
val outputDir = File(folder, "LSPatchOutput/")
|
||||
if (outputDir.exists() && outputDir.isDirectory) {
|
||||
if (outputDir.lastModified() < threeDaysAgo) {
|
||||
outputDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
folder.listFiles()?.forEach { file ->
|
||||
if (file.name.startsWith("grindr-") && file.name.endsWith(".xapk")) {
|
||||
val version = file.name.removePrefix("grindr-").removeSuffix(".xapk")
|
||||
if (!keepLatestVersion || version != latestVersion) {
|
||||
if (file.lastModified() < threeDaysAgo) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
folder.listFiles()?.forEach { file ->
|
||||
if (file.name.startsWith("mod-") && file.name.endsWith(".zip")) {
|
||||
val version = file.name.removePrefix("mod-").removeSuffix(".zip")
|
||||
if (!keepLatestVersion || version != latestVersion) {
|
||||
if (file.lastModified() < threeDaysAgo) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during cleanup", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ import kotlin.reflect.KClass
|
||||
class HookManager {
|
||||
private var hooks = mutableMapOf<KClass<out Hook>, Hook>()
|
||||
|
||||
private fun registerAndInitHooks() {
|
||||
public fun registerHooks(init: Boolean = true) {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
val hookList = listOf(
|
||||
FeatureGranting(),
|
||||
@@ -73,6 +73,8 @@ class HookManager {
|
||||
)
|
||||
}
|
||||
|
||||
if (!init) return@runBlocking
|
||||
|
||||
hooks = hookList.associateBy { it::class }.toMutableMap()
|
||||
|
||||
hooks.values.forEach { hook ->
|
||||
@@ -90,12 +92,12 @@ class HookManager {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
hooks.values.forEach { hook -> hook.cleanup() }
|
||||
hooks.clear()
|
||||
registerAndInitHooks()
|
||||
registerHooks()
|
||||
GrindrPlus.logger.log("Hooks reloaded successfully.")
|
||||
}
|
||||
}
|
||||
|
||||
fun init() {
|
||||
registerAndInitHooks()
|
||||
registerHooks()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC+zCCAeOgAwIBAgIUI88YGOT3VIUcZg8mEvp17o5Y+2IwDQYJKoZIhvcNAQEL
|
||||
BQAwDTELMAkGA1UEBhMCUlMwHhcNMjUwMzI3MTMxMTExWhcNMzUwMzI1MTMxMTEx
|
||||
WjANMQswCQYDVQQGEwJSUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
ALLZph5Oq/+HpWFSEdfTVPqDoRkDdeR7KPK/e3yZ4wsvAIvfzbLuYQLZDZk6iKV9
|
||||
+E38RDyagFyyG073lEFKa+3GAkaPd083APf9RuEgSmt+ZnC7Z87uq9FxBlVwNEQx
|
||||
iLwqWCrkyqe3QDVXzLJzRDzsNuXJ+R9ezhg0Do3XyGW0lU+feh4N01xYyS9arA4/
|
||||
hnOmPrX5RW+hk6CUlLFjmIvIkqxOtOVs60Omu8PcHlxEORXt4AL1HCFxbVIz5LY+
|
||||
voaR4j3ID3KRbCRkA9YTKXxaG9YmMibov4s8gPqBCpfjCexkE670q+qw0aNpYsQp
|
||||
14b4L3Lq2EtEtvWye+LSKBsCAwEAAaNTMFEwHQYDVR0OBBYEFPKU4U0LTy3m6fMn
|
||||
s3rXFTBBvqEsMB8GA1UdIwQYMBaAFPKU4U0LTy3m6fMns3rXFTBBvqEsMA8GA1Ud
|
||||
EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBABFtw5SMXd5SdhtXKP0yetXq
|
||||
bPh7KLwuDC/sWnfn3sWZHAJAhU7/nsHTKB1BzlL2gpHhb3BAk03uMASTPXjnevZf
|
||||
tkafAAeUpUTiJOHt+dcSFgUZpW/qXMGk5qLGFTgLEA/lyO+XEuboZ2A61kqZmE+j
|
||||
tMr4AMQ9y6Mg0tGqC8TE2e6bgVmb76Yxd5dZu/uOBrmBW6tolJaDZIJo7zLvR1oW
|
||||
XN9KsxKJl++1gVDjEK2yEaceVv/HtXUzuwmLy8LSLRSxG3qyZYV/EVJPUUicxEYu
|
||||
K1qXXfGoqRAoEyWAKFXO7uCyNZqnEOKdzARFy6hs7sEsPqjQ+fYcfu40JK9CYcM=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -3,4 +3,5 @@ plugins {
|
||||
alias(libs.plugins.androidApplication) apply false
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid) apply false
|
||||
alias(libs.plugins.googleKsp) apply false
|
||||
alias(libs.plugins.compose.compiler) apply false
|
||||
}
|
||||
@@ -10,6 +10,7 @@ room = "2.6.1"
|
||||
appcompat = "1.7.0"
|
||||
coordinatorlayout = "1.2.0"
|
||||
material = "1.12.0"
|
||||
runtimeAndroid = "1.7.8"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -22,9 +23,10 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref =
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
androidx-coordinatorlayout = { group = "androidx.coordinatorlayout", name = "coordinatorlayout", version.ref = "coordinatorlayout" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
androidx-runtime-android = { group = "androidx.compose.runtime", name = "runtime-android", version.ref = "runtimeAndroid" }
|
||||
|
||||
[plugins]
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
googleKsp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user