diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3fddd21..ff75e0c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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(" ")
diff --git a/app/libs/lspatch.jar b/app/libs/lspatch.jar
new file mode 100644
index 0000000..746d51f
Binary files /dev/null and b/app/libs/lspatch.jar differ
diff --git a/app/lspatch.zip b/app/lspatch.zip
new file mode 100644
index 0000000..3e25ca3
Binary files /dev/null and b/app/lspatch.zip differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 641a1ad..eeed09b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,39 +1,51 @@
+ xmlns:tools="http://schemas.android.com/tools">
-
+
+
+ android:allowBackup="true"
+ android:icon="@mipmap/ic_launcher"
+ android:roundIcon="@mipmap/ic_launcher_round"
+ android:label="@string/app_name"
+ android:supportsRtl="true">
+ android:name="xposedmodule"
+ android:value="true" />
+ android:name="xposeddescription"
+ android:value="Xposed Module to enhance Grindr" />
+ android:name="xposedminversion"
+ android:value="93" />
+ android:name="xposedscope"
+ android:value="com.grindrapp.android" />
+ android:name=".bridge.BridgeService"
+ android:exported="true"
+ tools:ignore="ExportedService">
+
+
+
+
+
+
+
+
diff --git a/app/src/main/aidl/com/grindrplus/bridge/IBridgeService.aidl b/app/src/main/aidl/com/grindrplus/bridge/IBridgeService.aidl
index dac4dbe..8476296 100644
--- a/app/src/main/aidl/com/grindrplus/bridge/IBridgeService.aidl
+++ b/app/src/main/aidl/com/grindrplus/bridge/IBridgeService.aidl
@@ -3,4 +3,6 @@ package com.grindrplus.bridge;
interface IBridgeService {
String getTranslation(String locale);
List getAvailableTranslations();
+ String getConfig();
+ void setConfig(String config);
}
\ No newline at end of file
diff --git a/app/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so b/app/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so
new file mode 100644
index 0000000..55da03f
Binary files /dev/null and b/app/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so differ
diff --git a/app/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so b/app/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so
new file mode 100644
index 0000000..af6425c
Binary files /dev/null and b/app/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so differ
diff --git a/app/src/main/assets/lspatch/so/x86/liblspatch.so b/app/src/main/assets/lspatch/so/x86/liblspatch.so
new file mode 100644
index 0000000..57d7e7e
Binary files /dev/null and b/app/src/main/assets/lspatch/so/x86/liblspatch.so differ
diff --git a/app/src/main/assets/lspatch/so/x86_64/liblspatch.so b/app/src/main/assets/lspatch/so/x86_64/liblspatch.so
new file mode 100644
index 0000000..fd35c1f
Binary files /dev/null and b/app/src/main/assets/lspatch/so/x86_64/liblspatch.so differ
diff --git a/app/src/main/java/com/grindrplus/GrindrPlus.kt b/app/src/main/java/com/grindrplus/GrindrPlus.kt
index 1af4741..6574ef9 100644
--- a/app/src/main/java/com/grindrplus/GrindrPlus.kt
+++ b/app/src/main/java/com/grindrplus/GrindrPlus.kt
@@ -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) {
diff --git a/app/src/main/java/com/grindrplus/bridge/BridgeClient.kt b/app/src/main/java/com/grindrplus/bridge/BridgeClient.kt
index e443bec..3a16bc9 100644
--- a/app/src/main/java/com/grindrplus/bridge/BridgeClient.kt
+++ b/app/src/main/java/com/grindrplus/bridge/BridgeClient.kt
@@ -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 {
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))
+ }
+
}
diff --git a/app/src/main/java/com/grindrplus/bridge/BridgeService.kt b/app/src/main/java/com/grindrplus/bridge/BridgeService.kt
index 88f1332..a2e74e1 100644
--- a/app/src/main/java/com/grindrplus/bridge/BridgeService.kt
+++ b/app/src/main/java/com/grindrplus/bridge/BridgeService.kt
@@ -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)
+ }
+ }
}
}
diff --git a/app/src/main/java/com/grindrplus/core/Config.kt b/app/src/main/java/com/grindrplus/core/Config.kt
index 159aeec..9b13f58 100644
--- a/app/src/main/java/com/grindrplus/core/Config.kt
+++ b/app/src/main/java/com/grindrplus/core/Config.kt
@@ -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> {
- val hooks = config.optJSONObject("hooks") ?: return emptyMap()
+ val hooks = readRemoteConfig().optJSONObject("hooks") ?: return emptyMap()
val map = mutableMapOf>()
val keys = hooks.keys()
diff --git a/app/src/main/java/com/grindrplus/hooks/ModSettings.kt b/app/src/main/java/com/grindrplus/hooks/ModSettings.kt
index 04582ee..0f1651f 100644
--- a/app/src/main/java/com/grindrplus/hooks/ModSettings.kt
+++ b/app/src/main/java/com/grindrplus/hooks/ModSettings.kt
@@ -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)
}
}
diff --git a/app/src/main/java/com/grindrplus/manager/Installation.kt b/app/src/main/java/com/grindrplus/manager/Installation.kt
new file mode 100644
index 0000000..8f18d8c
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/Installation.kt
@@ -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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/MainActivity.kt b/app/src/main/java/com/grindrplus/manager/MainActivity.kt
new file mode 100644
index 0000000..81d8f97
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/MainActivity.kt
@@ -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))
+ }
+}
+
diff --git a/app/src/main/java/com/grindrplus/manager/settings/SettingTypes.kt b/app/src/main/java/com/grindrplus/manager/settings/SettingTypes.kt
new file mode 100644
index 0000000..fa930af
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/settings/SettingTypes.kt
@@ -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,
+)
+
+// Keyboard type enum
+enum class KeyboardType {
+ Text, Number, Email, Password, Phone
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/settings/SettingsViewModel.kt b/app/src/main/java/com/grindrplus/manager/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..7e05ffd
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/settings/SettingsViewModel.kt
@@ -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>(emptyList())
+ val settingGroups: StateFlow> = _settingGroups
+
+ private val _isLoading = MutableStateFlow(true)
+ val isLoading: StateFlow = _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(
+ 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 create(modelClass: Class): 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)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/ui/HomeScreen.kt b/app/src/main/java/com/grindrplus/manager/ui/HomeScreen.kt
new file mode 100644
index 0000000..acafdc4
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/ui/HomeScreen.kt
@@ -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() }
+ var releases = remember { mutableStateMapOf() }
+ var isLoading by remember { mutableStateOf(true) }
+ var errorMessage by remember { mutableStateOf(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()
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/ui/InstallScreen.kt b/app/src/main/java/com/grindrplus/manager/ui/InstallScreen.kt
new file mode 100644
index 0000000..03f4881
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/ui/InstallScreen.kt
@@ -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()
+private var progress by mutableFloatStateOf(0f)
+
+@Composable
+fun InstallPage(context: Activity, innerPadding: PaddingValues) {
+ var isLoading by remember { mutableStateOf(true) }
+ var errorMessage by remember { mutableStateOf(null) }
+ val versionData = remember { mutableStateListOf() }
+ var selectedVersion by remember { mutableStateOf(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,
+ 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) -> 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 {
+ try {
+ val result = mutableListOf()
+ 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,
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/ui/SettingsScreen.kt b/app/src/main/java/com/grindrplus/manager/ui/SettingsScreen.kt
new file mode 100644
index 0000000..d4d805a
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/ui/SettingsScreen.kt
@@ -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,
+ 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(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")
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/ui/theme/Theme.kt b/app/src/main/java/com/grindrplus/manager/ui/theme/Theme.kt
new file mode 100644
index 0000000..df32468
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/ui/theme/Theme.kt
@@ -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
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/ui/theme/Typography.kt b/app/src/main/java/com/grindrplus/manager/ui/theme/Typography.kt
new file mode 100644
index 0000000..d53ca2c
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/ui/theme/Typography.kt
@@ -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
+ )
+)
diff --git a/app/src/main/java/com/grindrplus/manager/utils/ConsoleLogger.kt b/app/src/main/java/com/grindrplus/manager/utils/ConsoleLogger.kt
new file mode 100644
index 0000000..73a799b
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/utils/ConsoleLogger.kt
@@ -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,
+ 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
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/utils/ErrorHandling.kt b/app/src/main/java/com/grindrplus/manager/utils/ErrorHandling.kt
new file mode 100644
index 0000000..751d922
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/utils/ErrorHandling.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/utils/ManagerUtils.kt b/app/src/main/java/com/grindrplus/manager/utils/ManagerUtils.kt
new file mode 100644
index 0000000..b3f0cd1
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/utils/ManagerUtils.kt
@@ -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,
+ 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,
+ 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, authType: String) {
+ try {
+ defaultTrustManager.checkClientTrusted(chain, authType)
+ } catch (_: Exception) {
+ customTrustManager.checkClientTrusted(chain, authType)
+ }
+ }
+
+ override fun checkServerTrusted(chain: Array, authType: String) {
+ try {
+ defaultTrustManager.checkServerTrusted(chain, authType)
+ } catch (_: Exception) {
+ customTrustManager.checkServerTrusted(chain, authType)
+ }
+ }
+
+ override fun getAcceptedIssuers(): Array =
+ 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(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)
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/manager/utils/StorageUtils.kt b/app/src/main/java/com/grindrplus/manager/utils/StorageUtils.kt
new file mode 100644
index 0000000..490d398
--- /dev/null
+++ b/app/src/main/java/com/grindrplus/manager/utils/StorageUtils.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/grindrplus/ui/fragments/SettingsFragment.kt b/app/src/main/java/com/grindrplus/ui/fragments/SettingsFragment.kt
deleted file mode 100644
index 9430e05..0000000
--- a/app/src/main/java/com/grindrplus/ui/fragments/SettingsFragment.kt
+++ /dev/null
@@ -1,1144 +0,0 @@
-package com.grindrplus.ui.fragments
-
-import Database
-import android.annotation.SuppressLint
-import android.app.Activity
-import android.app.AlertDialog
-import android.content.Context
-import android.content.Intent
-import android.graphics.Color
-import android.graphics.PorterDuff
-import android.net.Uri
-import android.os.Build
-import android.os.Bundle
-import android.provider.DocumentsContract
-import android.text.InputType
-import android.util.TypedValue
-import android.view.*
-import android.view.inputmethod.EditorInfo
-import android.view.inputmethod.InputMethodManager
-import android.widget.Button
-import android.widget.EditText
-import android.widget.FrameLayout
-import android.widget.ImageView
-import android.widget.LinearLayout
-import android.widget.PopupMenu
-import android.widget.ScrollView
-import android.widget.Switch
-import android.widget.TextView
-import android.widget.Toast
-import androidx.activity.result.ActivityResultLauncher
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.widget.AppCompatTextView
-import androidx.coordinatorlayout.widget.CoordinatorLayout
-import androidx.fragment.app.Fragment
-import com.grindrplus.GrindrPlus
-import com.grindrplus.core.Config
-import com.grindrplus.core.Utils.getSystemInfo
-import com.grindrplus.ui.Utils
-import com.grindrplus.ui.colors.Colors
-import java.io.File
-import java.time.format.DateTimeFormatter
-import kotlin.system.exitProcess
-
-enum class FileType {
- CONFIG,
- DATABASE,
- LOGS,
- BLOCK_LIST,
- FAVORITES_LIST
-}
-
-class SettingsFragment : Fragment() {
- private var fileType: FileType = FileType.CONFIG
- private lateinit var importLauncher: ActivityResultLauncher
- private lateinit var exportLauncher: ActivityResultLauncher
- private lateinit var subLinearLayout: LinearLayout
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- exportLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == Activity.RESULT_OK) {
- result.data?.data?.also { uri ->
- when (fileType) {
- FileType.CONFIG -> exportConfigToUri(uri)
- FileType.DATABASE -> exportDatabaseToUri(uri)
- FileType.LOGS -> exportLogsToUri(uri)
- FileType.BLOCK_LIST -> exportFileToUri(uri, "blocks.txt")
- FileType.FAVORITES_LIST -> exportFileToUri(uri, "favorites.txt")
- }
- }
- }
- }
-
- importLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == Activity.RESULT_OK) {
- result.data?.data?.also { uri ->
- when (fileType) {
- FileType.CONFIG -> importConfigFromUri(uri)
- FileType.DATABASE -> importDatabaseFromUri(uri)
- FileType.LOGS -> return@also // Do nothing
- FileType.BLOCK_LIST -> importFileFromUri(uri, "blocks_to_import.txt", "blocks")
- FileType.FAVORITES_LIST -> importFileFromUri(uri, "favorites_to_import.txt", "favorites")
- }
- }
- }
- }
-
- }
-
- override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
- ): View {
- val context = requireContext()
- Config.initialize(context)
-
- val rootLayout = CoordinatorLayout(context).apply {
- layoutParams = ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
- )
- setBackgroundColor(Colors.grindr_dark_amoled_black)
- id = Utils.getId("activity_content", "id", context)
- }
-
- val customToolbar = createCustomToolbarWithMenu(context)
- rootLayout.addView(customToolbar)
-
- val fragmentContainer = FrameLayout(context).apply {
- layoutParams = FrameLayout.LayoutParams(
- FrameLayout.LayoutParams.MATCH_PARENT,
- FrameLayout.LayoutParams.MATCH_PARENT
- )
- id = Utils.getId("activity_fragment_container", "id", context)
- }
-
- val scrollView = ScrollView(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.MATCH_PARENT
- ).also {
- it.topMargin = getActionBarSize(context)
- }
- }
-
- val linearLayout = LinearLayout(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- )
- setBackgroundColor(Colors.grindr_dark_amoled_black)
- dividerDrawable = Utils.getDrawable("settings_divider", context)
- orientation = LinearLayout.VERTICAL
- showDividers = LinearLayout.SHOW_DIVIDER_MIDDLE
- }
-
- subLinearLayout = LinearLayout(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- )
- setPadding(50, 10, 50, 10)
- orientation = LinearLayout.VERTICAL
- }
-
- linearLayout.addView(subLinearLayout)
- scrollView.addView(linearLayout)
- fragmentContainer.addView(scrollView)
- rootLayout.addView(fragmentContainer)
-
- updateUIFromConfig()
-
- return rootLayout
- }
-
- private fun importDatabaseFromUri(uri: Uri) {
- val context = requireContext()
- val backupPath = File(context.cacheDir, "grindrplus_backup.db").absolutePath
- val databasePath = context.filesDir.absolutePath + "/grindrplus.db"
-
- try {
- context.contentResolver.openInputStream(uri)?.use { inputStream ->
- val backupFile = File(backupPath)
- inputStream.copyTo(backupFile.outputStream())
-
- val database = Database(context, databasePath)
- val restored = database.restoreDatabase(backupPath)
-
- if (restored) {
- GrindrPlus.showToast(Toast.LENGTH_LONG, "Database imported successfully!", context)
- showImportSuccessDialog()
- } else {
- GrindrPlus.showToast(Toast.LENGTH_LONG, "Failed to restore database!", context)
- }
- }
- } catch (e: Exception) {
- e.printStackTrace()
- GrindrPlus.showToast(Toast.LENGTH_LONG, "Failed to import database!", context)
- }
- }
-
- private fun importConfigFromUri(uri: Uri) {
- val context = requireContext()
- try {
- context.contentResolver.openInputStream(uri)?.use { inputStream ->
- val configJson = inputStream.bufferedReader().use { it.readText() }
- Config.importFromJson(configJson)
- updateUIFromConfig()
- Toast.makeText(context, "Config imported successfully!", Toast.LENGTH_LONG).show()
- }
- } catch (e: Exception) {
- e.printStackTrace()
- Toast.makeText(context, "Failed to import config!", Toast.LENGTH_LONG).show()
- }
- }
-
- private fun importFileFromUri(uri: Uri, name: String, type: String) {
- val context = requireContext()
- try {
- context.contentResolver.openInputStream(uri)?.use { inputStream ->
- val blocksFile = File(context.filesDir, name)
- inputStream.copyTo(blocksFile.outputStream())
- AlertDialog.Builder(context)
- .setTitle("File Import")
- .setMessage("The $type list will be imported on the next app restart. Do you want to proceed?")
- .setPositiveButton("OK") { _, _ ->
- closeApp()
- }
- .setNegativeButton("Cancel") { _, _ ->
- blocksFile.delete()
- }
- .setCancelable(false)
- .show()
- }
- } catch (e: Exception) {
- e.printStackTrace()
- Toast.makeText(context, "Failed to import $name!", Toast.LENGTH_LONG).show()
- }
- }
-
- private fun showImportSuccessDialog() {
- val context = requireContext()
- AlertDialog.Builder(context)
- .setTitle("Database Import")
- .setMessage("The database has been successfully imported. The app will now close to apply the changes.")
- .setPositiveButton("OK") { _, _ ->
- closeApp()
- }
- .setCancelable(false)
- .show()
- }
-
- private fun promptImportSelection(fType: FileType) {
- fileType = fType
- val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- if (fileType == FileType.DATABASE) {
- type = "*/*"
- putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/octet-stream", "application/x-sqlite3", "application/vnd.sqlite3", "application/db", "*/*"))
- } else if (fileType == FileType.CONFIG) {
- type = "application/json"
- } else if (fileType == FileType.BLOCK_LIST || fileType == FileType.FAVORITES_LIST) {
- type = "text/plain"
- }
- }
- importLauncher.launch(intent)
- }
-
- private fun promptFolderSelection(fType: FileType) {
- fileType = fType
- val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
- exportLauncher.launch(intent)
- }
-
- private fun exportDatabaseToUri(uri: Uri) {
- val context = requireContext()
- val databasePath = "${context.filesDir.absolutePath}/grindrplus.db"
- val databaseFile = File(databasePath)
-
- try {
- val childUri = DocumentsContract.buildDocumentUriUsingTree(
- uri,
- DocumentsContract.getTreeDocumentId(uri)
- )
-
- val newFileUri = DocumentsContract.createDocument(
- context.contentResolver,
- childUri,
- "application/x-sqlite3",
- "grindrplus_backup.db"
- )
-
- if (newFileUri != null) {
- context.contentResolver.openOutputStream(newFileUri)?.use { outputStream ->
- databaseFile.inputStream().use { inputStream ->
- inputStream.copyTo(outputStream)
- }
- GrindrPlus.showToast(
- Toast.LENGTH_LONG,
- "Database exported successfully!",
- context
- )
- }
- } else {
- GrindrPlus.showToast(
- Toast.LENGTH_LONG,
- "Failed to create file in the selected folder!",
- context
- )
- }
- } catch (e: Exception) {
- e.printStackTrace()
- GrindrPlus.showToast(
- Toast.LENGTH_LONG,
- "Failed to export database!",
- context
- )
- }
- }
-
- private fun exportLogsToUri(uri: Uri) {
- val context = requireContext()
- val fileName = "grindrplus_logs.txt"
- val mimeType = "text/plain"
- val logFile = File(context.filesDir, "grindrplus.log")
-
- val info = getSystemInfo(context)
- val logContent = logFile.readText()
- val activeHooks = buildString {
- Config.getHooksSettings().forEach { (hookName, pair) ->
- appendLine("$hookName: ${if (pair.second) "Enabled" else "Disabled"}")
- }
- appendLine("========================================")
- }
- val databases = buildString {
- val dbFiles = context.databaseList()
- if (dbFiles.isNotEmpty()) {
- dbFiles.forEach { dbFile -> appendLine(dbFile) }
- appendLine("========================================")
- }
- }
-
- try {
- val childUri = DocumentsContract.buildDocumentUriUsingTree(
- uri,
- DocumentsContract.getTreeDocumentId(uri)
- )
-
- val newFileUri = DocumentsContract.createDocument(
- context.contentResolver,
- childUri,
- mimeType,
- fileName
- )
-
- if (newFileUri != null) {
- context.contentResolver.openOutputStream(newFileUri)?.use { outputStream ->
- outputStream.write(info.toByteArray())
- outputStream.write(activeHooks.toByteArray())
- outputStream.write(databases.toByteArray())
- outputStream.write(logContent.toByteArray())
- outputStream.flush()
- }
- Toast.makeText(context, "Logs exported successfully!", Toast.LENGTH_LONG).show()
- } else {
- Toast.makeText(context, "Failed to create file in the selected folder!", Toast.LENGTH_LONG).show()
- }
- } catch (e: Exception) {
- e.printStackTrace()
- Toast.makeText(context, "Failed to export logs!", Toast.LENGTH_LONG).show()
- }
- }
-
- private fun exportConfigToUri(uri: Uri) {
- val context = requireContext()
- val fileName = "grindrplus_config.json"
- val mimeType = "application/json"
- val configJson = Config.getConfigJson()
-
- try {
- val childUri = DocumentsContract.buildDocumentUriUsingTree(
- uri,
- DocumentsContract.getTreeDocumentId(uri)
- )
-
- val newFileUri = DocumentsContract.createDocument(
- context.contentResolver,
- childUri,
- mimeType,
- fileName
- )
-
- if (newFileUri != null) {
- context.contentResolver.openOutputStream(newFileUri)?.use { outputStream ->
- outputStream.write(configJson.toByteArray())
- outputStream.flush()
- }
- Toast.makeText(context, "Config exported successfully!", Toast.LENGTH_LONG).show()
- } else {
- Toast.makeText(context, "Failed to create file in the selected folder!", Toast.LENGTH_LONG).show()
- }
- } catch (e: Exception) {
- e.printStackTrace()
- Toast.makeText(context, "Failed to export config!", Toast.LENGTH_LONG).show()
- }
- }
-
- fun exportFileToUri(uri: Uri, name: String) {
- val file = File(requireContext().filesDir, name)
-
- if (file.exists()) {
- try {
- val childUri = DocumentsContract.buildDocumentUriUsingTree(
- uri,
- DocumentsContract.getTreeDocumentId(uri)
- )
-
- val newFileUri = DocumentsContract.createDocument(
- requireContext().contentResolver,
- childUri,
- "text/plain",
- name
- )
-
- if (newFileUri != null) {
- requireContext().contentResolver.openOutputStream(newFileUri)?.use { outputStream ->
- file.inputStream().use { inputStream ->
- inputStream.copyTo(outputStream)
- }
- file.delete()
- Toast.makeText(context, "$name exported successfully!", Toast.LENGTH_LONG).show()
- }
- } else {
- Toast.makeText(context, "Failed to create file in the selected folder!", Toast.LENGTH_LONG).show()
- }
- } catch (e: Exception) {
- e.printStackTrace()
- Toast.makeText(context, "Failed to export $name!", Toast.LENGTH_LONG).show()
- }
- } else {
- Toast.makeText(context, "No $name found!", Toast.LENGTH_LONG).show()
- }
- }
-
- private fun updateUIFromConfig() {
- subLinearLayout.removeAllViews()
- addViewsToContainer(subLinearLayout)
- }
-
- private fun addViewsToContainer(container: LinearLayout?) {
- val context = requireContext()
-
- val manageHooksTitle = AppCompatTextView(context).apply {
- setTextAppearance(Utils.getId("TextAppearanceH6AllCaps", "styles", context))
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).also { params ->
- params.topMargin = 44
- params.bottomMargin = 49
- }
- typeface = Utils.getFont("ibm_plex_sans_medium", context)
- text = "Manage Hooks"
- isAllCaps = true
- setTextColor(Colors.text_secondary_dark_bg)
- }
- container?.addView(manageHooksTitle)
-
- val hooks = Config.getHooksSettings()
- hooks.forEach { (hookName, pair) ->
- if (hookName != "Mod settings" && hookName != "Persistent incognito" && hookName != "Unlimited albums") {
- val hookView = createHookSwitch(context, hookName, pair.second, pair.first)
- container?.addView(hookView)
- }
- }
-
- val otherSettingsTitle = AppCompatTextView(context).apply {
- setTextAppearance(Utils.getId("TextAppearanceH6AllCaps", "styles", context))
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).also { params ->
- params.topMargin = 44
- params.bottomMargin = 49
- }
- typeface = Utils.getFont("ibm_plex_sans_medium", context)
- text = "Other Settings"
- isAllCaps = true
- setTextColor(Colors.text_secondary_dark_bg)
- }
- container?.addView(otherSettingsTitle)
-
- container?.addView(
- createDynamicSettingView(
- context,
- title = "Command Prefix",
- description = "Change the command prefix (default: /)",
- key = "command_prefix",
- defaultValue = "/",
- validation = { 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
- }
- }
- )
- )
- container?.addView(
- createDynamicSettingView(
- context,
- title = "Online indicator duration (mins)",
- description = "Control when the green dot disappears after inactivity",
- key = "online_indicator",
- defaultValue = 5,
- inputType = InputType.TYPE_CLASS_NUMBER,
- validation = { input ->
- val value = input.toIntOrNull()
- if (value == null || value <= 0) "Duration must be a positive number" else null
- }
- )
- )
- container?.addView(
- createDynamicSettingView(
- context,
- title = "Favorites grid size",
- description = "Set the number of columns in the favorites grid",
- key = "favorites_grid_columns",
- defaultValue = 3,
- inputType = InputType.TYPE_CLASS_NUMBER,
- validation = { input ->
- val value = input.toIntOrNull()
- if (value == null || value <= 0) "Grid size must be a positive number" else null
- }
- )
- )
-
- container?.addView(
- createDynamicSettingView(
- context,
- title = "Date format",
- description = "Set the date format used to show the estimation account creation date",
- key = "date_format",
- defaultValue = "yyyy-MM-dd",
- validation = { input ->
- return@createDynamicSettingView try {
- DateTimeFormatter.ofPattern(input)
- null
- } catch (e: IllegalArgumentException) {
- "Invalid date format: ${e.message}"
- }
- }
- )
- )
-
- container?.addView(createToggleableSettingView(context, "Force old AntiBlock behavior", "Use the old AntiBlock behavior (don't use this, required for testing)", "force_old_anti_block_behavior"))
- container?.addView(createToggleableSettingView(context, "Use toasts for AntiBlock hook", "Instead of receiving Android notifications, use toasts for block/unblock notifications", "anti_block_use_toasts"))
- container?.addView(createToggleableSettingView(context, "Keep separated favorites section", "Keep the favorites section separated from the main cascade", "separated_favorites_section", true))
- container?.addView(createToggleableSettingView(context, "Enable interest section", "Enable the interest section in the bottom bar", "enable_interest_section", true))
- container?.addView(createToggleableSettingView(context, "Disable profile swipe", "Disable the swipe gesture on profiles", "disable_profile_swipe"))
-
- val experimentalFeaturesTitle = AppCompatTextView(context).apply {
- setTextAppearance(Utils.getId("TextAppearanceH6AllCaps", "styles", context))
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).also {
- it.topMargin = 44
- it.bottomMargin = 49
- }
- typeface = Utils.getFont("ibm_plex_sans_medium", context)
- text = "Experimental Features"
- isAllCaps = true
- setTextColor(Colors.text_secondary_dark_bg)
- }
- container?.addView(experimentalFeaturesTitle)
-
- container?.addView(
- createDynamicSettingView(
- context,
- title = "Import Blocks Threshold",
- description = "Set the time to wait between each block import (in milliseconds)",
- key = "block_import_threshold",
- defaultValue = 500,
- inputType = InputType.TYPE_CLASS_NUMBER,
- validation = { input ->
- val value = input.toIntOrNull()
- if (value == null || value <= 0) "Threshold must be a positive number" else null
- }
- )
- )
-
- container?.addView(
- createDynamicSettingView(
- context,
- title = "Import Favorites Threshold",
- description = "Set the time to wait between each favorites import (in milliseconds)",
- key = "favorites_import_threshold",
- defaultValue = 500,
- inputType = InputType.TYPE_CLASS_NUMBER,
- validation = { input ->
- val value = input.toIntOrNull()
- if (value == null || value <= 0) "Threshold must be a positive number" else null
- }
- )
- )
-
- container?.addView(createExperimentalFeatureOption(context, "Manage Blocks"))
- container?.addView(createExperimentalFeatureOption(context, "Manage Favorites"))
- }
-
- private fun createExperimentalFeatureOption(context: Context, title: String): View {
- return LinearLayout(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).also { params ->
- params.topMargin = 20
- }
- orientation = LinearLayout.HORIZONTAL
- gravity = Gravity.CENTER_VERTICAL
-
- val textView = AppCompatTextView(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- 0,
- LinearLayout.LayoutParams.WRAP_CONTENT,
- 1f
- )
- text = title
- textSize = 16f
- setTextColor(Colors.text_primary_dark_bg)
- typeface = Utils.getFont("ibm_plex_sans_medium", context)
- }
-
- val button = Button(context).apply {
- text = "Open"
- setOnClickListener {
- showExperimentalFeatureDialog(context, title)
- }
- }
-
- addView(textView)
- addView(button)
- }
- }
-
- private fun showExperimentalFeatureDialog(context: Context, featureName: String) {
- val dialogBuilder = AlertDialog.Builder(context).apply {
- setTitle(featureName)
- setMessage(
- "$featureName is an experimental feature. It may result in account bans." +
- " Use it at your own risk. The developers take no responsibility for any consequences."
- )
-
- setPositiveButton("Export") { _, _ ->
- when (featureName) {
- "Manage Blocks" -> {
- val blocksFile = File(context.filesDir, "blocks.txt")
- if (!blocksFile.exists()) {
- Toast.makeText(
- context,
- "Use /blocks to populate the block list first.",
- Toast.LENGTH_SHORT
- ).show()
- return@setPositiveButton
- }
- promptFolderSelection(FileType.BLOCK_LIST)
- }
- "Manage Favorites" -> {
- val favoritesFile = File(context.filesDir, "favorites.txt")
- if (!favoritesFile.exists()) {
- Toast.makeText(
- context,
- "Use /favorites to populate the favorites list first.",
- Toast.LENGTH_SHORT
- ).show()
- return@setPositiveButton
- }
- promptFolderSelection(FileType.FAVORITES_LIST)
- }
- }
- }
-
- setNegativeButton("Import") { _, _ ->
- when (featureName) {
- "Manage Blocks" -> {
- val pendingFavoritesFile = File(context.filesDir, "favorites_to_import.txt")
- if (pendingFavoritesFile.exists()) {
- AlertDialog.Builder(context).apply {
- setTitle("Import Blocked")
- setMessage(
- "GrindrPlus has detected a pending favorites import. " +
- "Please import the favorites list first before importing blocks."
- )
- setPositiveButton("OK", null)
- }.create().show()
- return@setNegativeButton
- }
- promptImportSelection(FileType.BLOCK_LIST)
- }
- "Manage Favorites" -> {
- val pendingBlocksFile = File(context.filesDir, "blocks_to_import.txt")
- if (pendingBlocksFile.exists()) {
- AlertDialog.Builder(context).apply {
- setTitle("Import Blocked")
- setMessage(
- "GrindrPlus has detected a pending blocks import. " +
- "Please import the blocks list first before importing favorites."
- )
- setPositiveButton("OK", null)
- }.create().show()
- return@setNegativeButton
- }
- promptImportSelection(FileType.FAVORITES_LIST)
- }
- }
- }
-
- setNeutralButton("Cancel", null)
- }
-
- val dialog = dialogBuilder.create()
- dialog.show()
- }
-
- private fun showResetConfirmationDialog() {
- val context = requireContext()
- AlertDialog.Builder(context).apply {
- setTitle("Reset GrindrPlus")
- setMessage("This will reset the database and the config of the mod, which means your cached albums/pictures will be gone, as well as saved phrases and locations.")
- setPositiveButton("Yes") { _, _ ->
- resetConfigAndCloseApp()
- }
- setNegativeButton("No", null)
- }.create().show()
- }
-
- private fun resetConfigAndCloseApp() {
- Config.resetConfig(true)
- closeApp()
- }
-
- fun closeApp() {
- val activity = requireActivity()
- activity.finishAffinity()
- exitProcess(0)
- }
-
- @SuppressLint("UseSwitchCompatOrMaterialCode")
- private fun createHookSwitch(
- context: Context,
- hookName: String,
- initialState: Boolean,
- description: String
- ): View {
- val hookVerticalLayout = LinearLayout(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).also { params ->
- params.topMargin = 44
- params.bottomMargin = 44
- }
- orientation = LinearLayout.VERTICAL
- gravity = Gravity.CENTER_VERTICAL
- }
-
- val hookHorizontalLayout = LinearLayout(context).apply {
- layoutParams = FrameLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- )
- orientation = LinearLayout.HORIZONTAL
- gravity = Gravity.CENTER_VERTICAL
- }
-
- val hookTitle = AppCompatTextView(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).also {
- it.weight = 1f
- it.gravity = Gravity.START or Gravity.CENTER_VERTICAL
- }
- typeface = Utils.getFont("ibm_plex_sans_medium", context)
- textSize = 16f
- text = hookName
- }
-
- val hookSwitch = Switch(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.WRAP_CONTENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- )
- isChecked = initialState
- setOnCheckedChangeListener { _, isChecked ->
- Config.setHookEnabled(hookName, isChecked)
- }
- }
-
- val hookDescription = AppCompatTextView(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).apply {
- topMargin = TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP, 4f,
- resources.displayMetrics
- ).toInt()
- }
- setTextColor(Colors.text_primary_dark_bg)
- typeface = Utils.getFont("ibm_plex_sans_fonts", context)
- setTextColor(Colors.grindr_light_gray_0)
- text = description
- }
-
- hookHorizontalLayout.addView(hookTitle)
- hookHorizontalLayout.addView(hookSwitch)
- hookVerticalLayout.addView(hookHorizontalLayout)
- hookVerticalLayout.addView(hookDescription)
-
- return hookVerticalLayout
- }
-
- private fun createDynamicSettingView(
- context: Context,
- title: String,
- description: String,
- key: String,
- defaultValue: Any,
- inputType: Int = InputType.TYPE_CLASS_TEXT,
- validation: ((String) -> String?)? = null
- ): View {
- val settingLayout = LinearLayout(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).also { params ->
- params.topMargin = 44
- params.bottomMargin = 44
- }
- orientation = LinearLayout.VERTICAL
- gravity = Gravity.CENTER_VERTICAL
- }
-
- val horizontalLayout = LinearLayout(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- )
- orientation = LinearLayout.HORIZONTAL
- gravity = Gravity.CENTER_VERTICAL
- }
-
- val settingTitle = AppCompatTextView(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- 0,
- LinearLayout.LayoutParams.WRAP_CONTENT,
- 1f
- ).also {
- it.gravity = Gravity.START or Gravity.CENTER_VERTICAL
- }
- typeface = Utils.getFont("ibm_plex_sans_medium", context)
- textSize = 16f
- text = title
- }
-
- val currentValue = Config.get(key, defaultValue).toString()
-
- val editText = EditText(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.WRAP_CONTENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- )
- this.inputType = inputType
- setText(currentValue)
- setSelection(text.length)
- isFocusable = false
- isClickable = true
-
- var originalValue = currentValue
-
- setOnClickListener {
- isFocusableInTouchMode = true
- isFocusable = true
- isClickable = false
- requestFocus()
- val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
- imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
- }
-
- setOnEditorActionListener { _, actionId, event ->
- if (actionId == EditorInfo.IME_ACTION_DONE || event?.keyCode == KeyEvent.KEYCODE_ENTER) {
- val newValue = text.toString()
- if (newValue.isBlank()) {
- setText(originalValue)
- Toast.makeText(context, "Input cannot be empty. Reverted to original value.", Toast.LENGTH_SHORT).show()
- } else if (newValue != originalValue) {
- val validationMessage = validation?.invoke(newValue)
- if (validationMessage != null) {
- Toast.makeText(context, validationMessage, Toast.LENGTH_LONG).show()
- setText(originalValue)
- } else {
- originalValue = newValue
- Config.put(key, newValue)
- Toast.makeText(context, "$title set to $newValue", Toast.LENGTH_SHORT).show()
- }
- }
- isFocusable = false
- isClickable = true
- true
- } else {
- false
- }
- }
-
- setOnFocusChangeListener { _, hasFocus ->
- if (!hasFocus) {
- if (text.toString().isBlank() || text.toString() == originalValue) {
- setText(originalValue)
- isFocusable = false
- isClickable = true
- }
- }
- }
- }
-
- val settingDescription = AppCompatTextView(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).apply {
- topMargin = TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP, 4f,
- resources.displayMetrics
- ).toInt()
- }
- setTextColor(Colors.text_primary_dark_bg)
- typeface = Utils.getFont("ibm_plex_sans_fonts", context)
- setTextColor(Colors.grindr_light_gray_0)
- text = description
- }
-
- horizontalLayout.addView(settingTitle)
- horizontalLayout.addView(editText)
- settingLayout.addView(horizontalLayout)
- settingLayout.addView(settingDescription)
-
- return settingLayout
- }
-
- @SuppressLint("UseSwitchCompatOrMaterialCode")
- private fun createToggleableSettingView(context: Context, title: String, description: String, key: String, defaultKeyValue: Boolean = false): View {
- val settingLayout = LinearLayout(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).also { params ->
- params.topMargin = 44
- params.bottomMargin = 44
- }
- orientation = LinearLayout.VERTICAL
- gravity = Gravity.CENTER_VERTICAL
- }
-
- val horizontalLayout = LinearLayout(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- )
- orientation = LinearLayout.HORIZONTAL
- gravity = Gravity.CENTER_VERTICAL
- }
-
- val settingTitle = AppCompatTextView(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- 0,
- LinearLayout.LayoutParams.WRAP_CONTENT,
- 1f
- ).also {
- it.gravity = Gravity.START or Gravity.CENTER_VERTICAL
- }
- typeface = Utils.getFont("ibm_plex_sans_medium", context)
- textSize = 16f
- text = title
- }
-
- val currentValue = Config.get(key, defaultKeyValue) as Boolean
- val settingSwitch = Switch(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.WRAP_CONTENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- )
- isChecked = currentValue
- setOnCheckedChangeListener { _, isChecked ->
- Config.put(key, isChecked)
- }
- }
-
- val settingDescription = AppCompatTextView(context).apply {
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT
- ).apply {
- topMargin = TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP, 4f,
- resources.displayMetrics
- ).toInt()
- }
- setTextColor(Colors.text_primary_dark_bg)
- typeface = Utils.getFont("ibm_plex_sans_fonts", context)
- setTextColor(Colors.grindr_light_gray_0)
- text = description
- }
-
- horizontalLayout.addView(settingTitle)
- horizontalLayout.addView(settingSwitch)
-
- settingLayout.addView(horizontalLayout)
- settingLayout.addView(settingDescription)
-
- return settingLayout
- }
-
- private fun getActionBarSize(context: Context): Int {
- val typedValue = TypedValue()
- if (context.theme.resolveAttribute(android.R.attr.actionBarSize, typedValue, true)) {
- return TypedValue.complexToDimensionPixelSize(
- typedValue.data,
- context.resources.displayMetrics
- )
- }
- return 0
- }
-
- private fun showPopupMenu(anchor: View, context: Context) {
- val popupMenu = PopupMenu(context, anchor)
- popupMenu.menu.add("Export Logs")
- popupMenu.menu.add("Clear Logs")
- popupMenu.menu.add("Export Config")
- popupMenu.menu.add("Import Config")
- popupMenu.menu.add("Export Database")
- popupMenu.menu.add("Import Database")
- popupMenu.menu.add("Reset GrindrPlus")
- popupMenu.menu.add("Reset Config")
- popupMenu.menu.add("Clear Cache")
-
- popupMenu.setOnMenuItemClickListener { item ->
- when (item.title) {
- "Export Logs" -> {
- promptFolderSelection(FileType.LOGS)
- true
- }
- "Clear Logs" -> {
- context.filesDir.listFiles { file ->
- file.name.endsWith(".log") }?.forEach { file ->
- file.delete()
- }
- Toast.makeText(context, "Successfully cleared logs", Toast.LENGTH_SHORT).show()
- true
- }
- "Export Config" -> {
- promptFolderSelection(FileType.CONFIG)
- true
- }
- "Import Config" -> {
- promptImportSelection(FileType.CONFIG)
- true
- }
- "Export Database" -> {
- promptFolderSelection(FileType.DATABASE)
- true
- }
- "Import Database" -> {
- promptImportSelection(FileType.DATABASE)
- true
- }
- "Reset GrindrPlus" -> {
- showResetConfirmationDialog()
- true
- }
- "Reset Config" -> {
- Config.resetConfig()
- updateUIFromConfig()
- Toast.makeText(context, "Config reset successfully", Toast.LENGTH_SHORT).show()
- AlertDialog.Builder(context)
- .setTitle("Config Reset")
- .setMessage("The app will now close to regenerate the config.")
- .setPositiveButton("OK") { _, _ ->
- closeApp()
- }
- .setCancelable(false)
- .show()
- true
- }
- "Clear Cache" -> {
- val context = requireContext()
- val blocksFileImport = File(context.filesDir, "blocks_to_import.txt")
- val favoritesFileImport = File(context.filesDir, "favorites_to_import.txt")
- val blocksFile = File(context.filesDir, "blocks.txt")
- val favoritesFile = File(context.filesDir, "favorites.txt")
- if (blocksFileImport.exists()) blocksFileImport.delete()
- if (favoritesFileImport.exists()) favoritesFileImport.delete()
- if (blocksFile.exists()) blocksFile.delete()
- if (favoritesFile.exists()) favoritesFile.delete()
- Toast.makeText(context, "Cache cleared", Toast.LENGTH_SHORT).show()
- true
- }
- else -> false
- }
- }
-
- popupMenu.show()
- }
-
- private fun createCustomToolbarWithMenu(context: Context): LinearLayout {
- return LinearLayout(context).apply {
- orientation = LinearLayout.HORIZONTAL
- setBackgroundColor(Colors.grindr_dark_amoled_black)
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP, 56f, context.resources.displayMetrics
- ).toInt()
- ).apply {
- setPadding(50, 0, 16, 0)
- }
-
- val title = TextView(context).apply {
- text = "Mod Settings"
- textSize = 20f
- setTextColor(Color.WHITE)
- layoutParams = LinearLayout.LayoutParams(
- 0,
- LinearLayout.LayoutParams.WRAP_CONTENT,
- 1f
- ).also {
- it.gravity = Gravity.START or Gravity.CENTER_VERTICAL
- }
- }
- addView(title)
-
- val menuIcon = ImageView(context).apply {
- setImageResource(Utils.getId(
- "abc_ic_menu_overflow_material",
- "drawable",
- context
- ))
- setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN)
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.WRAP_CONTENT,
- LinearLayout.LayoutParams.MATCH_PARENT
- ).apply {
- setMargins(0, 0, 16, 0)
- }
- setOnClickListener { view ->
- showPopupMenu(view, context)
- }
- }
- addView(menuIcon)
- }
- }
-}
diff --git a/app/src/main/java/com/grindrplus/utils/HookManager.kt b/app/src/main/java/com/grindrplus/utils/HookManager.kt
index 2a27ca4..18bf5fa 100644
--- a/app/src/main/java/com/grindrplus/utils/HookManager.kt
+++ b/app/src/main/java/com/grindrplus/utils/HookManager.kt
@@ -34,7 +34,7 @@ import kotlin.reflect.KClass
class HookManager {
private var hooks = mutableMapOf, 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()
}
}
\ No newline at end of file
diff --git a/app/src/main/res/raw/cert.crt b/app/src/main/res/raw/cert.crt
new file mode 100644
index 0000000..a3ec8a7
--- /dev/null
+++ b/app/src/main/res/raw/cert.crt
@@ -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-----
diff --git a/build.gradle.kts b/build.gradle.kts
index 90f4305..dde8efc 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -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
}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6048a25..0255111 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
diff --git a/settings.gradle b/settings.gradle
index aaea9b6..855a21d 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven { url 'https://jitpack.io' }
}
}