GrindrPlus: Implement the manager

Change-Id: I67fbd295277a70a705e36146c3d17a7f07ab607f

Co-authored-by: Giovanni Mazzone <giovannimariamazzone@gmail.com>
Co-authored-by: R0rt1z2 <me@r0rt1z2.com>
Change-Id: Ia72b5ff2aee1485f3d6b16398d2ac58aa71f372a


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