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' } } }