diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 93ed7ef..c6cf661 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -127,6 +127,36 @@ jobs:
name: Twitch.Drops.Miner.Windows
path: Twitch.Drops.Miner.Windows.zip
+ android:
+ name: Android
+ runs-on: ubuntu-latest
+ needs:
+ - validate
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ cache: gradle
+
+ - name: Build and test Android app
+ working-directory: android
+ run: |
+ chmod +x gradlew
+ ./gradlew testDebugUnitTest assembleDebug --no-daemon
+
+ - name: Upload Android APK
+ uses: actions/upload-artifact@v4
+ with:
+ if-no-files-found: error
+ name: Twitch.Drops.Miner.Android
+ path: android/app/build/outputs/apk/debug/app-debug.apk
+
macos:
name: macOS
runs-on: macos-latest
@@ -409,6 +439,7 @@ jobs:
name: Upload builds to Releases
if: github.event_name != 'pull_request'
needs:
+ - android
- windows
- linux-pyinstaller
- linux-appimage
diff --git a/.gitignore b/.gitignore
index 5992774..9d9fc7a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,4 +28,10 @@ settings.json
*.app
/.venv-linux-build
/.venv-tui
-/graphify-out
\ No newline at end of file
+/graphify-out
+
+# Android local SDK/build state
+/android/.gradle
+/android/.kotlin
+/android/local.properties
+/android/app/build
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..f39b580
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,40 @@
+# TD Miner Android
+
+Command-line Android build. No Android Studio required.
+
+## Build
+
+```powershell
+.\gradlew.bat testDebugUnitTest assembleDebug
+```
+
+APK:
+
+```text
+app/build/outputs/apk/debug/app-debug.apk
+```
+
+## Install To Phone
+
+Enable USB debugging, then:
+
+```powershell
+$adb = "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe"
+& $adb devices
+& $adb install -r app\build\outputs\apk\debug\app-debug.apk
+& $adb shell am start -n io.github.himanm.tdminer/.MainActivity
+```
+
+## Seed Desktop Cookies For Debug Testing
+
+Do not bake cookies into the APK. For local testing, copy the existing desktop JSON cookie jar into the app-private files directory:
+
+```powershell
+& $adb push ..\dist\cookies.jar /sdcard/Download/tdminer-cookies.jar
+& $adb shell run-as io.github.himanm.tdminer sh -c "mkdir -p files && cp /sdcard/Download/tdminer-cookies.jar files/cookies.jar"
+& $adb shell am start -n io.github.himanm.tdminer/.MainActivity
+```
+
+The app stores imported cookies in private `SharedPreferences`. They survive `adb install -r` updates and are cleared by Logout or app uninstall.
+
+The current app is a native responsive shell only. It does not run the real miner core yet.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..79d3ceb
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,51 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "io.github.himanm.tdminer"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "io.github.himanm.tdminer"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation(platform("androidx.compose:compose-bom:2024.12.01"))
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.json:json:20240303")
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..33ec87b
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/CookieStore.kt b/android/app/src/main/java/io/github/himanm/tdminer/CookieStore.kt
new file mode 100644
index 0000000..9251499
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/CookieStore.kt
@@ -0,0 +1,43 @@
+package io.github.himanm.tdminer
+
+import android.content.Context
+
+interface CookieStore {
+ fun loadCookies(): String?
+ fun saveCookies(cookies: String)
+ fun hasCookies(): Boolean = !loadCookies().isNullOrBlank()
+ fun loadCookieJar(): TwitchCookieJar? = loadCookies()?.let(TwitchCookieJar::parse)
+ fun logout()
+}
+
+class SharedPrefsCookieStore(context: Context) : CookieStore {
+ private val prefs = context.getSharedPreferences("tdminer_auth", Context.MODE_PRIVATE)
+
+ override fun loadCookies(): String? = prefs.getString(KEY_COOKIES, null)
+
+ override fun saveCookies(cookies: String) {
+ prefs.edit().putString(KEY_COOKIES, cookies).apply()
+ }
+
+ override fun logout() {
+ prefs.edit().remove(KEY_COOKIES).apply()
+ }
+
+ companion object {
+ private const val KEY_COOKIES = "cookies"
+ }
+}
+
+class MemoryCookieStore : CookieStore {
+ private var cookies: String? = null
+
+ override fun loadCookies(): String? = cookies
+
+ override fun saveCookies(cookies: String) {
+ this.cookies = cookies
+ }
+
+ override fun logout() {
+ cookies = null
+ }
+}
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/MainActivity.kt b/android/app/src/main/java/io/github/himanm/tdminer/MainActivity.kt
new file mode 100644
index 0000000..0a61ed9
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/MainActivity.kt
@@ -0,0 +1,41 @@
+package io.github.himanm.tdminer
+
+import android.Manifest
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.net.Uri
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (Build.VERSION.SDK_INT >= 33) {
+ requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100)
+ }
+ val core = MinerCore(SharedPrefsCookieStore(this))
+ intent.getStringExtra(EXTRA_COOKIES)?.takeIf { it.isNotBlank() }?.let(core::saveCookies)
+ setContent {
+ TDMinerApp(
+ core = core,
+ settingsStore = SharedPrefsMinerSettingsStore(this),
+ onStart = {
+ startForegroundService(Intent(this, MinerForegroundService::class.java))
+ MinerWidgetProvider.updateAll(this, true)
+ },
+ onStop = {
+ startService(
+ Intent(this, MinerForegroundService::class.java).setAction(ACTION_STOP),
+ )
+ MinerWidgetProvider.updateAll(this, false)
+ },
+ onOpenUrl = { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) },
+ )
+ }
+ }
+
+ companion object {
+ const val EXTRA_COOKIES = "io.github.himanm.tdminer.COOKIES"
+ }
+}
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/MinerCore.kt b/android/app/src/main/java/io/github/himanm/tdminer/MinerCore.kt
new file mode 100644
index 0000000..d01ee77
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/MinerCore.kt
@@ -0,0 +1,154 @@
+package io.github.himanm.tdminer
+
+class MinerCore(private val cookieStore: CookieStore) {
+ var session: MinerSession = idleSession()
+ private set
+
+ fun start(): MinerSession {
+ val jar = cookieStore.loadCookieJar()
+ val ready = jar?.hasAuthToken == true
+ session = MinerSession.running(loggedIn = ready, authReady = ready, userId = jar?.userId)
+ return session
+ }
+
+ fun validateAuth(validator: (String) -> TwitchAuthResult = ::validateTwitchAuth): MinerSession {
+ val jar = cookieStore.loadCookieJar()
+ val token = jar?.authToken
+ if (token.isNullOrBlank()) {
+ session = session.copy(loggedIn = false, authReady = false, userId = null)
+ return session
+ }
+ val result = validator(token)
+ session = session.copy(loggedIn = result.valid, authReady = result.valid, userId = result.userId ?: jar.userId)
+ return session
+ }
+
+ fun refreshDrops(
+ settings: MinerSettings = MinerSettings(),
+ onFetchProgress: (Int, Int) -> Unit = { _, _ -> },
+ fetcher: (TwitchCookieJar, (Int, Int) -> Unit) -> List = { jar, progress ->
+ fetchInventorySnapshots(jar.authToken.orEmpty(), jar.userId, jar.cookieHeader, jar.deviceId, progress)
+ },
+ ): MinerSession {
+ val jar = cookieStore.loadCookieJar()
+ if (jar?.authToken.isNullOrBlank()) return session
+ if (settings.priorityGames.isEmpty()) {
+ session = session.copy(
+ channel = "Priority empty",
+ game = "No priority selected",
+ campaign = "Add a priority game to start mining",
+ drop = "Waiting for priority",
+ gameImageUrl = null,
+ dropImageUrl = null,
+ rewards = emptyList(),
+ rewardImageUrls = emptyList(),
+ channels = emptyList(),
+ drops = emptyList(),
+ remainingSeconds = 0,
+ remaining = "--:--:--",
+ campaignProgress = 0f,
+ dropProgress = 0f,
+ )
+ return session
+ }
+ val snapshots = fetcher(jar, onFetchProgress)
+ val visibleDrops = filterPrioritySnapshots(snapshots, settings.priorityGames, settings.excludedGames)
+ val snapshot = selectInventorySnapshot(snapshots, settings.priorityGames, settings.excludedGames) ?: run {
+ session = session.copy(
+ channel = "No matching drop",
+ game = "No priority drop",
+ campaign = "Priority/exclude filtered active drops",
+ drop = "Waiting for matching campaign",
+ gameImageUrl = null,
+ dropImageUrl = null,
+ rewards = emptyList(),
+ rewardImageUrls = emptyList(),
+ channels = emptyList(),
+ drops = visibleDrops,
+ remainingSeconds = 0,
+ remaining = "--:--:--",
+ campaignProgress = 0f,
+ dropProgress = 0f,
+ )
+ return session
+ }
+ session = session.copy(
+ channel = if (snapshot.channel == "Twitch") session.channel.takeUnless { it == "Twitch" } ?: "Finding channel" else snapshot.channel,
+ game = snapshot.game,
+ campaign = snapshot.campaign,
+ drop = snapshot.drop,
+ gameImageUrl = snapshot.gameImageUrl,
+ dropImageUrl = snapshot.dropImageUrl,
+ rewards = snapshot.rewards,
+ rewardImageUrls = snapshot.rewardImageUrls,
+ drops = visibleDrops,
+ remainingSeconds = snapshot.remainingSeconds,
+ remaining = snapshot.remaining,
+ campaignProgress = snapshot.campaignProgress,
+ dropProgress = snapshot.dropProgress,
+ )
+ return session
+ }
+
+ fun watchOnce(
+ channelFetcher: (String, String) -> List = { token, game -> fetchLiveChannelsForGame(token, game) },
+ watcher: (String, String, TwitchChannel) -> Boolean = ::sendWatchMinute,
+ ): MinerSession {
+ val jar = cookieStore.loadCookieJar()
+ val token = jar?.authToken
+ val userId = session.userId ?: jar?.userId
+ if (token.isNullOrBlank() || userId.isNullOrBlank() || !session.authReady) return session
+ if (!session.running) return session
+ if (session.game.isBlank() || session.game == "Loading drops" || session.game == "Unknown game") return session
+ if (session.drops.isEmpty()) return session.copy(channels = emptyList())
+ val channels = channelFetcher(token, session.game)
+ val channel = channels.firstOrNull() ?: return session.copy(channel = "No live drops channel", channels = emptyList())
+ watcher(token, userId, channel)
+ session = session.copy(channel = channel.displayName, channels = channels)
+ return session
+ }
+
+ fun switchChannel(
+ channel: TwitchChannel,
+ watcher: (String, String, TwitchChannel) -> Boolean = ::sendWatchMinute,
+ ): MinerSession {
+ val jar = cookieStore.loadCookieJar()
+ val token = jar?.authToken
+ val userId = session.userId ?: jar?.userId
+ if (!token.isNullOrBlank() && !userId.isNullOrBlank() && session.authReady) {
+ watcher(token, userId, channel)
+ }
+ session = session.copy(channel = channel.displayName)
+ return session
+ }
+
+ fun loadCategories(fetcher: (String, String?, String?, String?) -> List = ::fetchDropCategories): List {
+ val jar = cookieStore.loadCookieJar() ?: return emptyList()
+ val token = jar.authToken ?: return emptyList()
+ return fetcher(token, jar.userId, jar.cookieHeader, jar.deviceId)
+ }
+
+ fun stop(): MinerSession {
+ session = idleSession()
+ return session
+ }
+
+ fun saveCookies(cookies: String): MinerSession {
+ cookieStore.saveCookies(cookies)
+ val ready = TwitchCookieJar.parse(cookies).hasAuthToken
+ session = session.copy(loggedIn = ready, authReady = ready, userId = TwitchCookieJar.parse(cookies).userId)
+ return session
+ }
+
+ fun logout(): MinerSession {
+ cookieStore.logout()
+ session = MinerSession.idle(false)
+ return session
+ }
+
+ private fun idleSession(): MinerSession {
+ val jar = cookieStore.loadCookieJar()
+ val ready = jar?.hasAuthToken == true
+ return MinerSession.idle(loggedIn = ready, authReady = ready, userId = jar?.userId)
+ }
+}
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/MinerForegroundService.kt b/android/app/src/main/java/io/github/himanm/tdminer/MinerForegroundService.kt
new file mode 100644
index 0000000..9089cb7
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/MinerForegroundService.kt
@@ -0,0 +1,155 @@
+package io.github.himanm.tdminer
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+import androidx.core.app.NotificationCompat
+
+class MinerForegroundService : Service() {
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var worker: Thread? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ createChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_STOP) {
+ stopSession()
+ return START_NOT_STICKY
+ }
+ if (worker?.isAlive == true) return START_STICKY
+ if (SharedPrefsMinerSettingsStore(this).load().wakeLockEnabled) acquireWakeLock()
+ val core = MinerCore(SharedPrefsCookieStore(this))
+ val session = core.start()
+ startForeground(NOTIFICATION_ID, notification("Validating Twitch session", session, indeterminate = true))
+ MinerWidgetProvider.updateAll(this, session)
+ worker?.interrupt()
+ worker = Thread {
+ var firstRun = true
+ while (!Thread.currentThread().isInterrupted) {
+ if (firstRun) {
+ firstRun = false
+ try {
+ Thread.sleep(60_000)
+ } catch (_: InterruptedException) {
+ break
+ }
+ }
+ val refreshed = refresh(core, validate = false)
+ val title = if (refreshed.authReady) "TD Miner running" else "TD Miner login needed"
+ getSystemService(NotificationManager::class.java)
+ .notify(NOTIFICATION_ID, notification(title, refreshed, indeterminate = false))
+ MinerWidgetProvider.updateAll(this, refreshed)
+ try {
+ Thread.sleep(60_000)
+ } catch (_: InterruptedException) {
+ break
+ }
+ }
+ }.apply { start() }
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ worker?.interrupt()
+ worker = null
+ wakeLock?.takeIf { it.isHeld }?.release()
+ wakeLock = null
+ MinerWidgetProvider.updateAll(this, false)
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun acquireWakeLock() {
+ if (wakeLock?.isHeld == true) return
+ val power = getSystemService(POWER_SERVICE) as PowerManager
+ wakeLock = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "tdminer:session").apply {
+ acquire(6 * 60 * 60 * 1000L)
+ }
+ }
+
+ private fun createChannel() {
+ if (Build.VERSION.SDK_INT < 26) return
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "TD Miner session",
+ NotificationManager.IMPORTANCE_LOW,
+ )
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+
+ private fun notification(title: String, session: MinerSession, indeterminate: Boolean) =
+ NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(title)
+ .setContentText(notificationText(session))
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setProgress(100, (session.dropProgress * 100).toInt(), indeterminate)
+ .addAction(0, "STOP", stopIntent())
+ .build()
+
+ private fun notificationText(session: MinerSession): String =
+ if (session.authReady) {
+ "${session.channel} / ${session.drop} ${(session.dropProgress * 100).toInt()}%"
+ } else {
+ "Saved Twitch cookies are missing or expired"
+ }
+
+ private fun refresh(core: MinerCore, validate: Boolean): MinerSession {
+ val validated = if (validate) {
+ try {
+ core.validateAuth()
+ } catch (_: Exception) {
+ core.session.copy(loggedIn = false, authReady = false)
+ }
+ } else {
+ core.session
+ }
+ return if (validated.authReady) {
+ try {
+ val settings = SharedPrefsMinerSettingsStore(this).load()
+ core.refreshDrops(settings)
+ core.watchOnce()
+ } catch (_: Exception) {
+ validated
+ }
+ } else {
+ validated
+ }
+ }
+
+ private fun stopIntent(): PendingIntent {
+ val intent = Intent(this, MinerForegroundService::class.java).setAction(ACTION_STOP)
+ return PendingIntent.getService(
+ this,
+ 2,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ }
+
+ private fun stopSession() {
+ worker?.interrupt()
+ worker = null
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ getSystemService(NotificationManager::class.java).cancel(NOTIFICATION_ID)
+ wakeLock?.takeIf { it.isHeld }?.release()
+ wakeLock = null
+ MinerWidgetProvider.updateAll(this, false)
+ stopSelf()
+ }
+
+ companion object {
+ const val CHANNEL_ID = "tdminer-session"
+ const val NOTIFICATION_ID = 1
+ }
+}
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/MinerSession.kt b/android/app/src/main/java/io/github/himanm/tdminer/MinerSession.kt
new file mode 100644
index 0000000..058484b
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/MinerSession.kt
@@ -0,0 +1,60 @@
+package io.github.himanm.tdminer
+
+data class MinerSession(
+ val running: Boolean,
+ val channel: String,
+ val game: String,
+ val campaign: String,
+ val drop: String,
+ val gameImageUrl: String?,
+ val dropImageUrl: String?,
+ val rewards: List,
+ val rewardImageUrls: List,
+ val channels: List,
+ val drops: List,
+ val remainingSeconds: Int,
+ val remaining: String,
+ val campaignProgress: Float,
+ val dropProgress: Float,
+ val wakeLockActive: Boolean,
+ val notificationActive: Boolean,
+ val loggedIn: Boolean,
+ val authReady: Boolean,
+ val userId: String?,
+) {
+ companion object {
+ fun running(loggedIn: Boolean = false, authReady: Boolean = loggedIn, userId: String? = null) = MinerSession(
+ running = true,
+ channel = "Finding channel",
+ game = "Loading drops",
+ campaign = "Validating Twitch session",
+ drop = "Waiting for inventory",
+ gameImageUrl = null,
+ dropImageUrl = null,
+ rewards = emptyList(),
+ rewardImageUrls = emptyList(),
+ channels = emptyList(),
+ drops = emptyList(),
+ remainingSeconds = 0,
+ remaining = "--:--:--",
+ campaignProgress = 0f,
+ dropProgress = 0f,
+ wakeLockActive = true,
+ notificationActive = true,
+ loggedIn = loggedIn,
+ authReady = authReady,
+ userId = userId,
+ )
+
+ fun idle(loggedIn: Boolean = false, authReady: Boolean = loggedIn, userId: String? = null) = running(loggedIn, authReady, userId).copy(
+ running = false,
+ channel = "Not watching",
+ game = "Ready",
+ campaign = "Press Start to mine drops",
+ drop = "No active drop",
+ remaining = "--:--:--",
+ wakeLockActive = false,
+ notificationActive = false,
+ )
+ }
+}
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/MinerSettingsStore.kt b/android/app/src/main/java/io/github/himanm/tdminer/MinerSettingsStore.kt
new file mode 100644
index 0000000..76d59e0
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/MinerSettingsStore.kt
@@ -0,0 +1,156 @@
+package io.github.himanm.tdminer
+
+import android.content.Context
+
+data class MinerSettings(
+ val priorityGames: List = emptyList(),
+ val excludedGames: List = emptyList(),
+ val farmUnlinkedDrops: Boolean = true,
+ val badgeEmoteSupport: Boolean = true,
+ val notificationsEnabled: Boolean = true,
+ val wakeLockEnabled: Boolean = true,
+)
+
+data class CategoryCache(
+ val categories: List,
+ val savedAtMillis: Long,
+) {
+ fun isFresh(nowMillis: Long, ttlMillis: Long = 60 * 60 * 1000L): Boolean =
+ categories.isNotEmpty() && nowMillis - savedAtMillis < ttlMillis
+}
+
+interface MinerSettingsStore {
+ fun load(): MinerSettings
+ fun save(settings: MinerSettings)
+ fun loadCategoryCache(): CategoryCache
+ fun saveCategoryCache(categories: List, savedAtMillis: Long = System.currentTimeMillis())
+}
+
+class SharedPrefsMinerSettingsStore(context: Context) : MinerSettingsStore {
+ private val prefs = context.getSharedPreferences("tdminer_settings", Context.MODE_PRIVATE)
+
+ override fun load(): MinerSettings {
+ migratePlaceholderDefaults()
+ return MinerSettings(
+ priorityGames = prefs.loadList(KEY_PRIORITY) ?: MinerSettings().priorityGames,
+ excludedGames = prefs.loadList(KEY_EXCLUDED) ?: MinerSettings().excludedGames,
+ farmUnlinkedDrops = prefs.getBoolean(KEY_FARM_UNLINKED, true),
+ badgeEmoteSupport = prefs.getBoolean(KEY_BADGE_EMOTE, true),
+ notificationsEnabled = prefs.getBoolean(KEY_NOTIFICATIONS, true),
+ wakeLockEnabled = prefs.getBoolean(KEY_WAKE_LOCK, true),
+ )
+ }
+
+ override fun save(settings: MinerSettings) {
+ prefs.edit()
+ .putString(KEY_PRIORITY, encodeSettingsList(settings.priorityGames))
+ .putString(KEY_EXCLUDED, encodeSettingsList(settings.excludedGames))
+ .putBoolean(KEY_FARM_UNLINKED, settings.farmUnlinkedDrops)
+ .putBoolean(KEY_BADGE_EMOTE, settings.badgeEmoteSupport)
+ .putBoolean(KEY_NOTIFICATIONS, settings.notificationsEnabled)
+ .putBoolean(KEY_WAKE_LOCK, settings.wakeLockEnabled)
+ .apply()
+ }
+
+ override fun loadCategoryCache(): CategoryCache =
+ if (prefs.getInt(KEY_CATEGORIES_VERSION, 0) == CATEGORY_CACHE_VERSION) {
+ CategoryCache(
+ categories = prefs.loadCategories(KEY_CATEGORIES),
+ savedAtMillis = prefs.getLong(KEY_CATEGORIES_SAVED_AT, 0L),
+ )
+ } else {
+ CategoryCache(emptyList(), 0L)
+ }
+
+ override fun saveCategoryCache(categories: List, savedAtMillis: Long) {
+ prefs.edit()
+ .putString(KEY_CATEGORIES, encodeCategories(categories))
+ .putLong(KEY_CATEGORIES_SAVED_AT, savedAtMillis)
+ .putInt(KEY_CATEGORIES_VERSION, CATEGORY_CACHE_VERSION)
+ .apply()
+ }
+
+ private fun android.content.SharedPreferences.loadList(key: String): List? =
+ getString(key, null)?.lineSequence()?.toList()?.let(::normalizeSettingsList)
+
+ private fun android.content.SharedPreferences.loadCategories(key: String): List =
+ getString(key, null)
+ ?.lineSequence()
+ ?.mapNotNull { row ->
+ val parts = row.split('\t', limit = 2)
+ if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) {
+ TwitchCategory(parts[0], parts[1])
+ } else {
+ null
+ }
+ }
+ ?.toList()
+ ?: emptyList()
+
+ private fun migratePlaceholderDefaults() {
+ val version = prefs.getInt(KEY_VERSION, 0)
+ if (version >= 3) return
+ val priority = prefs.loadList(KEY_PRIORITY)
+ val excluded = prefs.loadList(KEY_EXCLUDED)
+ val autoSeededPriority = setOf("Overwatch", "Overwatch 2", "Marvel Rivals")
+ prefs.edit()
+ .apply {
+ if (priority == listOf("Detroit: Become Human") || priority?.all { it in autoSeededPriority } == true) {
+ remove(KEY_PRIORITY)
+ } else {
+ priority?.filterNot { it == "Detroit: Become Human" }?.let { putString(KEY_PRIORITY, encodeSettingsList(it)) }
+ }
+ if (excluded == listOf("Just Chatting")) {
+ remove(KEY_EXCLUDED)
+ } else {
+ excluded?.filterNot { it == "Just Chatting" }?.let { putString(KEY_EXCLUDED, encodeSettingsList(it)) }
+ }
+ }
+ .putInt(KEY_VERSION, 3)
+ .apply()
+ }
+
+ companion object {
+ private const val KEY_VERSION = "settings_version"
+ private const val KEY_PRIORITY = "priority_games"
+ private const val KEY_EXCLUDED = "excluded_games"
+ private const val KEY_FARM_UNLINKED = "farm_unlinked_drops"
+ private const val KEY_BADGE_EMOTE = "badge_emote_support"
+ private const val KEY_NOTIFICATIONS = "notifications_enabled"
+ private const val KEY_WAKE_LOCK = "wake_lock_enabled"
+ private const val KEY_CATEGORIES = "drop_categories"
+ private const val KEY_CATEGORIES_SAVED_AT = "drop_categories_saved_at"
+ private const val KEY_CATEGORIES_VERSION = "drop_categories_version"
+ private const val CATEGORY_CACHE_VERSION = 2
+ }
+}
+
+class MemoryMinerSettingsStore(settings: MinerSettings = MinerSettings()) : MinerSettingsStore {
+ private var settings = settings
+
+ override fun load(): MinerSettings = settings
+
+ override fun save(settings: MinerSettings) {
+ this.settings = settings.copy(
+ priorityGames = normalizeSettingsList(settings.priorityGames),
+ excludedGames = normalizeSettingsList(settings.excludedGames),
+ )
+ }
+
+ private var categoryCache = CategoryCache(emptyList(), 0L)
+
+ override fun loadCategoryCache(): CategoryCache = categoryCache
+
+ override fun saveCategoryCache(categories: List, savedAtMillis: Long) {
+ categoryCache = CategoryCache(categories, savedAtMillis)
+ }
+}
+
+internal fun encodeSettingsList(items: Iterable): String =
+ normalizeSettingsList(items).joinToString("\n")
+
+internal fun normalizeSettingsList(items: Iterable): List =
+ items.map(String::trim).filter(String::isNotEmpty).distinct()
+
+private fun encodeCategories(categories: List): String =
+ categories.distinctBy { it.id }.joinToString("\n") { "${it.id}\t${it.name}" }
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/MinerWidgetProvider.kt b/android/app/src/main/java/io/github/himanm/tdminer/MinerWidgetProvider.kt
new file mode 100644
index 0000000..e8269d0
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/MinerWidgetProvider.kt
@@ -0,0 +1,70 @@
+package io.github.himanm.tdminer
+
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.widget.RemoteViews
+
+const val ACTION_START = "io.github.himanm.tdminer.START"
+const val ACTION_STOP = "io.github.himanm.tdminer.STOP"
+
+class MinerWidgetProvider : AppWidgetProvider() {
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+ when (intent.action) {
+ ACTION_START -> {
+ context.startForegroundService(Intent(context, MinerForegroundService::class.java))
+ updateAll(context, true)
+ }
+ ACTION_STOP -> {
+ context.startService(
+ Intent(context, MinerForegroundService::class.java).setAction(ACTION_STOP),
+ )
+ updateAll(context, false)
+ }
+ }
+ }
+
+ override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
+ ids.forEach { manager.updateAppWidget(it, views(context, MinerSession.idle())) }
+ }
+
+ companion object {
+ fun updateAll(context: Context, running: Boolean) {
+ updateAll(context, MinerSession.idle().copy(running = running))
+ }
+
+ fun updateAll(context: Context, session: MinerSession) {
+ val manager = AppWidgetManager.getInstance(context)
+ val ids = manager.getAppWidgetIds(ComponentName(context, MinerWidgetProvider::class.java))
+ ids.forEach { manager.updateAppWidget(it, views(context, session)) }
+ }
+
+ private fun views(context: Context, session: MinerSession): RemoteViews {
+ val views = RemoteViews(context.packageName, R.layout.miner_widget)
+ views.setTextViewText(R.id.widget_status, if (session.running) "RUNNING" else "IDLE")
+ views.setTextViewText(
+ R.id.widget_detail,
+ if (session.running) "${session.channel} / ${session.drop} ${(session.dropProgress * 100).toInt()}%" else "Ready to start",
+ )
+ views.setProgressBar(R.id.widget_progress, 100, if (session.running) (session.dropProgress * 100).toInt() else 0, false)
+ views.setTextViewText(R.id.widget_button, if (session.running) "STOP" else "START")
+ views.setOnClickPendingIntent(
+ R.id.widget_button,
+ PendingIntent.getBroadcast(
+ context,
+ if (session.running) 4 else 3,
+ Intent(context, MinerWidgetProvider::class.java)
+ .setAction(if (session.running) ACTION_STOP else ACTION_START),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ ),
+ )
+ return views
+ }
+ }
+}
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/TDMinerApp.kt b/android/app/src/main/java/io/github/himanm/tdminer/TDMinerApp.kt
new file mode 100644
index 0000000..b6a6cbb
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/TDMinerApp.kt
@@ -0,0 +1,1275 @@
+package io.github.himanm.tdminer
+
+import android.graphics.BitmapFactory
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.automirrored.outlined.Logout
+import androidx.compose.material.icons.automirrored.outlined.ViewList
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Campaign
+import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.Dashboard
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.Terminal
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.NavigationRailItemDefaults
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlin.math.roundToInt
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.net.URL
+
+private val Paper = Color(0xFFF1EBDD)
+private val Ink = Color(0xFF101513)
+private val Muted = Color(0xFF9DA8A1)
+private val Rule = Color(0xFF34413C)
+private val Green = Color(0xFF37E2B2)
+private val Orange = Color(0xFFFF665A)
+private val Panel = Color(0xFF18201D)
+private val DisplayFont = FontFamily(
+ Font(R.font.roboto_condensed_regular, FontWeight.Normal),
+ Font(R.font.roboto_condensed_bold, FontWeight.Bold),
+ Font(R.font.roboto_condensed_black, FontWeight.Black),
+)
+private val AppColors = darkColorScheme(
+ primary = Green,
+ secondary = Green,
+ tertiary = Orange,
+ background = Ink,
+ surface = Panel,
+ surfaceVariant = Color(0xFF222C28),
+ onPrimary = Ink,
+ onSecondary = Ink,
+ onBackground = Paper,
+ onSurface = Paper,
+)
+
+private data class AppTab(val title: String, val icon: ImageVector)
+
+private val Tabs = listOf(
+ AppTab("Home", Icons.Outlined.Dashboard),
+ AppTab("Drops", Icons.Outlined.Campaign),
+ AppTab("Channels", Icons.AutoMirrored.Outlined.ViewList),
+ AppTab("Prefs", Icons.Outlined.Settings),
+ AppTab("Logs", Icons.Outlined.Terminal),
+)
+
+@Composable
+fun TDMinerApp(
+ core: MinerCore,
+ settingsStore: MinerSettingsStore,
+ onStart: () -> Unit,
+ onStop: () -> Unit,
+ onOpenUrl: (String) -> Unit,
+) {
+ var selected by remember { mutableIntStateOf(0) }
+ var session by remember { mutableStateOf(core.session) }
+ var showLogoutConfirm by remember { mutableStateOf(false) }
+ var notice by remember { mutableStateOf(null) }
+ val savedSettings = remember { settingsStore.load() }
+ val scope = rememberCoroutineScope()
+ val priority = remember { mutableStateListOf().also { it.addAll(savedSettings.priorityGames) } }
+ val excluded = remember { mutableStateListOf().also { it.addAll(savedSettings.excludedGames) } }
+ var priorityInput by remember { mutableStateOf("") }
+ var excludedInput by remember { mutableStateOf("") }
+ var availableCategories by remember { mutableStateOf(emptyList()) }
+ var priorityResults by remember { mutableStateOf(emptyList()) }
+ var excludedResults by remember { mutableStateOf(emptyList()) }
+ var categoriesLoading by remember { mutableStateOf(true) }
+ var miningLoading by remember { mutableStateOf(false) }
+ var loginCode by remember { mutableStateOf(null) }
+ var loginLoading by remember { mutableStateOf(false) }
+ var loginError by remember { mutableStateOf(null) }
+ var farmUnlinked by remember { mutableStateOf(savedSettings.farmUnlinkedDrops) }
+ var badgeEmote by remember { mutableStateOf(savedSettings.badgeEmoteSupport) }
+ var notifications by remember { mutableStateOf(savedSettings.notificationsEnabled) }
+ var wakeLock by remember { mutableStateOf(savedSettings.wakeLockEnabled) }
+ suspend fun fetchAndCacheCategories(showNotice: Boolean) {
+ categoriesLoading = true
+ try {
+ val categories = withContext(Dispatchers.IO) { core.loadCategories() }
+ if (categories.isNotEmpty()) {
+ availableCategories = categories
+ settingsStore.saveCategoryCache(categories)
+ if (showNotice) notice = "Drop game list reloaded"
+ } else if (showNotice) {
+ notice = "No drop games returned"
+ }
+ } catch (_: Exception) {
+ if (showNotice) notice = "Could not reload drop games"
+ } finally {
+ categoriesLoading = false
+ }
+ }
+ fun reloadCategories(showNotice: Boolean = true) {
+ scope.launch { fetchAndCacheCategories(showNotice) }
+ }
+ LaunchedEffect(Unit) {
+ val cache = settingsStore.loadCategoryCache()
+ availableCategories = cache.categories
+ if (!cache.isFresh(System.currentTimeMillis())) {
+ fetchAndCacheCategories(showNotice = false)
+ } else {
+ categoriesLoading = false
+ }
+ }
+ LaunchedEffect(priorityInput, availableCategories) {
+ priorityResults = matchLoadedCategories(availableCategories, priorityInput)
+ }
+ LaunchedEffect(excludedInput, availableCategories) {
+ excludedResults = matchLoadedCategories(availableCategories, excludedInput)
+ }
+ LaunchedEffect(session.running, session.game, session.campaign, session.drop) {
+ while (session.running) {
+ delay(1000)
+ session = session.let { current ->
+ if (!current.running || current.remainingSeconds <= 0) {
+ current
+ } else {
+ val next = (current.remainingSeconds - 1).coerceAtLeast(0)
+ val drops = current.drops.map { drop ->
+ if (drop.game == current.game && drop.campaign == current.campaign && drop.drop == current.drop) {
+ drop.copy(remainingSeconds = next)
+ } else {
+ drop
+ }
+ }
+ current.copy(drops = drops, remainingSeconds = next, remaining = formatRemainingSeconds(next))
+ }
+ }
+ }
+ }
+ fun currentSettings(): MinerSettings {
+ val cleanPriority = normalizeSettingsList(priority)
+ return MinerSettings(
+ priorityGames = cleanPriority,
+ excludedGames = normalizeSettingsList(excluded).filterNot { it in cleanPriority.toSet() },
+ farmUnlinkedDrops = farmUnlinked,
+ badgeEmoteSupport = badgeEmote,
+ notificationsEnabled = notifications,
+ wakeLockEnabled = wakeLock,
+ )
+ }
+ fun onFetchProgress(done: Int, total: Int) {
+ scope.launch {
+ val progress = if (total <= 0) 0f else done.toFloat() / total.toFloat()
+ session = core.session.copy(
+ campaign = "Fetching drop campaigns",
+ drop = "$done/$total campaigns loaded",
+ campaignProgress = progress,
+ dropProgress = progress,
+ )
+ }
+ }
+ val start = {
+ if (!miningLoading && !categoriesLoading) {
+ session = core.start()
+ miningLoading = true
+ scope.launch {
+ session = withContext(Dispatchers.IO) {
+ try {
+ val validated = core.validateAuth()
+ if (validated.authReady) {
+ core.refreshDrops(currentSettings(), onFetchProgress = ::onFetchProgress)
+ core.watchOnce()
+ } else {
+ validated
+ }
+ } catch (_: Exception) {
+ core.session
+ }
+ }
+ miningLoading = false
+ if (session.authReady && session.running) onStart()
+ }
+ }
+ Unit
+ }
+ val stop = {
+ session = core.stop()
+ onStop()
+ }
+ val logout = {
+ session = core.logout()
+ showLogoutConfirm = false
+ notice = "Saved Twitch session cleared"
+ onStop()
+ }
+ fun beginLogin() {
+ if (loginLoading) return
+ loginLoading = true
+ loginError = null
+ scope.launch {
+ try {
+ val code = withContext(Dispatchers.IO) { requestTwitchDeviceCode() }
+ loginCode = code
+ onOpenUrl(code.verificationUrl)
+ val cookies = withContext(Dispatchers.IO) { awaitTwitchDeviceLogin(code) }
+ session = core.saveCookies(cookies)
+ loginCode = null
+ notice = "Twitch account connected"
+ reloadCategories(showNotice = false)
+ } catch (error: Exception) {
+ loginError = error.message?.take(120) ?: "Could not start Twitch login"
+ } finally {
+ loginLoading = false
+ }
+ }
+ }
+ fun refreshSessionFromSettings() {
+ scope.launch {
+ session = withContext(Dispatchers.IO) {
+ try {
+ core.refreshDrops(currentSettings(), onFetchProgress = ::onFetchProgress)
+ core.watchOnce()
+ } catch (_: Exception) {
+ core.session
+ }
+ }
+ }
+ }
+ fun saveLists() {
+ saveSettings(settingsStore, priority, excluded, farmUnlinked, badgeEmote, notifications, wakeLock)
+ refreshSessionFromSettings()
+ }
+ fun movePriority(from: Int, to: Int) {
+ if (from !in priority.indices || to !in priority.indices || from == to) return
+ val item = priority.removeAt(from)
+ priority.add(to, item)
+ saveLists()
+ notice = "Priority order updated"
+ }
+
+ MaterialTheme(colorScheme = AppColors) {
+ Surface(color = Ink, modifier = Modifier.fillMaxSize()) {
+ if (!session.loggedIn) {
+ LoginScreen(loginCode, loginLoading, loginError, ::beginLogin, onOpenUrl)
+ return@Surface
+ }
+ BoxWithConstraints(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
+ val wide = maxWidth >= 720.dp
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ containerColor = Ink,
+ contentWindowInsets = WindowInsets(0, 0, 0, 0),
+ bottomBar = {
+ if (!wide) {
+ NavigationBar(containerColor = Ink, tonalElevation = 0.dp) {
+ Tabs.forEachIndexed { index, tab ->
+ NavigationBarItem(
+ selected = selected == index,
+ onClick = { selected = index },
+ icon = { Icon(tab.icon, contentDescription = tab.title) },
+ label = { Text(tab.title) },
+ colors = NavigationBarItemDefaults.colors(
+ selectedIconColor = Green,
+ selectedTextColor = Green,
+ indicatorColor = Color.Transparent,
+ unselectedIconColor = Muted,
+ unselectedTextColor = Muted,
+ ),
+ )
+ }
+ }
+ }
+ },
+ ) { padding ->
+ Row(Modifier.fillMaxSize().padding(padding)) {
+ if (wide) {
+ NavigationRail(containerColor = Ink) {
+ Tabs.forEachIndexed { index, tab ->
+ NavigationRailItem(
+ selected = selected == index,
+ onClick = { selected = index },
+ icon = { Icon(tab.icon, contentDescription = tab.title) },
+ label = { Text(tab.title) },
+ colors = NavigationRailItemDefaults.colors(
+ selectedIconColor = Green,
+ selectedTextColor = Green,
+ indicatorColor = Color.Transparent,
+ unselectedIconColor = Muted,
+ unselectedTextColor = Muted,
+ ),
+ )
+ }
+ }
+ }
+ Box(Modifier.fillMaxSize()) {
+ when (selected) {
+ 0 -> Dashboard(session, miningLoading, categoriesLoading, start, stop)
+ 1 -> Campaigns(session, availableCategories)
+ 2 -> Channels(session) { channel ->
+ scope.launch {
+ session = withContext(Dispatchers.IO) { core.switchChannel(channel) }
+ }
+ notice = "Watching ${channel.displayName}"
+ }
+ 3 -> Settings(
+ session = session,
+ priority = priority,
+ excluded = excluded,
+ priorityInput = priorityInput,
+ excludedInput = excludedInput,
+ availableCategories = availableCategories,
+ priorityResults = priorityResults,
+ excludedResults = excludedResults,
+ categoriesLoading = categoriesLoading,
+ farmUnlinked = farmUnlinked,
+ badgeEmote = badgeEmote,
+ notifications = notifications,
+ wakeLock = wakeLock,
+ onPriorityInput = { priorityInput = it },
+ onExcludedInput = { excludedInput = it },
+ onPickPriority = { category ->
+ normalizeSettingsList(listOf(category.name) + priority).also {
+ priority.clear()
+ priority.addAll(it)
+ }
+ excluded.remove(category.name)
+ priorityInput = ""
+ priorityResults = emptyList()
+ saveLists()
+ notice = "Priority list updated"
+ },
+ onPickExcluded = { category ->
+ normalizeSettingsList(excluded + category.name).also {
+ excluded.clear()
+ excluded.addAll(it)
+ }
+ priority.remove(category.name)
+ excludedInput = ""
+ excludedResults = emptyList()
+ saveLists()
+ notice = "Excluded list updated"
+ },
+ onRemovePriority = {
+ priority.remove(it)
+ saveLists()
+ notice = "Priority list updated"
+ },
+ onMovePriority = ::movePriority,
+ onRemoveExcluded = {
+ excluded.remove(it)
+ saveLists()
+ notice = "Excluded list updated"
+ },
+ onFarmUnlinkedChange = {
+ farmUnlinked = it
+ saveLists()
+ },
+ onBadgeEmoteChange = {
+ badgeEmote = it
+ saveLists()
+ },
+ onNotificationsChange = {
+ notifications = it
+ saveLists()
+ },
+ onWakeLockChange = {
+ wakeLock = it
+ saveLists()
+ },
+ onReloadCategories = { reloadCategories(showNotice = true) },
+ onLogoutClick = { showLogoutConfirm = true },
+ )
+ else -> Logs(session, availableCategories.size)
+ }
+ if (showLogoutConfirm) {
+ LogoutDialog(
+ onCancel = { showLogoutConfirm = false },
+ onConfirm = logout,
+ )
+ }
+ notice?.let {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
+ NoticeBanner(it)
+ }
+ LaunchedEffect(it) {
+ delay(1800)
+ notice = null
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoginScreen(
+ code: TwitchDeviceCode?,
+ loading: Boolean,
+ error: String?,
+ onBegin: () -> Unit,
+ onOpenUrl: (String) -> Unit,
+) {
+ Column(
+ Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).padding(28.dp),
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("TDM / MOBILE", style = mono(17, FontWeight.Black), color = Green)
+ Text("CONNECT\nTWITCH", style = swiss(48, FontWeight.Black))
+ Text("One account. Stored privately on this device.", style = swiss(15), color = Muted)
+ }
+ ModernCard {
+ if (code == null) {
+ Text("DEVICE LOGIN", style = mono(12, FontWeight.Bold), color = Green)
+ Text("Twitch opens in your browser. Approve this device, then return here.", style = swiss(16, FontWeight.Bold))
+ } else {
+ Text("ENTER THIS CODE", style = mono(12, FontWeight.Bold), color = Green)
+ Text(code.userCode, style = mono(38, FontWeight.Black))
+ Text("Waiting for Twitch approval...", style = swiss(14), color = Muted)
+ }
+ error?.let { Text(it, style = swiss(13, FontWeight.Bold), color = Orange) }
+ PrimaryButton(
+ text = when {
+ code != null -> "OPEN TWITCH AGAIN"
+ loading -> "REQUESTING CODE"
+ else -> "CONNECT TWITCH"
+ },
+ color = Green,
+ onClick = { if (code == null) onBegin() else onOpenUrl(code.verificationUrl) },
+ enabled = code != null || !loading,
+ )
+ }
+ Text("No password is entered into TD Miner.", style = mono(11), color = Muted)
+ }
+}
+
+@Composable
+@OptIn(ExperimentalLayoutApi::class)
+private fun Dashboard(session: MinerSession, miningLoading: Boolean, categoriesLoading: Boolean, onStart: () -> Unit, onStop: () -> Unit) {
+ val busy = miningLoading || categoriesLoading
+ Column(Modifier.fillMaxSize()) {
+ Row(Modifier.fillMaxWidth().height(68.dp).padding(horizontal = 20.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text("TDM / MOBILE", style = mono(18, FontWeight.Black))
+ Text("v19.3 ●", style = mono(13, FontWeight.Bold), color = Green)
+ }
+ Box(Modifier.fillMaxWidth().weight(1f).background(Panel)) {
+ NetworkImage(session.gameImageUrl, Modifier.fillMaxSize(), alignment = Alignment.TopCenter)
+ Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = if (session.gameImageUrl == null) 0.25f else 0.48f)))
+ Column(Modifier.align(Alignment.BottomStart).padding(22.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
+ Text(session.game.uppercase(), style = swiss(42, FontWeight.Black))
+ Text("CAMPAIGN", style = mono(12, FontWeight.Bold), color = Green)
+ Text(session.campaign, style = swiss(22, FontWeight.Black))
+ Spacer(Modifier.height(6.dp))
+ Text("WATCHING", style = mono(12, FontWeight.Bold), color = Green)
+ Text(session.channel, style = swiss(22, FontWeight.Black))
+ }
+ }
+ Column(Modifier.padding(horizontal = 20.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
+ Reward(session.drop.take(10), session.dropImageUrl)
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
+ Text("ACTIVE REWARD", style = mono(11, FontWeight.Bold), color = Green)
+ Text(session.drop, style = swiss(18, FontWeight.Black))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text("${(session.dropProgress * 100).toInt()}%", style = mono(24, FontWeight.Bold), color = Green)
+ Text(session.remaining, style = mono(19, FontWeight.Bold))
+ }
+ }
+ }
+ if (categoriesLoading) {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ color = Green,
+ trackColor = Rule,
+ )
+ } else {
+ ProgressLine("", "", session.dropProgress)
+ }
+ PrimaryButton(
+ text = if (categoriesLoading) "LOADING DROP GAMES" else if (miningLoading) "LOADING CAMPAIGNS" else if (session.running) "STOP MINING" else "START MINING",
+ color = if (session.running && !busy) Orange else Green,
+ onClick = if (session.running && !busy) onStop else onStart,
+ enabled = !busy,
+ )
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
+ Metric("${session.drops.map { it.game }.distinct().size}", "DROP GAMES")
+ Metric("${session.channels.size}", "LIVE CHANNELS")
+ Metric(if (session.wakeLockActive) "ON" else "OFF", "WAKE LOCK")
+ }
+ }
+ }
+}
+
+@Composable
+private fun Campaigns(session: MinerSession, categories: List) = LockedPage(
+ title = "CAMPAIGNS",
+ subtitle = "${categories.size} searchable drop games · ${session.drops.size} timed drops · ${session.channels.size} live",
+) {
+ if (session.drops.isEmpty()) {
+ Text("Add a priority game to show drops here. Cached games are only search suggestions.", style = swiss(14), color = Muted)
+ }
+ session.drops.groupBy { it.game }.forEach { (_, drops) ->
+ DropGroupCard(drops, session)
+ }
+}
+
+@Composable
+private fun Channels(session: MinerSession, onSwitch: (TwitchChannel) -> Unit) = LockedPage(
+ title = "CHANNEL SIGNAL",
+ subtitle = "${session.game} · Drops enabled · ${session.channels.size} online",
+) {
+ if (session.channels.isEmpty()) {
+ Text("No live channels loaded yet. Press Start to fetch drops-enabled channels for ${session.game}.", style = swiss(14), color = Muted)
+ }
+ session.channels.forEach { channel ->
+ val watching = channel.displayName == session.channel
+ Row(
+ Modifier.fillMaxWidth().clickable { onSwitch(channel) }.border(if (watching) 1.dp else 0.dp, if (watching) Green else Color.Transparent).padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.size(58.dp).border(1.dp, Rule), contentAlignment = Alignment.Center) {
+ Text(channel.displayName.take(1).uppercase(), style = swiss(24, FontWeight.Black), color = if (watching) Green else Paper)
+ }
+ Column(Modifier.weight(1f)) {
+ Text(channel.displayName, style = swiss(17, FontWeight.Black))
+ Text(channel.gameName, style = mono(12), color = Muted)
+ if (watching) Text("WATCHING", style = mono(11, FontWeight.Bold), color = Green)
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Text("${channel.viewers}", style = mono(14, FontWeight.Bold))
+ Text("● DROPS ON", style = mono(11, FontWeight.Bold), color = Green)
+ }
+ }
+ }
+}
+
+@Composable
+private fun Settings(
+ session: MinerSession,
+ priority: List,
+ excluded: List,
+ priorityInput: String,
+ excludedInput: String,
+ availableCategories: List,
+ priorityResults: List,
+ excludedResults: List,
+ categoriesLoading: Boolean,
+ farmUnlinked: Boolean,
+ badgeEmote: Boolean,
+ notifications: Boolean,
+ wakeLock: Boolean,
+ onPriorityInput: (String) -> Unit,
+ onExcludedInput: (String) -> Unit,
+ onPickPriority: (TwitchCategory) -> Unit,
+ onPickExcluded: (TwitchCategory) -> Unit,
+ onRemovePriority: (String) -> Unit,
+ onMovePriority: (Int, Int) -> Unit,
+ onRemoveExcluded: (String) -> Unit,
+ onFarmUnlinkedChange: (Boolean) -> Unit,
+ onBadgeEmoteChange: (Boolean) -> Unit,
+ onNotificationsChange: (Boolean) -> Unit,
+ onWakeLockChange: (Boolean) -> Unit,
+ onReloadCategories: () -> Unit,
+ onLogoutClick: () -> Unit,
+) {
+ var expanded by remember { mutableIntStateOf(0) }
+ var browser by remember { mutableIntStateOf(-1) }
+ Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text("GAME ROUTING", style = swiss(30, FontWeight.Black))
+ Text(
+ "PRIORITIZE WHAT TO MINE. EXCLUDED GAMES WON'T BE WATCHED.",
+ style = mono(8, FontWeight.Bold),
+ color = Muted,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Spacer(Modifier.width(10.dp))
+ OutlinedButton(
+ onClick = onReloadCategories,
+ enabled = !categoriesLoading,
+ shape = RoundedCornerShape(2.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = Green),
+ border = BorderStroke(1.dp, Green),
+ contentPadding = PaddingValues(0.dp),
+ modifier = Modifier.width(84.dp).height(40.dp),
+ ) {
+ Text(if (categoriesLoading) "LOADING" else "RELOAD", style = mono(11, FontWeight.Bold))
+ }
+ }
+ CategoryListEditor(
+ title = "PRIORITY QUEUE",
+ helper = "Drag to reorder · mines top available",
+ addLabel = "ADD PRIORITY GAME",
+ items = priority,
+ value = priorityInput,
+ placeholder = "Search loaded drop game",
+ results = priorityResults,
+ categoriesLoaded = availableCategories.isNotEmpty(),
+ categoriesLoading = categoriesLoading,
+ onValueChange = onPriorityInput,
+ onPick = onPickPriority,
+ onRemove = onRemovePriority,
+ onMove = onMovePriority,
+ imageUrl = { name -> availableCategories.firstOrNull { it.name == name }?.boxArtUrl() },
+ expanded = expanded == 0,
+ browserOpen = browser == 0,
+ onHeaderClick = { expanded = 0; browser = -1 },
+ onAddClick = { expanded = 0; browser = if (browser == 0) -1 else 0 },
+ modifier = if (expanded == 0) Modifier.weight(1f) else Modifier.height(62.dp),
+ )
+ CategoryListEditor(
+ title = "EXCLUDED GAMES",
+ helper = "Never watched or mined",
+ addLabel = "ADD EXCLUDED GAME",
+ items = excluded,
+ value = excludedInput,
+ placeholder = "Search loaded drop game",
+ results = excludedResults,
+ categoriesLoaded = availableCategories.isNotEmpty(),
+ categoriesLoading = categoriesLoading,
+ onValueChange = onExcludedInput,
+ onPick = onPickExcluded,
+ onRemove = onRemoveExcluded,
+ imageUrl = { name -> availableCategories.firstOrNull { it.name == name }?.boxArtUrl() },
+ expanded = expanded == 1,
+ browserOpen = browser == 1,
+ onHeaderClick = { expanded = 1; browser = -1 },
+ onAddClick = { expanded = 1; browser = if (browser == 1) -1 else 1 },
+ modifier = if (expanded == 1) Modifier.weight(1f) else Modifier.height(62.dp),
+ )
+ ModernCard(
+ (if (expanded == 2) Modifier.weight(1f) else Modifier.height(62.dp)).clickable { expanded = 2; browser = -1 },
+ ) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Column {
+ Text("BEHAVIOR", style = swiss(16, FontWeight.Black), color = Green)
+ Text("Notifications, partial rewards and power", style = swiss(10), color = Muted)
+ }
+ Text(if (expanded == 2) "−" else "+", style = mono(18, FontWeight.Bold), color = Green)
+ }
+ if (expanded == 2) {
+ ToggleRow("Partial badges & emotes", badgeEmote, onBadgeEmoteChange, "Accept partial progress")
+ ToggleRow("Farm unlinked", farmUnlinked, onFarmUnlinkedChange, "Allow drops from unlinked channels")
+ ToggleRow("Notifications", notifications, onNotificationsChange, "Show mining alerts")
+ ToggleRow("Wake lock", wakeLock, onWakeLockChange, "Keep device awake while mining")
+ Text("${availableCategories.size} DROP GAMES CACHED", style = mono(10, FontWeight.Bold), color = Muted)
+ }
+ }
+ OutlinedButton(
+ onClick = onLogoutClick,
+ shape = RoundedCornerShape(2.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = Orange),
+ border = BorderStroke(1.dp, Orange),
+ modifier = Modifier.fillMaxWidth().height(44.dp),
+ ) {
+ Icon(Icons.AutoMirrored.Outlined.Logout, contentDescription = null, tint = Orange)
+ Spacer(Modifier.size(6.dp))
+ Text("LOG OUT", color = Orange)
+ }
+ }
+}
+
+@Composable
+private fun Logs(session: MinerSession, categoryCount: Int) = LockedPage(
+ title = "EVENT STREAM",
+ trailing = "● LIVE",
+) {
+ Row(Modifier.fillMaxWidth().border(1.dp, Rule).padding(16.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
+ Metric(if (session.running) "RUN" else "IDLE", session.remaining)
+ Metric("NET", "OK")
+ Metric("COOKIE", if (session.loggedIn) "SAVED" else "MISSING")
+ }
+ listOf(
+ "10:42:31" to "Watching ${session.channel}",
+ "10:41:07" to "Selected ${session.game}",
+ "10:40:22" to "Loaded ${session.drops.size} drops",
+ "10:39:41" to "Found $categoryCount drop games",
+ "10:38:56" to if (session.authReady) "Authentication restored" else "Authentication required",
+ ).forEachIndexed { index, event ->
+ Row(Modifier.fillMaxWidth().height(72.dp).border(0.dp, Color.Transparent), verticalAlignment = Alignment.CenterVertically) {
+ Text(event.first, style = mono(13), color = Muted, modifier = Modifier.width(92.dp))
+ Text(if (index == 0) "◉" else "◇", color = Green, style = mono(18), modifier = Modifier.width(44.dp))
+ Text(event.second, style = mono(14), modifier = Modifier.weight(1f))
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(Rule))
+ }
+}
+
+@Composable
+private fun LockedPage(
+ title: String,
+ subtitle: String? = null,
+ trailing: String? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(
+ Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(title, style = swiss(34, FontWeight.Black))
+ trailing?.let { Text(it, style = mono(12, FontWeight.Bold), color = Green) }
+ }
+ subtitle?.let { Text(it, style = mono(12), color = Muted) }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(Rule))
+ Column(
+ Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ content = content,
+ )
+ }
+}
+
+@Composable
+private fun ModernCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
+ ElevatedCard(
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surface),
+ shape = RoundedCornerShape(4.dp),
+ elevation = CardDefaults.elevatedCardElevation(0.dp),
+ modifier = modifier.fillMaxWidth().border(1.dp, Rule, RoundedCornerShape(4.dp)),
+ ) {
+ Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), content = content)
+ }
+}
+
+@Composable
+private fun Artwork(session: MinerSession) {
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
+ ArtworkBlock(session.game.replace(": ", ":\n"), session.gameImageUrl)
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text(session.campaign, style = swiss(22, FontWeight.Bold))
+ RewardRow(session)
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalLayoutApi::class)
+private fun DropGroupCard(drops: List, session: MinerSession) {
+ val first = drops.first()
+ Column(Modifier.fillMaxWidth().border(1.dp, Rule)) {
+ Box(Modifier.fillMaxWidth().height(150.dp).background(Panel)) {
+ NetworkImage(first.gameImageUrl, Modifier.fillMaxSize())
+ Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)))
+ Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
+ Text(first.game.uppercase(), style = swiss(28, FontWeight.Black))
+ Text("${drops.map { it.campaign }.distinct().size} CAMPAIGNS · ${drops.size} TIMED DROPS", style = mono(11), color = Muted)
+ if (drops.any { it.game == session.game }) Text("● MINING", style = mono(12, FontWeight.Bold), color = Green)
+ }
+ }
+ drops.forEach { drop ->
+ val selected = drop.game == session.game && drop.campaign == session.campaign && drop.drop == session.drop
+ DropItemRow(drop, selected, session.running, if (selected) session.remaining else drop.remaining)
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalLayoutApi::class)
+private fun DropItemRow(drop: TwitchDropSnapshot, selected: Boolean, running: Boolean, remaining: String) {
+ Row(
+ Modifier.fillMaxWidth().background(if (selected) Green.copy(alpha = 0.07f) else Panel).border(if (selected) 1.dp else 0.dp, if (selected) Green else Color.Transparent).padding(10.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ NetworkImage(drop.dropImageUrl ?: drop.rewardImageUrls.firstOrNull(), Modifier.size(58.dp).clip(RoundedCornerShape(3.dp)))
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(drop.drop, style = swiss(14, FontWeight.Bold))
+ Text(drop.campaign, style = swiss(11), color = Muted)
+ ProgressLine("", "", drop.dropProgress)
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Text("${(drop.dropProgress * 100).toInt()}%", style = mono(16, FontWeight.Bold), color = if (selected) Green else Paper)
+ Text(remaining, style = mono(12, FontWeight.Bold), color = if (selected && running) Green else Muted)
+ }
+ }
+}
+
+@Composable
+private fun ArtworkBlock(text: String, imageUrl: String? = null, modifier: Modifier = Modifier.size(width = 96.dp, height = 128.dp)) {
+ Box(
+ modifier.background(Ink, RoundedCornerShape(2.dp)),
+ contentAlignment = Alignment.BottomStart,
+ ) {
+ NetworkImage(imageUrl, Modifier.fillMaxSize())
+ if (imageUrl != null) Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.25f)))
+ Text(text, style = swiss(14, FontWeight.Bold), color = Paper, modifier = Modifier.padding(10.dp))
+ }
+}
+
+@Composable
+@OptIn(ExperimentalLayoutApi::class)
+private fun RewardRow(session: MinerSession) {
+ val rewards = session.rewards.ifEmpty { listOf(session.drop) }
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ rewards.take(4).forEachIndexed { index, reward ->
+ Reward(reward.take(10), session.rewardImageUrls.getOrNull(index))
+ }
+ }
+}
+
+@Composable
+private fun SettingRow(label: String, value: String) {
+ Row(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface, RoundedCornerShape(20.dp)).padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(label, style = swiss(15, FontWeight.Bold))
+ Text(value, style = swiss(15), color = Muted)
+ }
+}
+
+@Composable
+@OptIn(ExperimentalLayoutApi::class)
+private fun CategoryListEditor(
+ title: String,
+ helper: String,
+ addLabel: String,
+ items: List,
+ value: String,
+ placeholder: String,
+ results: List,
+ categoriesLoaded: Boolean,
+ categoriesLoading: Boolean,
+ onValueChange: (String) -> Unit,
+ onPick: (TwitchCategory) -> Unit,
+ onRemove: (String) -> Unit,
+ onMove: ((Int, Int) -> Unit)? = null,
+ imageUrl: (String) -> String? = { null },
+ expanded: Boolean,
+ browserOpen: Boolean,
+ onHeaderClick: () -> Unit,
+ onAddClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ModernCard(modifier) {
+ Row(
+ Modifier.fillMaxWidth().clickable(onClick = onHeaderClick),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text(title, style = swiss(16, FontWeight.Black), color = Green)
+ Text(helper, style = swiss(11), color = Muted)
+ }
+ Text("${items.size} ${if (expanded) "−" else "+"}", style = mono(13, FontWeight.Bold), color = Green)
+ }
+ if (expanded) {
+ if (!browserOpen) {
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().weight(1f).border(1.dp, Rule, RoundedCornerShape(2.dp)),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ itemsIndexed(items) { index, item ->
+ if (onMove == null) {
+ CompactListRow(item, imageUrl(item), onRemove)
+ } else {
+ DraggablePriorityRow(
+ item = item,
+ imageUrl = imageUrl(item),
+ index = index,
+ lastIndex = items.lastIndex,
+ onMove = onMove,
+ onRemove = onRemove,
+ )
+ }
+ }
+ }
+ OutlinedButton(
+ onClick = onAddClick,
+ shape = RoundedCornerShape(2.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = Green),
+ border = BorderStroke(1.dp, Green),
+ modifier = Modifier.fillMaxWidth().height(46.dp),
+ ) { Text("+ $addLabel", style = mono(12, FontWeight.Bold)) }
+ } else {
+ BrowseCategoryPanel(
+ title = if (onMove == null) "ADD TO EXCLUDED" else "ADD TO PRIORITY",
+ value = value,
+ results = results,
+ categoriesLoaded = categoriesLoaded,
+ categoriesLoading = categoriesLoading,
+ onValueChange = onValueChange,
+ onPick = onPick,
+ onClose = onAddClick,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BrowseCategoryPanel(
+ title: String,
+ value: String,
+ results: List,
+ categoriesLoaded: Boolean,
+ categoriesLoading: Boolean,
+ onValueChange: (String) -> Unit,
+ onPick: (TwitchCategory) -> Unit,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier.border(1.dp, Rule).padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(title, style = mono(12, FontWeight.Bold), color = Green)
+ TextButton(onClick = onClose) { Text("×", style = swiss(20), color = Green) }
+ }
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = { Text("Search drop games") },
+ singleLine = true,
+ shape = RoundedCornerShape(2.dp),
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedIndicatorColor = Green,
+ unfocusedIndicatorColor = Rule,
+ cursorColor = Green,
+ ),
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ )
+ when {
+ categoriesLoading -> Text("Loading drop categories first...", style = swiss(13), color = Muted)
+ value.length >= 2 && !categoriesLoaded -> Text("Drop categories are still loading", style = swiss(13, FontWeight.Bold), color = Orange)
+ value.length >= 2 && results.isEmpty() -> Text("No active or upcoming drop game found", style = swiss(13, FontWeight.Bold), color = Orange)
+ }
+ LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ itemsIndexed(results) { _, category ->
+ OutlinedButton(
+ onClick = { onPick(category) },
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = Paper),
+ border = BorderStroke(1.dp, Rule),
+ shape = RoundedCornerShape(2.dp),
+ modifier = Modifier.fillMaxWidth().height(46.dp),
+ ) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
+ NetworkImage(category.boxArtUrl(), Modifier.size(width = 54.dp, height = 34.dp).clip(RoundedCornerShape(2.dp)))
+ Text(category.name, style = swiss(14, FontWeight.Bold), maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ Text(if (title.contains("EXCLUDED")) "EXCLUDE" else "ADD", style = swiss(12, FontWeight.Bold), color = Green)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DraggablePriorityRow(
+ item: String,
+ imageUrl: String?,
+ index: Int,
+ lastIndex: Int,
+ onMove: (Int, Int) -> Unit,
+ onRemove: (String) -> Unit,
+) {
+ var dragY by remember { mutableStateOf(0f) }
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .offset { IntOffset(0, dragY.roundToInt()) }
+ .background(Panel)
+ .padding(horizontal = 10.dp, vertical = 7.dp)
+ .pointerInput(index, lastIndex) {
+ detectDragGesturesAfterLongPress(
+ onDragEnd = { dragY = 0f },
+ onDragCancel = { dragY = 0f },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ dragY += dragAmount.y
+ when {
+ dragY > 48f && index < lastIndex -> {
+ onMove(index, index + 1)
+ dragY = 0f
+ }
+ dragY < -48f && index > 0 -> {
+ onMove(index, index - 1)
+ dragY = 0f
+ }
+ }
+ },
+ )
+ },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text("⋮⋮", style = mono(16, FontWeight.Bold), color = Green)
+ NetworkImage(imageUrl, Modifier.size(width = 68.dp, height = 42.dp).clip(RoundedCornerShape(2.dp)))
+ Text("${(index + 1).toString().padStart(2, '0')}", style = mono(13, FontWeight.Bold), color = Muted)
+ Text(item, style = swiss(14, FontWeight.Bold))
+ }
+ TextButton(onClick = { onRemove(item) }) {
+ Text("×", style = swiss(20), color = Green)
+ }
+ }
+}
+
+@Composable
+private fun CompactListRow(item: String, imageUrl: String?, onRemove: (String) -> Unit) {
+ Row(
+ Modifier.fillMaxWidth().background(Panel).padding(horizontal = 10.dp, vertical = 7.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ NetworkImage(imageUrl, Modifier.size(width = 68.dp, height = 42.dp).clip(RoundedCornerShape(2.dp)))
+ Spacer(Modifier.width(10.dp))
+ Text(item, style = swiss(14, FontWeight.Bold), modifier = Modifier.weight(1f))
+ TextButton(onClick = { onRemove(item) }) { Text("×", style = swiss(20), color = Green) }
+ }
+}
+
+@Composable
+private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, subtitle: String? = null) {
+ Row(
+ Modifier.fillMaxWidth().height(if (subtitle == null) 44.dp else 48.dp).border(1.dp, Rule)
+ .clickable { onCheckedChange(!checked) }.padding(horizontal = 10.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(label, style = swiss(12, FontWeight.Bold))
+ subtitle?.let { Text(it, style = swiss(9), color = Muted) }
+ }
+ Box(
+ Modifier.size(36.dp)
+ .background(if (checked) Green else Ink)
+ .border(1.dp, if (checked) Green else Muted),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(if (checked) "ON" else "OFF", style = mono(9, FontWeight.Black), color = if (checked) Ink else Muted)
+ }
+ }
+}
+
+@Composable
+private fun NoticeBanner(text: String) {
+ Surface(
+ color = Panel,
+ shape = RoundedCornerShape(4.dp),
+ border = BorderStroke(1.dp, Green),
+ shadowElevation = 0.dp,
+ ) {
+ Row(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Green, modifier = Modifier.size(18.dp))
+ Column {
+ Text("TD MINER", style = mono(9, FontWeight.Bold), color = Green)
+ Text(text, style = swiss(13, FontWeight.Bold), color = Paper)
+ }
+ }
+ }
+}
+
+@Composable
+private fun LogoutDialog(onCancel: () -> Unit, onConfirm: () -> Unit) {
+ Dialog(onDismissRequest = onCancel) {
+ Column(
+ Modifier.fillMaxWidth().background(Panel).border(1.dp, Green).padding(22.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text("ACCOUNT / SESSION", style = mono(10, FontWeight.Bold), color = Green)
+ Text("LOG OUT?", style = swiss(30, FontWeight.Black), color = Paper)
+ Text(
+ "This clears saved Twitch cookies from private app storage. You will need to login again.",
+ style = swiss(14),
+ color = Muted,
+ )
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedButton(
+ onClick = onCancel,
+ shape = RoundedCornerShape(2.dp),
+ border = BorderStroke(1.dp, Rule),
+ modifier = Modifier.weight(1f),
+ ) {
+ Text("CANCEL", style = mono(11, FontWeight.Bold), color = Paper)
+ }
+ OutlinedButton(
+ onClick = onConfirm,
+ shape = RoundedCornerShape(2.dp),
+ border = BorderStroke(1.dp, Orange),
+ modifier = Modifier.weight(1f),
+ ) {
+ Text("LOG OUT", style = mono(11, FontWeight.Bold), color = Orange)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PrimaryButton(text: String, color: Color, onClick: () -> Unit, enabled: Boolean = true) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ colors = ButtonDefaults.buttonColors(containerColor = color),
+ shape = RoundedCornerShape(2.dp),
+ modifier = Modifier.fillMaxWidth().height(58.dp),
+ ) {
+ Text(text, style = mono(16, FontWeight.Bold), color = Ink)
+ }
+}
+
+@Composable
+private fun Metric(value: String, label: String) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(value, style = mono(18, FontWeight.Black), color = Green)
+ Text(label, style = mono(9, FontWeight.Bold), color = Muted)
+ }
+}
+
+@Composable
+private fun ProgressLine(label: String, value: String, progress: Float) {
+ val clamped = progress.coerceIn(0f, 1f)
+ Text("$label $value", style = swiss(14, FontWeight.Bold))
+ Box(Modifier.fillMaxWidth().height(4.dp).background(Rule)) {
+ Box(Modifier.fillMaxWidth(clamped).height(4.dp).background(Green))
+ }
+}
+
+@Composable
+private fun Chip(text: String, color: Color) {
+ Text(text, color = color, style = swiss(12, FontWeight.Bold), modifier = Modifier.border(1.dp, color).padding(horizontal = 8.dp, vertical = 4.dp))
+}
+
+@Composable
+private fun Reward(text: String, imageUrl: String? = null) {
+ Box(Modifier.size(54.dp).background(Panel).border(1.dp, Rule), contentAlignment = Alignment.Center) {
+ NetworkImage(imageUrl, Modifier.fillMaxSize())
+ if (imageUrl != null) Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.18f)))
+ Text(text, style = swiss(10, FontWeight.Bold), color = if (imageUrl == null) Ink else Paper, textAlign = TextAlign.Center, modifier = Modifier.padding(4.dp))
+ }
+}
+
+@Composable
+private fun NetworkImage(url: String?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center) {
+ var bitmap by remember(url) { mutableStateOf(null) }
+ LaunchedEffect(url) {
+ bitmap = if (url.isNullOrBlank()) {
+ null
+ } else {
+ withContext(Dispatchers.IO) {
+ runCatching { URL(url).openStream().use(BitmapFactory::decodeStream) }.getOrNull()
+ }
+ }
+ }
+ bitmap?.let {
+ Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, alignment = alignment, modifier = modifier)
+ }
+}
+
+private fun matchLoadedCategories(categories: List, query: String): List {
+ val trimmed = query.trim()
+ if (trimmed.length < 2) return emptyList()
+ return categories.filter { it.name.contains(trimmed, ignoreCase = true) }.take(8)
+}
+
+private fun TwitchCategory.boxArtUrl() = "https://static-cdn.jtvnw.net/ttv-boxart/$id-285x380.jpg"
+
+private fun saveSettings(
+ settingsStore: MinerSettingsStore,
+ priority: List,
+ excluded: List,
+ farmUnlinked: Boolean,
+ badgeEmote: Boolean,
+ notifications: Boolean,
+ wakeLock: Boolean,
+) {
+ val cleanPriority = normalizeSettingsList(priority)
+ val cleanExcluded = normalizeSettingsList(excluded).filterNot { it in cleanPriority.toSet() }
+ settingsStore.save(
+ MinerSettings(
+ priorityGames = cleanPriority,
+ excludedGames = cleanExcluded,
+ farmUnlinkedDrops = farmUnlinked,
+ badgeEmoteSupport = badgeEmote,
+ notificationsEnabled = notifications,
+ wakeLockEnabled = wakeLock,
+ ),
+ )
+}
+
+private fun swiss(size: Int, weight: FontWeight = FontWeight.Normal) =
+ androidx.compose.ui.text.TextStyle(fontSize = size.sp, fontWeight = weight, fontFamily = DisplayFont, color = Paper)
+
+private fun mono(size: Int, weight: FontWeight = FontWeight.Normal) =
+ androidx.compose.ui.text.TextStyle(fontSize = size.sp, fontWeight = weight, fontFamily = FontFamily.Monospace, color = Paper)
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/TwitchAuth.kt b/android/app/src/main/java/io/github/himanm/tdminer/TwitchAuth.kt
new file mode 100644
index 0000000..04c5e1a
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/TwitchAuth.kt
@@ -0,0 +1,27 @@
+package io.github.himanm.tdminer
+
+import java.net.HttpURLConnection
+import java.net.URL
+
+data class TwitchAuthResult(val valid: Boolean, val userId: String? = null)
+
+fun validateTwitchAuth(authToken: String): TwitchAuthResult {
+ val connection = (URL("https://id.twitch.tv/oauth2/validate").openConnection() as HttpURLConnection).apply {
+ requestMethod = "GET"
+ connectTimeout = 10_000
+ readTimeout = 10_000
+ setRequestProperty("Authorization", "OAuth $authToken")
+ }
+ return try {
+ if (connection.responseCode != HttpURLConnection.HTTP_OK) {
+ TwitchAuthResult(false)
+ } else {
+ TwitchAuthResult(true, connection.inputStream.bufferedReader().use { it.readText() }.jsonValue("user_id"))
+ }
+ } finally {
+ connection.disconnect()
+ }
+}
+
+internal fun String.jsonValue(name: String): String? =
+ Regex(""""${Regex.escape(name)}"\s*:\s*"([^"]*)"""").find(this)?.groupValues?.get(1)
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/TwitchCookieJar.kt b/android/app/src/main/java/io/github/himanm/tdminer/TwitchCookieJar.kt
new file mode 100644
index 0000000..e8bf91f
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/TwitchCookieJar.kt
@@ -0,0 +1,39 @@
+package io.github.himanm.tdminer
+
+data class TwitchCookieJar(
+ val raw: String,
+ val authToken: String?,
+ val userId: String?,
+ val deviceId: String?,
+ val cookieHeader: String,
+) {
+ val hasAuthToken: Boolean = !authToken.isNullOrBlank()
+
+ companion object {
+ fun parse(raw: String): TwitchCookieJar {
+ return TwitchCookieJar(
+ raw = raw,
+ authToken = raw.cookieValue("auth-token"),
+ userId = raw.cookieValue("persistent"),
+ deviceId = raw.cookieValue("unique_id"),
+ cookieHeader = raw.cookiePairs().associate { it.first to it.second }.entries.joinToString("; ") { "${it.key}=${it.value}" },
+ )
+ }
+ }
+}
+
+private fun String.cookieValue(name: String): String? {
+ val match = Regex(
+ """"${Regex.escape(name)}"\s*:\s*\{[^}]*"value"\s*:\s*"([^"]*)"""",
+ RegexOption.DOT_MATCHES_ALL,
+ ).find(this)
+ return match?.groupValues?.get(1)?.replace("\\\"", "\"")
+}
+
+private fun String.cookiePairs(): List> =
+ Regex(
+ """"key"\s*:\s*"([^"]+)"[\s\S]*?"value"\s*:\s*"([^"]*)"""",
+ RegexOption.DOT_MATCHES_ALL,
+ ).findAll(this).map { match ->
+ match.groupValues[1].replace("\\\"", "\"") to match.groupValues[2].replace("\\\"", "\"")
+ }.toList()
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/TwitchDeviceLogin.kt b/android/app/src/main/java/io/github/himanm/tdminer/TwitchDeviceLogin.kt
new file mode 100644
index 0000000..1315adf
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/TwitchDeviceLogin.kt
@@ -0,0 +1,84 @@
+package io.github.himanm.tdminer
+
+import java.net.HttpURLConnection
+import java.net.URL
+import java.net.URLEncoder
+import java.util.UUID
+
+data class TwitchDeviceCode(
+ val deviceCode: String,
+ val userCode: String,
+ val verificationUrl: String,
+ val intervalSeconds: Int,
+ val expiresInSeconds: Int,
+ val deviceId: String,
+)
+
+fun requestTwitchDeviceCode(): TwitchDeviceCode {
+ val deviceId = UUID.randomUUID().toString().replace("-", "")
+ val response = postForm(
+ "https://id.twitch.tv/oauth2/device",
+ mapOf("client_id" to ANDROID_APP_CLIENT_ID, "scopes" to ""),
+ deviceId,
+ )
+ return TwitchDeviceCode(
+ deviceCode = response.jsonValue("device_code") ?: error("Twitch did not return a device code"),
+ userCode = response.jsonValue("user_code") ?: error("Twitch did not return a user code"),
+ verificationUrl = response.jsonValue("verification_uri") ?: "https://www.twitch.tv/activate",
+ intervalSeconds = response.jsonInt("interval") ?: 5,
+ expiresInSeconds = response.jsonInt("expires_in") ?: 1800,
+ deviceId = deviceId,
+ )
+}
+
+fun awaitTwitchDeviceLogin(code: TwitchDeviceCode): String {
+ val deadline = System.currentTimeMillis() + code.expiresInSeconds * 1000L
+ while (System.currentTimeMillis() < deadline) {
+ Thread.sleep(code.intervalSeconds * 1000L)
+ val result = runCatching {
+ postForm(
+ "https://id.twitch.tv/oauth2/token",
+ mapOf(
+ "client_id" to ANDROID_APP_CLIENT_ID,
+ "device_code" to code.deviceCode,
+ "grant_type" to "urn:ietf:params:oauth:grant-type:device_code",
+ ),
+ code.deviceId,
+ )
+ }.getOrNull() ?: continue
+ val token = result.jsonValue("access_token") ?: continue
+ val userId = validateTwitchAuth(token).userId.orEmpty()
+ return deviceLoginCookies(token, userId, code.deviceId)
+ }
+ error("Twitch login code expired")
+}
+
+internal fun deviceLoginCookies(token: String, userId: String, deviceId: String): String =
+ """{"twitch.tv|":{"auth-token":{"key":"auth-token","value":"$token"},"persistent":{"key":"persistent","value":"$userId"},"unique_id":{"key":"unique_id","value":"$deviceId"}}}"""
+
+private fun postForm(url: String, fields: Map, deviceId: String): String {
+ val body = fields.entries.joinToString("&") { (key, value) ->
+ "${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}"
+ }
+ val connection = (URL(url).openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ connectTimeout = 10_000
+ readTimeout = 10_000
+ doOutput = true
+ setRequestProperty("Client-Id", ANDROID_APP_CLIENT_ID)
+ setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
+ setRequestProperty("X-Device-Id", deviceId)
+ }
+ return try {
+ connection.outputStream.use { it.write(body.toByteArray()) }
+ val stream = if (connection.responseCode < 400) connection.inputStream else connection.errorStream
+ val response = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
+ if (connection.responseCode >= 400) error(response)
+ response
+ } finally {
+ connection.disconnect()
+ }
+}
+
+private fun String.jsonInt(name: String): Int? =
+ Regex(""""${Regex.escape(name)}"\s*:\s*(\d+)""").find(this)?.groupValues?.get(1)?.toIntOrNull()
diff --git a/android/app/src/main/java/io/github/himanm/tdminer/TwitchGql.kt b/android/app/src/main/java/io/github/himanm/tdminer/TwitchGql.kt
new file mode 100644
index 0000000..10d69c0
--- /dev/null
+++ b/android/app/src/main/java/io/github/himanm/tdminer/TwitchGql.kt
@@ -0,0 +1,701 @@
+package io.github.himanm.tdminer
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.json.JSONTokener
+import java.io.ByteArrayOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+import java.time.Instant
+import java.util.Base64
+import java.util.zip.GZIPOutputStream
+
+data class TwitchCategory(val id: String, val name: String)
+data class TwitchChannel(
+ val id: String,
+ val login: String,
+ val displayName: String,
+ val broadcastId: String,
+ val gameId: String,
+ val gameName: String,
+ val viewers: Int,
+)
+
+data class TwitchDropSnapshot(
+ val campaignId: String = "",
+ val dropId: String = "",
+ val game: String,
+ val campaign: String,
+ val drop: String,
+ val channel: String = "Twitch",
+ val gameImageUrl: String? = null,
+ val dropImageUrl: String? = null,
+ val rewards: List = emptyList(),
+ val rewardImageUrls: List = emptyList(),
+ val currentMinutes: Int,
+ val requiredMinutes: Int,
+ val remainingSeconds: Int = (requiredMinutes - currentMinutes).coerceAtLeast(0) * 60,
+ val campaignProgress: Float,
+) {
+ val dropProgress: Float = progress(currentMinutes, requiredMinutes)
+ val remaining: String = formatRemainingSeconds(remainingSeconds)
+ val dropKey: String = listOf(campaignId, dropId, game, campaign, drop).joinToString("\u0000")
+}
+
+fun fetchInventorySnapshots(authToken: String): List {
+ return fetchInventorySnapshots(authToken, userId = null, cookieHeader = null, deviceId = null)
+}
+
+fun fetchInventorySnapshots(
+ authToken: String,
+ userId: String?,
+ cookieHeader: String?,
+ deviceId: String?,
+ onCampaignProgress: (Int, Int) -> Unit = { _, _ -> },
+): List {
+ val body = JSONObject()
+ .put("operationName", "Inventory")
+ .put("variables", JSONObject().put("fetchRewardCampaigns", false))
+ .put(
+ "extensions",
+ JSONObject().put(
+ "persistedQuery",
+ JSONObject()
+ .put("version", 1)
+ .put("sha256Hash", INVENTORY_HASH),
+ ),
+ )
+ .toString()
+ val campaignsBody = JSONObject()
+ .put("operationName", "ViewerDropsDashboard")
+ .put("variables", JSONObject().put("fetchRewardCampaigns", false))
+ .put(
+ "extensions",
+ JSONObject().put(
+ "persistedQuery",
+ JSONObject()
+ .put("version", 1)
+ .put("sha256Hash", CAMPAIGNS_HASH),
+ ),
+ )
+ .toString()
+ val inventoryRaw = postGql(authToken, body, cookieHeader, deviceId)
+ val campaignsRaw = postGql(authToken, campaignsBody, cookieHeader, deviceId)
+ val claimedBenefits = parseClaimedBenefits(inventoryRaw)
+ return (parseInventorySnapshots(inventoryRaw) +
+ fetchCampaignDetailSnapshots(authToken, userId, cookieHeader, deviceId, parseDropCampaignIds(campaignsRaw), claimedBenefits, onCampaignProgress))
+ .distinctBy { it.dropKey }
+}
+
+fun fetchInventorySnapshot(
+ authToken: String,
+ priorityGames: List = emptyList(),
+ excludedGames: List = emptyList(),
+): TwitchDropSnapshot? {
+ return selectInventorySnapshot(fetchInventorySnapshots(authToken), priorityGames, excludedGames)
+}
+
+fun fetchDropCategories(authToken: String, userId: String? = null): List {
+ return fetchDropCategories(authToken, userId, cookieHeader = null, deviceId = null)
+}
+
+fun fetchDropCategories(
+ authToken: String,
+ userId: String?,
+ cookieHeader: String?,
+ deviceId: String?,
+): List {
+ val inventoryBody = JSONObject()
+ .put("operationName", "Inventory")
+ .put("variables", JSONObject().put("fetchRewardCampaigns", false))
+ .put(
+ "extensions",
+ JSONObject().put(
+ "persistedQuery",
+ JSONObject()
+ .put("version", 1)
+ .put("sha256Hash", INVENTORY_HASH),
+ ),
+ )
+ .toString()
+ val campaignsBody = JSONObject()
+ .put("operationName", "ViewerDropsDashboard")
+ .put("variables", JSONObject().put("fetchRewardCampaigns", false))
+ .put(
+ "extensions",
+ JSONObject().put(
+ "persistedQuery",
+ JSONObject()
+ .put("version", 1)
+ .put("sha256Hash", CAMPAIGNS_HASH),
+ ),
+ )
+ .toString()
+ val inventoryRaw = postGql(authToken, inventoryBody, cookieHeader, deviceId)
+ val campaignsRaw = postGql(authToken, campaignsBody, cookieHeader, deviceId)
+ return normalizeCategories(
+ parseInventoryCategories(inventoryRaw) +
+ parseDropCategories(campaignsRaw) +
+ fetchCampaignDetailCategories(authToken, userId, cookieHeader, deviceId, parseDropCampaignIds(campaignsRaw)),
+ )
+}
+
+private fun fetchCampaignDetailCategories(
+ authToken: String,
+ userId: String?,
+ cookieHeader: String?,
+ deviceId: String?,
+ campaignIds: List,
+): List {
+ if (campaignIds.isEmpty()) return emptyList()
+ val channelLogin = userId.orEmpty()
+ return campaignIds.flatMap { campaignId ->
+ val body = JSONObject()
+ .put("operationName", "DropCampaignDetails")
+ .put("variables", JSONObject().put("channelLogin", channelLogin).put("dropID", campaignId))
+ .put(
+ "extensions",
+ JSONObject().put(
+ "persistedQuery",
+ JSONObject()
+ .put("version", 1)
+ .put("sha256Hash", CAMPAIGN_DETAILS_HASH),
+ ),
+ )
+ .toString()
+ runCatching { parseCampaignDetailCategories(postGql(authToken, body, cookieHeader, deviceId)) }.getOrDefault(emptyList())
+ }
+}
+
+private fun fetchCampaignDetailSnapshots(
+ authToken: String,
+ userId: String?,
+ cookieHeader: String?,
+ deviceId: String?,
+ campaignIds: List,
+ claimedBenefits: Map,
+ onProgress: (Int, Int) -> Unit,
+): List {
+ if (campaignIds.isEmpty()) return emptyList()
+ val channelLogin = userId.orEmpty()
+ val total = campaignIds.size
+ return campaignIds.flatMapIndexed { index, campaignId ->
+ val body = JSONObject()
+ .put("operationName", "DropCampaignDetails")
+ .put("variables", JSONObject().put("channelLogin", channelLogin).put("dropID", campaignId))
+ .put(
+ "extensions",
+ JSONObject().put(
+ "persistedQuery",
+ JSONObject()
+ .put("version", 1)
+ .put("sha256Hash", CAMPAIGN_DETAILS_HASH),
+ ),
+ )
+ .toString()
+ runCatching { parseCampaignDetailSnapshots(postGql(authToken, body, cookieHeader, deviceId), claimedBenefits) }
+ .also { onProgress(index + 1, total) }
+ .getOrDefault(emptyList())
+ }
+}
+
+fun fetchLiveChannelsForGame(authToken: String, gameName: String, limit: Int = 30): List {
+ val slug = fetchGameSlug(authToken, gameName) ?: gameName.toGameSlug()
+ val body = JSONObject()
+ .put("operationName", "DirectoryPage_Game")
+ .put(
+ "variables",
+ JSONObject()
+ .put("limit", limit)
+ .put("slug", slug)
+ .put("imageWidth", 50)
+ .put("includeCostreaming", false)
+ .put(
+ "options",
+ JSONObject()
+ .put("broadcasterLanguages", JSONArray())
+ .put("freeformTags", JSONObject.NULL)
+ .put("includeRestricted", JSONArray().put("SUB_ONLY_LIVE"))
+ .put("recommendationsContext", JSONObject().put("platform", "web"))
+ .put("sort", "RELEVANCE")
+ .put("systemFilters", JSONArray().put("DROPS_ENABLED"))
+ .put("tags", JSONArray())
+ .put("requestID", "JIRA-VXP-2397"),
+ )
+ .put("sortTypeIsRecency", false),
+ )
+ .put(
+ "extensions",
+ JSONObject().put(
+ "persistedQuery",
+ JSONObject()
+ .put("version", 1)
+ .put("sha256Hash", GAME_DIRECTORY_HASH),
+ ),
+ )
+ .toString()
+ return parseLiveChannels(postGql(authToken, body))
+}
+
+fun fetchGameSlug(authToken: String, gameName: String): String? {
+ val body = JSONObject()
+ .put("operationName", "DirectoryGameRedirect")
+ .put("variables", JSONObject().put("name", gameName))
+ .put(
+ "extensions",
+ JSONObject().put(
+ "persistedQuery",
+ JSONObject()
+ .put("version", 1)
+ .put("sha256Hash", GAME_REDIRECT_HASH),
+ ),
+ )
+ .toString()
+ return parseGameSlug(postGql(authToken, body))
+}
+
+internal fun parseGameSlug(raw: String): String? =
+ JSONObject(raw)
+ .optJSONObject("data")
+ ?.optJSONObject("game")
+ ?.optString("slug")
+ ?.takeIf(String::isNotBlank)
+
+fun sendWatchMinute(authToken: String, userId: String, channel: TwitchChannel): Boolean {
+ val event = JSONObject()
+ .put("event", "minute-watched")
+ .put(
+ "properties",
+ JSONObject()
+ .put("broadcast_id", channel.broadcastId)
+ .put("channel_id", channel.id)
+ .put("channel", channel.login)
+ .put("client_time", Instant.now().toString())
+ .put("game", channel.gameName)
+ .put("game_id", channel.gameId)
+ .put("hidden", false)
+ .put("is_live", true)
+ .put("live", true)
+ .put("logged_in", true)
+ .put("minutes_logged", 1)
+ .put("muted", false)
+ .put("user_id", userId),
+ )
+ val body = JSONObject()
+ .put("query", "\n mutation SendEvents(${'$'}input: SendSpadeEventsInput!) {\n sendSpadeEvents(input: ${'$'}input) {\n statusCode\n}\n}\n")
+ .put(
+ "variables",
+ JSONObject().put(
+ "input",
+ JSONObject()
+ .put("data", gzipBase64(JSONArray().put(event).toString()))
+ .put("repository", "twilight")
+ .put("encoding", "GZIP_B64"),
+ ),
+ )
+ .toString()
+ val statusCode = JSONObject(postGql(authToken, body))
+ .optJSONObject("data")
+ ?.optJSONObject("sendSpadeEvents")
+ ?.optInt("statusCode", 0)
+ ?: 0
+ return statusCode == 204
+}
+
+internal fun parseLiveChannels(raw: String): List {
+ val edges = JSONObject(raw)
+ .optJSONObject("data")
+ ?.optJSONObject("game")
+ ?.optJSONObject("streams")
+ ?.optJSONArray("edges")
+ ?: return emptyList()
+ return buildList {
+ for (index in 0 until edges.length()) {
+ val node = edges.optJSONObject(index)?.optJSONObject("node") ?: continue
+ val broadcaster = node.optJSONObject("broadcaster") ?: continue
+ val game = node.optJSONObject("game") ?: continue
+ val channelId = broadcaster.optString("id")
+ val login = broadcaster.optString("login")
+ val broadcastId = node.optString("id")
+ val gameId = game.optString("id")
+ val name = gameName(game).orFallback("")
+ if (channelId.isBlank() || login.isBlank() || broadcastId.isBlank() || gameId.isBlank() || name.isBlank()) continue
+ add(
+ TwitchChannel(
+ id = channelId,
+ login = login,
+ displayName = broadcaster.optString("displayName").orFallback(login),
+ broadcastId = broadcastId,
+ gameId = gameId,
+ gameName = name,
+ viewers = node.optInt("viewersCount", 0),
+ ),
+ )
+ }
+ }
+}
+
+internal fun parseInventoryCategories(raw: String): List {
+ val campaigns = JSONObject(raw)
+ .optJSONObject("data")
+ ?.optJSONObject("currentUser")
+ ?.optJSONObject("inventory")
+ ?.optJSONArray("dropCampaignsInProgress")
+ ?: return emptyList()
+ val activeOrUpcoming = JSONArray()
+ for (index in 0 until campaigns.length()) {
+ val campaign = campaigns.optJSONObject(index) ?: continue
+ if (campaign.isActiveOrUpcomingCampaign()) activeOrUpcoming.put(campaign)
+ }
+ return campaignGames(activeOrUpcoming)
+}
+
+internal fun parseDropCategories(raw: String): List {
+ val campaigns = JSONObject(raw)
+ .optJSONObject("data")
+ ?.optJSONObject("currentUser")
+ ?.optJSONArray("dropCampaigns")
+ ?: return emptyList()
+ val activeCampaigns = JSONArray()
+ for (index in 0 until campaigns.length()) {
+ val campaign = campaigns.optJSONObject(index) ?: continue
+ if (campaign.optString("status") in setOf("ACTIVE", "UPCOMING", "") && campaign.isActiveOrUpcomingCampaign()) {
+ activeCampaigns.put(campaign)
+ }
+ }
+ return campaignGames(activeCampaigns)
+}
+
+internal fun parseDropCampaignIds(raw: String): List {
+ val campaigns = JSONObject(raw)
+ .optJSONObject("data")
+ ?.optJSONObject("currentUser")
+ ?.optJSONArray("dropCampaigns")
+ ?: return emptyList()
+ return buildList {
+ for (index in 0 until campaigns.length()) {
+ val campaign = campaigns.optJSONObject(index) ?: continue
+ val id = campaign.optString("id")
+ if (id.isNotBlank() && campaign.optString("status") in setOf("ACTIVE", "UPCOMING", "") && campaign.isActiveOrUpcomingCampaign()) {
+ add(id)
+ }
+ }
+ }
+}
+
+internal fun parseCampaignDetailCategories(raw: String): List {
+ val value = JSONTokener(raw).nextValue()
+ val responses = when (value) {
+ is JSONArray -> value.objects()
+ is JSONObject -> listOf(value)
+ else -> emptyList()
+ }
+ return responses.mapNotNull { response ->
+ val campaign = response
+ .optJSONObject("data")
+ ?.optJSONObject("user")
+ ?.optJSONObject("dropCampaign")
+ categoryFromGame(campaign?.optJSONObject("game"))
+ }
+}
+
+internal fun parseCampaignDetailSnapshots(raw: String, claimedBenefits: Map = emptyMap()): List {
+ val value = JSONTokener(raw).nextValue()
+ val responses = when (value) {
+ is JSONArray -> value.objects()
+ is JSONObject -> listOf(value)
+ else -> emptyList()
+ }
+ return responses.flatMap { response ->
+ val campaign = response
+ .optJSONObject("data")
+ ?.optJSONObject("user")
+ ?.optJSONObject("dropCampaign")
+ campaignSnapshot(campaign, claimedBenefits)?.let(::listOf) ?: emptyList()
+ }
+}
+
+private fun campaignSnapshot(campaign: JSONObject?, claimedBenefits: Map = emptyMap()): TwitchDropSnapshot? {
+ if (campaign == null || !campaign.isActiveCampaign()) return null
+ val drops = campaign.optJSONArray("timeBasedDrops") ?: JSONArray()
+ val drop = firstEarnableDrop(drops, claimedBenefits) ?: return null
+ val current = drop.optJSONObject("self")?.optInt("currentMinutesWatched", 0) ?: 0
+ val required = drop.optInt("requiredMinutesWatched", 0)
+ if (required <= 0) return null
+ val game = campaign.optJSONObject("game")
+ val rewards = rewardNames(drop)
+ val rewardImages = rewardImages(drop)
+ return TwitchDropSnapshot(
+ campaignId = campaign.optString("id"),
+ dropId = drop.optString("id").orFallback(drop.optString("dropID")),
+ game = gameName(game).orFallback("Unknown game"),
+ campaign = campaign.optString("name").orFallback("Drops campaign"),
+ drop = drop.optString("name").orFallback(rewards.firstOrNull() ?: "Drop reward"),
+ gameImageUrl = imageUrl(game?.optString("boxArtURL")),
+ dropImageUrl = imageUrl(drop.deepString("imageAssetURL", "imageURL", "imageUrl") ?: rewardImages.firstOrNull()),
+ rewards = rewards,
+ rewardImageUrls = rewardImages,
+ currentMinutes = current,
+ requiredMinutes = required,
+ campaignProgress = campaignProgress(drops),
+ )
+}
+
+private fun campaignGames(campaigns: JSONArray): List {
+ return buildList {
+ for (index in 0 until campaigns.length()) {
+ val game = campaigns.optJSONObject(index)?.optJSONObject("game") ?: continue
+ categoryFromGame(game)?.let(::add)
+ }
+ }
+}
+
+private fun categoryFromGame(game: JSONObject?): TwitchCategory? {
+ val id = game?.optString("id").orEmpty()
+ val name = gameName(game).orFallback("")
+ return if (id.isNotBlank() && name.isNotBlank()) TwitchCategory(id, name) else null
+}
+
+private fun normalizeCategories(categories: List): List =
+ categories.distinctBy { it.id }.sortedBy { it.name.lowercase() }
+
+internal fun String.toGameSlug(): String =
+ lowercase()
+ .replace("'", "")
+ .replace(Regex("\\W+"), "-")
+ .trim('-')
+ .replace(Regex("-{2,}"), "-")
+
+private fun gzipBase64(text: String): String {
+ val output = ByteArrayOutputStream()
+ GZIPOutputStream(output).use { it.write(text.toByteArray(Charsets.UTF_8)) }
+ return Base64.getEncoder().encodeToString(output.toByteArray())
+}
+
+private fun gameName(game: JSONObject?): String? =
+ game?.optString("displayName").orFallback(game?.optString("name") ?: "")
+
+internal fun parseInventorySnapshot(raw: String): TwitchDropSnapshot? {
+ return parseInventorySnapshots(raw).minByOrNull { it.remainingSeconds }
+}
+
+internal fun parseInventorySnapshots(raw: String): List {
+ val root = JSONObject(raw)
+ val claimedBenefits = parseClaimedBenefits(root)
+ val campaigns = root
+ .optJSONObject("data")
+ ?.optJSONObject("currentUser")
+ ?.optJSONObject("inventory")
+ ?.optJSONArray("dropCampaignsInProgress")
+ ?: return emptyList()
+ return buildList {
+ for (campaignIndex in 0 until campaigns.length()) {
+ val campaign = campaigns.optJSONObject(campaignIndex) ?: continue
+ if (!campaign.isActiveCampaign()) continue
+ val drops = campaign.optJSONArray("timeBasedDrops") ?: JSONArray()
+ val drop = firstEarnableDrop(drops, claimedBenefits) ?: continue
+ val current = drop.optJSONObject("self")?.optInt("currentMinutesWatched", 0) ?: 0
+ val required = drop.optInt("requiredMinutesWatched", 0)
+ if (required <= 0) continue
+ val game = campaign.optJSONObject("game")
+ val rewards = rewardNames(drop)
+ val rewardImages = rewardImages(drop)
+ add(
+ TwitchDropSnapshot(
+ campaignId = campaign.optString("id"),
+ dropId = drop.optString("id").orFallback(drop.optString("dropID")),
+ game = gameName(game).orFallback("Unknown game"),
+ campaign = campaign.optString("name").orFallback("Drops campaign"),
+ drop = drop.optString("name").orFallback(rewards.firstOrNull() ?: "Drop reward"),
+ gameImageUrl = imageUrl(game?.optString("boxArtURL")),
+ dropImageUrl = imageUrl(drop.deepString("imageAssetURL", "imageURL", "imageUrl") ?: rewardImages.firstOrNull()),
+ rewards = rewards,
+ rewardImageUrls = rewardImages,
+ currentMinutes = current,
+ requiredMinutes = required,
+ campaignProgress = campaignProgress(drops),
+ ),
+ )
+ }
+ }
+}
+
+private fun JSONObject.isActiveCampaign(now: Instant = Instant.now()): Boolean {
+ if (optString("status").equals("EXPIRED", ignoreCase = true)) return false
+ val start = parseInstant("startAt")
+ val end = parseInstant("endAt")
+ return (start == null || !now.isBefore(start)) && (end == null || now.isBefore(end))
+}
+
+private fun JSONObject.isActiveOrUpcomingCampaign(now: Instant = Instant.now()): Boolean {
+ if (optString("status").equals("EXPIRED", ignoreCase = true)) return false
+ val end = parseInstant("endAt")
+ return end == null || now.isBefore(end)
+}
+
+private fun JSONObject.parseInstant(key: String): Instant? =
+ optString(key).takeIf(String::isNotBlank)?.let { runCatching { Instant.parse(it) }.getOrNull() }
+
+internal fun selectInventorySnapshot(
+ snapshots: List,
+ priorityGames: List,
+ excludedGames: List,
+): TwitchDropSnapshot? {
+ val priority = priorityGames.map { it.lowercase() }
+ if (priority.isEmpty()) return null
+ val allowed = filterPrioritySnapshots(snapshots, priorityGames, excludedGames)
+ return priority.firstNotNullOfOrNull { wanted ->
+ allowed
+ .filter { gameMatches(wanted, it.game.lowercase()) && it.requiredMinutes > 0 && it.currentMinutes < it.requiredMinutes }
+ .minByOrNull { it.remainingSeconds }
+ }
+}
+
+internal fun filterPrioritySnapshots(
+ snapshots: List,
+ priorityGames: List,
+ excludedGames: List,
+): List {
+ val priority = priorityGames.map { it.lowercase() }
+ if (priority.isEmpty()) return emptyList()
+ val excluded = excludedGames.map { it.lowercase() }.toSet()
+ return priority.flatMap { wanted ->
+ snapshots
+ .filterNot { it.game.lowercase() in excluded }
+ .filter { gameMatches(wanted, it.game.lowercase()) }
+ .sortedWith(compareBy { it.currentMinutes >= it.requiredMinutes }.thenBy { it.remainingSeconds })
+ }
+}
+
+private fun gameMatches(wanted: String, actual: String): Boolean =
+ wanted == actual || wanted in actual || actual in wanted
+
+private fun postGql(authToken: String, body: String, cookieHeader: String? = null, deviceId: String? = null): String {
+ val connection = (URL("https://gql.twitch.tv/gql").openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ connectTimeout = 10_000
+ readTimeout = 10_000
+ doOutput = true
+ setRequestProperty("Client-Id", ANDROID_APP_CLIENT_ID)
+ setRequestProperty("User-Agent", ANDROID_APP_USER_AGENT)
+ setRequestProperty("Accept", "*/*")
+ setRequestProperty("Accept-Language", "en-US")
+ setRequestProperty("Pragma", "no-cache")
+ setRequestProperty("Cache-Control", "no-cache")
+ setRequestProperty("Authorization", "OAuth $authToken")
+ setRequestProperty("Origin", "https://www.twitch.tv")
+ setRequestProperty("Referer", "https://www.twitch.tv")
+ setRequestProperty("Content-Type", "application/json")
+ if (!cookieHeader.isNullOrBlank()) setRequestProperty("Cookie", cookieHeader)
+ if (!deviceId.isNullOrBlank()) setRequestProperty("X-Device-Id", deviceId)
+ }
+ return try {
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
+ val stream = if (connection.responseCode < 400) connection.inputStream else connection.errorStream
+ stream.bufferedReader().use { it.readText() }
+ } finally {
+ connection.disconnect()
+ }
+}
+
+private fun firstEarnableDrop(drops: JSONArray, claimedBenefits: Map = emptyMap()): JSONObject? {
+ for (index in 0 until drops.length()) {
+ val drop = drops.optJSONObject(index) ?: continue
+ if (drop.optInt("requiredMinutesWatched", 0) <= 0) continue
+ val self = drop.optJSONObject("self")
+ val current = self?.optInt("currentMinutesWatched", 0) ?: 0
+ val claimed = self?.optBoolean("isClaimed", false) ?: drop.wasClaimedAsGameEvent(claimedBenefits)
+ if (!claimed && current < drop.optInt("requiredMinutesWatched", 0)) return drop
+ }
+ return null
+}
+
+private fun parseClaimedBenefits(raw: String): Map = parseClaimedBenefits(JSONObject(raw))
+
+private fun parseClaimedBenefits(root: JSONObject): Map {
+ val events = root.optJSONObject("data")?.optJSONObject("currentUser")?.optJSONObject("inventory")
+ ?.optJSONArray("gameEventDrops") ?: return emptyMap()
+ return buildMap {
+ for (index in 0 until events.length()) {
+ val event = events.optJSONObject(index) ?: continue
+ val id = event.optString("id")
+ val awarded = event.parseInstant("lastAwardedAt")
+ if (id.isNotBlank() && awarded != null) put(id, awarded)
+ }
+ }
+}
+
+private fun JSONObject.wasClaimedAsGameEvent(claimedBenefits: Map): Boolean {
+ val start = parseInstant("startAt") ?: return false
+ val end = parseInstant("endAt") ?: return false
+ val benefits = optJSONArray("benefitEdges") ?: return false
+ val awards = benefits.objects().mapNotNull { edge ->
+ claimedBenefits[edge.optJSONObject("benefit")?.optString("id")]
+ }
+ return awards.isNotEmpty() && awards.all { !it.isBefore(start) && it.isBefore(end) }
+}
+
+private fun campaignProgress(drops: JSONArray): Float {
+ var current = 0
+ var required = 0
+ for (index in 0 until drops.length()) {
+ val drop = drops.optJSONObject(index) ?: continue
+ val dropRequired = drop.optInt("requiredMinutesWatched", 0).coerceAtLeast(0)
+ required += dropRequired
+ current += (drop.optJSONObject("self")?.optInt("currentMinutesWatched", 0) ?: 0).coerceAtMost(dropRequired)
+ }
+ return progress(current, required)
+}
+
+private fun rewardNames(drop: JSONObject): List =
+ drop.optJSONArray("benefitEdges")?.objects()
+ ?.mapNotNull { it.optJSONObject("benefit")?.optString("name")?.takeIf(String::isNotBlank) }
+ ?: emptyList()
+
+private fun rewardImages(drop: JSONObject): List =
+ drop.optJSONArray("benefitEdges")?.objects()
+ ?.mapNotNull { it.optJSONObject("benefit")?.deepString("imageAssetURL", "imageURL", "imageUrl")?.let(::imageUrl) }
+ ?: emptyList()
+
+private fun JSONArray.objects(): List =
+ buildList {
+ for (index in 0 until length()) optJSONObject(index)?.let(::add)
+ }
+
+private fun JSONObject.deepString(vararg keys: String): String? {
+ for (key in keys) optString(key).takeIf(String::isNotBlank)?.let { return it }
+ for (key in keys()) {
+ when (val value = opt(key)) {
+ is JSONObject -> value.deepString(*keys)?.let { return it }
+ is JSONArray -> value.objects().firstNotNullOfOrNull { it.deepString(*keys) }?.let { return it }
+ }
+ }
+ return null
+}
+
+private fun imageUrl(url: String?): String? =
+ url?.takeIf(String::isNotBlank)
+ ?.replace("{width}", "300")
+ ?.replace("{height}", "400")
+
+private fun progress(current: Int, required: Int): Float =
+ if (required <= 0) 0f else (current.toFloat() / required.toFloat()).coerceIn(0f, 1f)
+
+internal fun formatRemainingSeconds(seconds: Int): String {
+ val safeSeconds = seconds.coerceAtLeast(0)
+ val hours = safeSeconds / 3600
+ val mins = (safeSeconds % 3600) / 60
+ val secs = safeSeconds % 60
+ return "%02d:%02d:%02d".format(hours, mins, secs)
+}
+
+private fun String?.orFallback(fallback: String): String =
+ if (isNullOrBlank()) fallback else this
+
+internal const val ANDROID_APP_CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp"
+private const val ANDROID_APP_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 16; SM-S911B Build/TP1A.220624.014) tv.twitch.android.app/25.3.0/2503006"
+private const val INVENTORY_HASH = "d86775d0ef16a63a33ad52e80eaff963b2d5b72fada7c991504a57496e1d8e4b"
+private const val CAMPAIGNS_HASH = "5a4da2ab3d5b47c9f9ce864e727b2cb346af1e3ea8b897fe8f704a97ff017619"
+private const val CAMPAIGN_DETAILS_HASH = "039277bf98f3130929262cc7c6efd9c141ca3749cb6dca442fc8ead9a53f77c1"
+private const val GAME_DIRECTORY_HASH = "cb5dc816e139dcb8a118f14b4b677d59abc224a4b016c4bc2bb00a47fe0ddec4"
+private const val GAME_REDIRECT_HASH = "1f0300090caceec51f33c5e20647aceff9017f740f223c3c532ba6fa59f6b6cc"
diff --git a/android/app/src/main/res/drawable-nodpi/tdminer_app_icon.png b/android/app/src/main/res/drawable-nodpi/tdminer_app_icon.png
new file mode 100644
index 0000000..4893d4e
Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/tdminer_app_icon.png differ
diff --git a/android/app/src/main/res/drawable-nodpi/tdminer_icon_foreground.png b/android/app/src/main/res/drawable-nodpi/tdminer_icon_foreground.png
new file mode 100644
index 0000000..e2bff90
Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/tdminer_icon_foreground.png differ
diff --git a/android/app/src/main/res/drawable-nodpi/tdminer_icon_source.png b/android/app/src/main/res/drawable-nodpi/tdminer_icon_source.png
new file mode 100644
index 0000000..1ebb0a8
Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/tdminer_icon_source.png differ
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..aecb6c8
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_panel.xml b/android/app/src/main/res/drawable/launch_panel.xml
new file mode 100644
index 0000000..df02d75
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_panel.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_progress.xml b/android/app/src/main/res/drawable/launch_progress.xml
new file mode 100644
index 0000000..262f31c
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_progress.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_screen.xml b/android/app/src/main/res/drawable/launch_screen.xml
new file mode 100644
index 0000000..9884d3e
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_screen.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/android/app/src/main/res/font/roboto_condensed_black.ttf b/android/app/src/main/res/font/roboto_condensed_black.ttf
new file mode 100644
index 0000000..5f16f32
Binary files /dev/null and b/android/app/src/main/res/font/roboto_condensed_black.ttf differ
diff --git a/android/app/src/main/res/font/roboto_condensed_bold.ttf b/android/app/src/main/res/font/roboto_condensed_bold.ttf
new file mode 100644
index 0000000..b50fae8
Binary files /dev/null and b/android/app/src/main/res/font/roboto_condensed_bold.ttf differ
diff --git a/android/app/src/main/res/font/roboto_condensed_regular.ttf b/android/app/src/main/res/font/roboto_condensed_regular.ttf
new file mode 100644
index 0000000..6268836
Binary files /dev/null and b/android/app/src/main/res/font/roboto_condensed_regular.ttf differ
diff --git a/android/app/src/main/res/layout/miner_widget.xml b/android/app/src/main/res/layout/miner_widget.xml
new file mode 100644
index 0000000..5a3433c
--- /dev/null
+++ b/android/app/src/main/res/layout/miner_widget.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..ee52017
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..4b18e35
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+ #101513
+ #101513
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..6a214db
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ TD Miner
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..9b882c1
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/xml/miner_widget_info.xml b/android/app/src/main/res/xml/miner_widget_info.xml
new file mode 100644
index 0000000..6e198cc
--- /dev/null
+++ b/android/app/src/main/res/xml/miner_widget_info.xml
@@ -0,0 +1,11 @@
+
diff --git a/android/app/src/test/java/io/github/himanm/tdminer/CookieStoreTest.kt b/android/app/src/test/java/io/github/himanm/tdminer/CookieStoreTest.kt
new file mode 100644
index 0000000..2f2de00
--- /dev/null
+++ b/android/app/src/test/java/io/github/himanm/tdminer/CookieStoreTest.kt
@@ -0,0 +1,24 @@
+package io.github.himanm.tdminer
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CookieStoreTest {
+ @Test
+ fun persistsCookiesUntilLogout() {
+ val store = MemoryCookieStore()
+
+ store.saveCookies("auth-token=cookie-value")
+
+ assertTrue(store.hasCookies())
+ assertEquals("auth-token=cookie-value", store.loadCookies())
+
+ store.logout()
+
+ assertFalse(store.hasCookies())
+ assertNull(store.loadCookies())
+ }
+}
diff --git a/android/app/src/test/java/io/github/himanm/tdminer/DesktopCookieImportTest.kt b/android/app/src/test/java/io/github/himanm/tdminer/DesktopCookieImportTest.kt
new file mode 100644
index 0000000..1c580ba
--- /dev/null
+++ b/android/app/src/test/java/io/github/himanm/tdminer/DesktopCookieImportTest.kt
@@ -0,0 +1,15 @@
+package io.github.himanm.tdminer
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DesktopCookieImportTest {
+ @Test
+ fun desktopCookieJsonCanBeStoredAsSessionCookies() {
+ val core = MinerCore(MemoryCookieStore())
+
+ core.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"secret"}}}""")
+
+ assertTrue(core.session.loggedIn)
+ }
+}
diff --git a/android/app/src/test/java/io/github/himanm/tdminer/MinerCoreTest.kt b/android/app/src/test/java/io/github/himanm/tdminer/MinerCoreTest.kt
new file mode 100644
index 0000000..92759e1
--- /dev/null
+++ b/android/app/src/test/java/io/github/himanm/tdminer/MinerCoreTest.kt
@@ -0,0 +1,198 @@
+package io.github.himanm.tdminer
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MinerCoreTest {
+ @Test
+ fun startUsesPersistedCookies() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+
+ assertFalse(core.session.loggedIn)
+
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"cookie-value"}}}""")
+ core.start()
+
+ assertTrue(core.session.running)
+ assertTrue(core.session.loggedIn)
+ }
+
+ @Test
+ fun placeholderCookiesDoNotCountAsLoggedIn() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+
+ store.saveCookies("demo-auth-cookie")
+ core.start()
+
+ assertTrue(core.session.running)
+ assertFalse(core.session.loggedIn)
+ assertFalse(core.session.authReady)
+ }
+
+ @Test
+ fun startParsesDesktopCookieJarAuthToken() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"oauth-token"},"persistent":{"value":"464062006"}}}""")
+
+ core.start()
+
+ assertTrue(core.session.authReady)
+ }
+
+ @Test
+ fun validateAuthKeepsValidTokenReady() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"oauth-token"}}}""")
+
+ core.start()
+ core.validateAuth { TwitchAuthResult(true, "464062006") }
+
+ assertTrue(core.session.loggedIn)
+ assertTrue(core.session.authReady)
+ }
+
+ @Test
+ fun validateAuthRejectsInvalidToken() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"expired-token"}}}""")
+
+ core.start()
+ core.validateAuth { TwitchAuthResult(false) }
+
+ assertFalse(core.session.loggedIn)
+ assertFalse(core.session.authReady)
+ }
+
+ @Test
+ fun refreshDropsUpdatesSessionFromInventorySnapshot() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"oauth-token"}}}""")
+
+ core.start()
+ core.refreshDrops(MinerSettings(priorityGames = listOf("Detroit: Become Human"))) { _, _ ->
+ listOf(TwitchDropSnapshot(
+ game = "Detroit: Become Human",
+ campaign = "Detroit Badge Drop",
+ drop = "Android Triangle",
+ currentMinutes = 15,
+ requiredMinutes = 60,
+ campaignProgress = 0.25f,
+ ))
+ }
+
+ assertEquals("Detroit: Become Human", core.session.game)
+ assertEquals("Detroit Badge Drop", core.session.campaign)
+ assertEquals("Android Triangle", core.session.drop)
+ }
+
+ @Test
+ fun emptyPriorityDoesNotFetchOrDisplayDrops() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"oauth-token"}}}""")
+ var fetched = false
+
+ core.start()
+ core.refreshDrops { _, _ ->
+ fetched = true
+ listOf(TwitchDropSnapshot(
+ game = "Overwatch",
+ campaign = "OWCS",
+ drop = "Reward",
+ currentMinutes = 1,
+ requiredMinutes = 60,
+ campaignProgress = 0.01f,
+ ))
+ }
+
+ assertFalse(fetched)
+ assertEquals("No priority selected", core.session.game)
+ assertEquals(emptyList(), core.session.drops)
+ }
+
+ @Test
+ fun refreshDropsPassesPriorityAndExcludedSettings() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"oauth-token"}}}""")
+
+ core.start()
+ core.refreshDrops(MinerSettings(priorityGames = listOf("Overwatch"), excludedGames = listOf("Detroit"))) { _, _ ->
+ listOf(TwitchDropSnapshot(
+ game = "Overwatch",
+ campaign = "OWCS",
+ drop = "Battle Pass Tier Skip",
+ currentMinutes = 1,
+ requiredMinutes = 60,
+ campaignProgress = 0.01f,
+ ))
+ }
+
+ assertEquals("Overwatch", core.session.game)
+ }
+
+ @Test
+ fun watchOnceDoesNothingWhenIdle() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"oauth-token"},"persistent":{"value":"464062006"}}}""")
+ var watched = false
+
+ core.refreshDrops(MinerSettings(priorityGames = listOf("Overwatch"))) { _, _ ->
+ listOf(TwitchDropSnapshot(
+ game = "Overwatch",
+ campaign = "OWCS",
+ drop = "Reward",
+ currentMinutes = 1,
+ requiredMinutes = 60,
+ campaignProgress = 0.01f,
+ ))
+ }
+ core.watchOnce(
+ channelFetcher = { _, _ ->
+ listOf(TwitchChannel("1", "login", "Display", "b1", "g1", "Overwatch", 10))
+ },
+ watcher = { _, _, _ ->
+ watched = true
+ true
+ },
+ )
+
+ assertFalse(watched)
+ assertEquals(emptyList(), core.session.channels)
+ }
+
+ @Test
+ fun refreshDropsClearsSessionWhenPriorityHasNoActiveMatch() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"oauth-token"}}}""")
+
+ core.start()
+ core.refreshDrops(MinerSettings(priorityGames = listOf("Overwatch"))) { _, _ -> emptyList() }
+
+ assertEquals("No priority drop", core.session.game)
+ assertEquals("No matching drop", core.session.channel)
+ }
+
+ fun logoutStopsAndClearsCookies() {
+ val store = MemoryCookieStore()
+ val core = MinerCore(store)
+ store.saveCookies("""{"twitch.tv|":{"auth-token":{"value":"cookie-value"}}}""")
+
+ core.start()
+ core.logout()
+
+ assertFalse(core.session.running)
+ assertFalse(core.session.loggedIn)
+ assertFalse(store.hasCookies())
+ }
+}
diff --git a/android/app/src/test/java/io/github/himanm/tdminer/MinerSessionTest.kt b/android/app/src/test/java/io/github/himanm/tdminer/MinerSessionTest.kt
new file mode 100644
index 0000000..3937ab6
--- /dev/null
+++ b/android/app/src/test/java/io/github/himanm/tdminer/MinerSessionTest.kt
@@ -0,0 +1,31 @@
+package io.github.himanm.tdminer
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MinerSessionTest {
+ @Test
+ fun runningSessionStartsInLoadingStateAndReportsServiceFlags() {
+ val session = MinerSession.running()
+
+ assertTrue(session.running)
+ assertEquals("Finding channel", session.channel)
+ assertEquals(0f, session.campaignProgress, 0.001f)
+ assertEquals(0f, session.dropProgress, 0.001f)
+ assertTrue(session.wakeLockActive)
+ assertTrue(session.notificationActive)
+ }
+
+ @Test
+ fun idleSessionKeepsBackgroundWorkOff() {
+ val session = MinerSession.idle()
+
+ assertFalse(session.running)
+ assertEquals("Not watching", session.channel)
+ assertEquals("Ready", session.game)
+ assertFalse(session.wakeLockActive)
+ assertFalse(session.notificationActive)
+ }
+}
diff --git a/android/app/src/test/java/io/github/himanm/tdminer/MinerSettingsStoreTest.kt b/android/app/src/test/java/io/github/himanm/tdminer/MinerSettingsStoreTest.kt
new file mode 100644
index 0000000..91d2e26
--- /dev/null
+++ b/android/app/src/test/java/io/github/himanm/tdminer/MinerSettingsStoreTest.kt
@@ -0,0 +1,58 @@
+package io.github.himanm.tdminer
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class MinerSettingsStoreTest {
+ @Test
+ fun defaultsDoNotContainPlaceholderGames() {
+ val settings = MinerSettings()
+
+ assertEquals(emptyList(), settings.priorityGames)
+ assertEquals(emptyList(), settings.excludedGames)
+ }
+
+ @Test
+ fun categoryCacheIsFreshForOneHour() {
+ val cache = CategoryCache(listOf(TwitchCategory("1", "Overwatch")), savedAtMillis = 1_000)
+
+ assertEquals(true, cache.isFresh(nowMillis = 1_000 + 59 * 60 * 1000L))
+ assertEquals(false, cache.isFresh(nowMillis = 1_000 + 61 * 60 * 1000L))
+ }
+
+ @Test
+ fun memoryStorePersistsCategoryCache() {
+ val store = MemoryMinerSettingsStore()
+
+ store.saveCategoryCache(listOf(TwitchCategory("1", "Overwatch")), savedAtMillis = 123)
+
+ assertEquals("Overwatch", store.loadCategoryCache().categories[0].name)
+ assertEquals(123, store.loadCategoryCache().savedAtMillis)
+ }
+
+ @Test
+ fun normalizeSettingsListRemovesBlanksAndDuplicates() {
+ val normalized = normalizeSettingsList(
+ listOf(" Detroit: Become Human ", "", "Just Chatting", "Detroit: Become Human"),
+ )
+
+ assertEquals(listOf("Detroit: Become Human", "Just Chatting"), normalized)
+ }
+
+ @Test
+ fun memoryStoreNormalizesSavedLists() {
+ val store = MemoryMinerSettingsStore()
+
+ store.save(
+ MinerSettings(
+ priorityGames = listOf("Minecraft", " Minecraft "),
+ excludedGames = listOf("", "Slots"),
+ notificationsEnabled = false,
+ ),
+ )
+
+ assertEquals(listOf("Minecraft"), store.load().priorityGames)
+ assertEquals(listOf("Slots"), store.load().excludedGames)
+ assertEquals(false, store.load().notificationsEnabled)
+ }
+}
diff --git a/android/app/src/test/java/io/github/himanm/tdminer/TwitchAuthTest.kt b/android/app/src/test/java/io/github/himanm/tdminer/TwitchAuthTest.kt
new file mode 100644
index 0000000..58eb41a
--- /dev/null
+++ b/android/app/src/test/java/io/github/himanm/tdminer/TwitchAuthTest.kt
@@ -0,0 +1,11 @@
+package io.github.himanm.tdminer
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TwitchAuthTest {
+ @Test
+ fun extractsUserIdFromValidateResponse() {
+ assertEquals("464062006", """{"client_id":"x","user_id":"464062006","login":"him"}""".jsonValue("user_id"))
+ }
+}
diff --git a/android/app/src/test/java/io/github/himanm/tdminer/TwitchCookieJarTest.kt b/android/app/src/test/java/io/github/himanm/tdminer/TwitchCookieJarTest.kt
new file mode 100644
index 0000000..4728a53
--- /dev/null
+++ b/android/app/src/test/java/io/github/himanm/tdminer/TwitchCookieJarTest.kt
@@ -0,0 +1,64 @@
+package io.github.himanm.tdminer
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TwitchCookieJarTest {
+ @Test
+ fun deviceLoginTokenConvertsToCookieJar() {
+ val jar = TwitchCookieJar.parse(deviceLoginCookies("token", "42", "device"))
+
+ assertEquals("token", jar.authToken)
+ assertEquals("42", jar.userId)
+ assertEquals("device", jar.deviceId)
+ }
+
+ @Test
+ fun extractsAuthTokenAndPersistentUserIdFromDesktopCookieJson() {
+ val jar = TwitchCookieJar.parse(
+ """
+ {
+ "twitch.tv|": {
+ "auth-token": {"value": "oauth-token"},
+ "persistent": {"value": "464062006"}
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals("oauth-token", jar.authToken)
+ assertEquals("464062006", jar.userId)
+ assertTrue(jar.hasAuthToken)
+ }
+
+ @Test
+ fun buildsCookieHeaderAndDeviceIdFromDesktopCookieJson() {
+ val jar = TwitchCookieJar.parse(
+ """
+ {
+ "twitch.tv|": {
+ "unique_id": {"key": "unique_id", "value": "device-1"},
+ "auth-token": {"key": "auth-token", "value": "oauth-token"}
+ },
+ "www.twitch.tv|": {
+ "unique_id": {"key": "unique_id", "value": "device-2"},
+ "persistent": {"key": "persistent", "value": "464062006"}
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals("device-1", jar.deviceId)
+ assertTrue(jar.cookieHeader.contains("auth-token=oauth-token"))
+ assertTrue(jar.cookieHeader.contains("persistent=464062006"))
+ }
+
+ @Test
+ fun treatsMissingAuthTokenAsLoggedOut() {
+ val jar = TwitchCookieJar.parse("""{"twitch.tv|":{}}""")
+
+ assertFalse(jar.hasAuthToken)
+ }
+}
diff --git a/android/app/src/test/java/io/github/himanm/tdminer/TwitchGqlTest.kt b/android/app/src/test/java/io/github/himanm/tdminer/TwitchGqlTest.kt
new file mode 100644
index 0000000..bb489c6
--- /dev/null
+++ b/android/app/src/test/java/io/github/himanm/tdminer/TwitchGqlTest.kt
@@ -0,0 +1,453 @@
+package io.github.himanm.tdminer
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class TwitchGqlTest {
+ @Test
+ fun parsesInventorySnapshot() {
+ val snapshot = parseInventorySnapshot(
+ """
+ {
+ "data": {
+ "currentUser": {
+ "inventory": {
+ "dropCampaignsInProgress": [{
+ "name": "Detroit Badge Drop",
+ "game": {"displayName": "Detroit: Become Human"},
+ "timeBasedDrops": [{
+ "name": "Android Triangle",
+ "requiredMinutesWatched": 60,
+ "self": {"currentMinutesWatched": 15, "isClaimed": false},
+ "benefitEdges": [{"benefit": {"name": "Triangle"}}]
+ }]
+ }]
+ }
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertNotNull(snapshot)
+ assertEquals("Detroit: Become Human", snapshot!!.game)
+ assertEquals("Detroit Badge Drop", snapshot.campaign)
+ assertEquals("Android Triangle", snapshot.drop)
+ assertEquals("00:45:00", snapshot.remaining)
+ assertEquals(0.25f, snapshot.dropProgress)
+ }
+
+ @Test
+ fun parsesInventorySnapshotGameNameFallback() {
+ val snapshot = parseInventorySnapshot(
+ """
+ {
+ "data": {
+ "currentUser": {
+ "inventory": {
+ "dropCampaignsInProgress": [{
+ "name": "OWCS S2 Campaign 3",
+ "game": {"name": "Overwatch 2"},
+ "timeBasedDrops": [{
+ "name": "Battle Pass Tier Skip",
+ "requiredMinutesWatched": 180,
+ "self": {"currentMinutesWatched": 4, "isClaimed": false}
+ }]
+ }]
+ }
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals("Overwatch 2", snapshot!!.game)
+ }
+
+ @Test
+ fun skipsSubOnlyDropsButKeepsTimedDropInSameCategory() {
+ val snapshot = parseInventorySnapshot(
+ """
+ {
+ "data": {
+ "currentUser": {
+ "inventory": {
+ "dropCampaignsInProgress": [{
+ "name": "Mixed Campaign",
+ "game": {"displayName": "Example Game"},
+ "timeBasedDrops": [
+ {
+ "name": "Sub-only reward",
+ "requiredMinutesWatched": 0,
+ "self": {"currentMinutesWatched": 0, "isClaimed": false}
+ },
+ {
+ "name": "Timed reward",
+ "requiredMinutesWatched": 60,
+ "self": {"currentMinutesWatched": 12, "isClaimed": false}
+ }
+ ]
+ }]
+ }
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals("Example Game", snapshot!!.game)
+ assertEquals("Timed reward", snapshot.drop)
+ }
+
+ @Test
+ fun skipsExpiredInventoryCampaigns() {
+ val snapshots = parseInventorySnapshots(
+ """
+ {
+ "data": {
+ "currentUser": {
+ "inventory": {
+ "dropCampaignsInProgress": [
+ {
+ "name": "Expired OWCS",
+ "status": "EXPIRED",
+ "startAt": "2020-01-01T00:00:00Z",
+ "endAt": "2020-02-01T00:00:00Z",
+ "game": {"displayName": "Overwatch"},
+ "timeBasedDrops": [{
+ "name": "Old Reward",
+ "requiredMinutesWatched": 60,
+ "self": {"currentMinutesWatched": 1, "isClaimed": false}
+ }]
+ },
+ {
+ "name": "Active OWCS",
+ "status": "ACTIVE",
+ "startAt": "2020-01-01T00:00:00Z",
+ "endAt": "2099-02-01T00:00:00Z",
+ "game": {"displayName": "Overwatch"},
+ "timeBasedDrops": [{
+ "name": "Live Reward",
+ "requiredMinutesWatched": 60,
+ "self": {"currentMinutesWatched": 1, "isClaimed": false}
+ }]
+ }
+ ]
+ }
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals(1, snapshots.size)
+ assertEquals("Active OWCS", snapshots[0].campaign)
+ }
+
+ @Test
+ fun priorityWithoutTimedDropFallsThroughToNextPriority() {
+ val selected = selectInventorySnapshot(
+ listOf(
+ TwitchDropSnapshot(
+ game = "Second",
+ campaign = "Timed",
+ drop = "Reward",
+ currentMinutes = 1,
+ requiredMinutes = 30,
+ campaignProgress = 0.1f,
+ ),
+ ),
+ priorityGames = listOf("First", "Second"),
+ excludedGames = emptyList(),
+ )
+
+ assertEquals("Second", selected!!.game)
+ }
+
+ @Test
+ fun samePriorityGameSelectsCampaignWithLowestRemainingTime() {
+ val selected = selectInventorySnapshot(
+ listOf(
+ TwitchDropSnapshot(
+ game = "Overwatch",
+ campaign = "Long Campaign",
+ drop = "Long Reward",
+ currentMinutes = 0,
+ requiredMinutes = 120,
+ campaignProgress = 0f,
+ ),
+ TwitchDropSnapshot(
+ game = "Overwatch",
+ campaign = "Short Campaign",
+ drop = "Short Reward",
+ currentMinutes = 25,
+ requiredMinutes = 30,
+ campaignProgress = 0.5f,
+ ),
+ ),
+ priorityGames = listOf("Overwatch"),
+ excludedGames = emptyList(),
+ )
+
+ assertEquals("Short Campaign", selected!!.campaign)
+ }
+
+ @Test
+ fun completedDropIsDisplayedAfterAndNeverSelectedOverUnfinishedDrop() {
+ val completed = TwitchDropSnapshot(
+ game = "Rust", campaign = "Charity", drop = "Bed",
+ currentMinutes = 120, requiredMinutes = 120, campaignProgress = 0.75f,
+ )
+ val unfinished = TwitchDropSnapshot(
+ game = "Rust", campaign = "Charity", drop = "Furnace",
+ currentMinutes = 0, requiredMinutes = 120, campaignProgress = 0.75f,
+ )
+
+ assertEquals("Furnace", selectInventorySnapshot(listOf(completed, unfinished), listOf("Rust"), emptyList())!!.drop)
+ assertEquals(listOf("Furnace", "Bed"), filterPrioritySnapshots(listOf(completed, unfinished), listOf("Rust"), emptyList()).map { it.drop })
+ }
+
+ @Test
+ fun noPrioritySelectsNothing() {
+ val selected = selectInventorySnapshot(
+ listOf(
+ TwitchDropSnapshot(
+ game = "First",
+ campaign = "Long",
+ drop = "Reward",
+ currentMinutes = 0,
+ requiredMinutes = 60,
+ campaignProgress = 0f,
+ ),
+ TwitchDropSnapshot(
+ game = "Second",
+ campaign = "Short",
+ drop = "Reward",
+ currentMinutes = 58,
+ requiredMinutes = 60,
+ campaignProgress = 0.9f,
+ ),
+ ),
+ priorityGames = emptyList(),
+ excludedGames = emptyList(),
+ )
+
+ assertEquals(null, selected)
+ }
+
+ @Test
+ fun filtersDisplayDropsToPriorityGames() {
+ val drops = filterPrioritySnapshots(
+ listOf(
+ TwitchDropSnapshot(
+ game = "Detroit: Become Human",
+ campaign = "Detroit Badge Drop",
+ drop = "Android Triangle",
+ currentMinutes = 1,
+ requiredMinutes = 60,
+ campaignProgress = 0.01f,
+ ),
+ TwitchDropSnapshot(
+ game = "Overwatch",
+ campaign = "Long Campaign",
+ drop = "Long Reward",
+ currentMinutes = 0,
+ requiredMinutes = 120,
+ campaignProgress = 0f,
+ ),
+ TwitchDropSnapshot(
+ game = "Overwatch",
+ campaign = "Short Campaign",
+ drop = "Short Reward",
+ currentMinutes = 25,
+ requiredMinutes = 30,
+ campaignProgress = 0.5f,
+ ),
+ ),
+ priorityGames = listOf("Overwatch"),
+ excludedGames = emptyList(),
+ )
+
+ assertEquals(2, drops.size)
+ assertEquals("Short Campaign", drops[0].campaign)
+ assertEquals("Long Campaign", drops[1].campaign)
+ }
+
+ @Test
+ fun parsesDropCategories() {
+ val categories = parseDropCategories(
+ """
+ {
+ "data": {
+ "currentUser": {
+ "dropCampaigns": [
+ {"status": "ACTIVE", "game": {"id": "515025", "displayName": "Overwatch 2"}},
+ {"status": "UPCOMING", "game": {"id": "218378525", "displayName": "Marvel Rivals"}},
+ {
+ "status": "EXPIRED",
+ "endAt": "2020-02-01T00:00:00Z",
+ "game": {"id": "old", "displayName": "Expired Game"}
+ }
+ ]
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals("Overwatch 2", categories[0].name)
+ assertEquals("218378525", categories[1].id)
+ assertEquals(2, categories.size)
+ }
+
+ @Test
+ fun parsesActiveDropCampaignIds() {
+ val ids = parseDropCampaignIds(
+ """
+ {
+ "data": {
+ "currentUser": {
+ "dropCampaigns": [
+ {"id": "active-1", "status": "ACTIVE"},
+ {"id": "upcoming-1", "status": "UPCOMING"},
+ {"id": "expired-1", "status": "EXPIRED"}
+ ]
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals(listOf("active-1", "upcoming-1"), ids)
+ }
+
+ @Test
+ fun parsesCampaignDetailCategories() {
+ val categories = parseCampaignDetailCategories(
+ """
+ {
+ "data": {
+ "user": {
+ "dropCampaign": {
+ "id": "campaign-1",
+ "game": {"id": "65632", "displayName": "Rust"}
+ }
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals(1, categories.size)
+ assertEquals("65632", categories[0].id)
+ assertEquals("Rust", categories[0].name)
+ }
+
+ @Test
+ fun parsesCampaignDetailSnapshotWithoutSelfProgress() {
+ val snapshots = parseCampaignDetailSnapshots(
+ """
+ {
+ "data": {
+ "user": {
+ "dropCampaign": {
+ "id": "campaign-1",
+ "name": "Rust Charity 26 Bed",
+ "status": "ACTIVE",
+ "startAt": "2020-01-01T00:00:00Z",
+ "endAt": "2099-02-01T00:00:00Z",
+ "game": {"id": "263490", "displayName": "Rust"},
+ "timeBasedDrops": [{
+ "name": "Rust Charity '26 Bed",
+ "requiredMinutesWatched": 120,
+ "benefitEdges": [{"benefit": {"name": "Rust Charity '26 Bed"}}]
+ }]
+ }
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals(1, snapshots.size)
+ assertEquals("Rust", snapshots[0].game)
+ assertEquals("Rust Charity 26 Bed", snapshots[0].campaign)
+ assertEquals("02:00:00", snapshots[0].remaining)
+ }
+
+ @Test
+ fun snapshotDropKeyUsesCampaignAndDropIds() {
+ val first = TwitchDropSnapshot(
+ campaignId = "campaign-1",
+ dropId = "drop-1",
+ game = "Rust",
+ campaign = "Same Name",
+ drop = "Same Drop",
+ currentMinutes = 0,
+ requiredMinutes = 60,
+ campaignProgress = 0f,
+ )
+ val duplicate = first.copy(currentMinutes = 10)
+
+ assertEquals(1, listOf(first, duplicate).distinctBy { it.dropKey }.size)
+ }
+
+ @Test
+ fun parsesInventoryCategories() {
+ val categories = parseInventoryCategories(
+ """
+ {
+ "data": {
+ "currentUser": {
+ "inventory": {
+ "dropCampaignsInProgress": [
+ {"game": {"id": "123", "displayName": "Battlefield 6"}},
+ {
+ "status": "EXPIRED",
+ "endAt": "2020-02-01T00:00:00Z",
+ "game": {"id": "old", "displayName": "Expired Inventory Game"}
+ }
+ ]
+ }
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals("Battlefield 6", categories[0].name)
+ assertEquals(1, categories.size)
+ }
+
+ @Test
+ fun gameEventBenefitClaimPreventsRefarmingDropWithoutSelfEdge() {
+ val snapshots = parseInventorySnapshots(
+ """
+ {"data":{"currentUser":{"inventory":{
+ "gameEventDrops":[{"id":"benefit-1","lastAwardedAt":"2026-07-10T12:00:00Z"}],
+ "dropCampaignsInProgress":[{
+ "id":"campaign-1","name":"Overwatch","status":"ACTIVE",
+ "startAt":"2026-07-01T00:00:00Z","endAt":"2026-08-01T00:00:00Z",
+ "game":{"id":"515025","displayName":"Overwatch"},
+ "timeBasedDrops":[{
+ "id":"drop-1","name":"Captured Moments Player Icon",
+ "startAt":"2026-07-01T00:00:00Z","endAt":"2026-08-01T00:00:00Z",
+ "requiredMinutesWatched":60,
+ "benefitEdges":[{"benefit":{"id":"benefit-1","name":"Captured Moments Player Icon"}}]
+ }]
+ }]
+ }}}}
+ """.trimIndent(),
+ )
+
+ assertEquals(0, snapshots.size)
+ }
+
+ @Test
+ fun parsesGameRedirectSlug() {
+ val slug = parseGameSlug("""{"data":{"game":{"slug":"overwatch-2"}}}""")
+
+ assertEquals("overwatch-2", slug)
+ }
+}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..1b4481a
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..bae9034
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,3 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..df97d72
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100644
index 0000000..f5feea6
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..9d21a21
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..fa393fb
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "TDMinerAndroid"
+include(":app")
diff --git a/core/constants.py b/core/constants.py
index cbddd5f..8138d4e 100644
--- a/core/constants.py
+++ b/core/constants.py
@@ -256,6 +256,7 @@ class State(Enum):
CHANNELS_FETCH = auto()
CHANNELS_CLEANUP = auto()
CHANNEL_SWITCH = auto()
+ RESTART = auto()
EXIT = auto()
diff --git a/docs/android-app-feasibility.md b/docs/android-app-feasibility.md
new file mode 100644
index 0000000..a5185b5
--- /dev/null
+++ b/docs/android-app-feasibility.md
@@ -0,0 +1,300 @@
+# Android App Feasibility Report
+
+Date: 2026-07-04
+
+## Verdict
+
+Building an Android app is technically feasible.
+
+Recommended path: ship a sideload/GitHub APK only, with a native Android UI, a user-controlled Start/Stop session model, a visible foreground service, progress notifications, and a home-screen widget. Play Store distribution and multi-account support are out of scope.
+
+## Eligibility Summary
+
+| Area | Eligibility | Reason |
+| --- | --- | --- |
+| Native Android APK | Eligible | Android can run a Compose UI plus a foreground service for long-running work. |
+| Reusing current Python core | Partially eligible | Current code is Python/asyncio and already runs under Termux, but packaging Python inside APK adds complexity. |
+| Play Store distribution | Out of scope | The target is sideload/GitHub APK distribution only. |
+| GitHub/sideload distribution | Eligible | No Play review gate. Still carries Twitch account/platform risk. |
+| Single account | Eligible | Keeps token storage, UI, and service state simple. Multi-account is not planned. |
+| Session-based farming | Eligible | User presses Start to run and Stop to end the foreground service. No hidden long-term autostart. |
+| Home-screen widget | Eligible | Android App Widget can show status/progress and send Start/Stop intents to the app. |
+| Progress notifications | Eligible | The foreground-service notification can show active campaign/drop progress and expose Stop. |
+| Wake lock permission | Eligible with limits | `android.permission.WAKE_LOCK` can keep CPU work alive during an active session, but it must be released on Stop. |
+| Proper mobile UI | Eligible | Existing GUI/TUI state model can guide screens, but the UI itself should be native Android, not ported Tk/Textual. |
+
+## Current Codebase Findings
+
+The repo is not Android-ready yet. It is a Python desktop/terminal app:
+
+- `network/twitch.py` owns the main Twitch runtime: authenticated HTTP/GQL calls, campaign fetches, channel selection, websocket events, progress, and claiming.
+- `models/inventory.py` owns campaign/drop eligibility, progress, and claim state.
+- `core/settings.py` and `core/constants.py` store config, cookies, cache, and runtime paths.
+- `gui/` is the desktop GUI layer.
+- `tui/` is the Textual/prompt_toolkit terminal UI layer.
+- `scripts/install.sh` has a Termux path that installs the Python source into a venv and writes a `tdminer` launcher.
+
+The useful separation is that the mining logic is already mostly outside the UI. The missing Android piece is a native app shell, service lifecycle, Android storage, and secure login/token handling.
+
+Important GUI features to preserve:
+
+- Main status line: current watched channel/session state.
+- Websocket/service health.
+- Login/account status.
+- Channel list with online status, game, drops flag, viewers, and manual switch.
+- Campaign and drop progress as separate progress indicators.
+- Output/log stream.
+- Inventory images: campaign cover art and reward/badge/emote images.
+- Settings parity for behavior that affects farming, especially badges/emotes support.
+
+## Android Platform Constraints
+
+Android will not reliably allow a silent long-running background miner. Android 8+ stops background services after an idle window unless the app is foreground-visible or uses scheduled jobs. A miner needs continuous websocket/network work, so WorkManager alone is not enough for active farming.
+
+Required Android model:
+
+- A foreground service while farming is active.
+- Persistent notification: "Twitch Drops Miner is running".
+- Notification progress bars for active campaign/drop progress.
+- Stop action in the notification.
+- Explicit user start/stop in the UI.
+- Home-screen widget with status, current progress, and Start/Stop action.
+- Partial wake lock only while a user-started farming session is active.
+- No hidden autostart farming by default.
+- Battery optimization guidance, because OEMs may still kill long-running apps.
+
+For Android 14+, foreground service type declarations are required. The closest type is likely `dataSync` or `specialUse`. Since Play Store is not targeted, Play Console review requirements are not product constraints, but the Android platform foreground-service rules still apply.
+
+Manifest-level permissions likely needed:
+
+- `android.permission.INTERNET`
+- `android.permission.FOREGROUND_SERVICE`
+- `android.permission.FOREGROUND_SERVICE_DATA_SYNC` or the selected foreground-service type permission
+- `android.permission.POST_NOTIFICATIONS` on Android 13+
+- `android.permission.WAKE_LOCK`
+
+Wake lock rule:
+
+- Use a partial wake lock only after the user presses Start.
+- Release it immediately on Stop, logout, service crash recovery, or session completion.
+- Do not keep the screen awake; the app only needs CPU/network continuity.
+
+Sources:
+
+- Android background limits: https://developer.android.com/about/versions/oreo/background
+- Android services overview: https://developer.android.com/develop/background-work/services
+- Android 14 foreground service types: https://developer.android.com/about/versions/14/changes/fgs-types-required
+- Android wake locks: https://developer.android.com/develop/background-work/background-tasks/awake/wakelock
+- Android wake lock API reference: https://developer.android.com/reference/android/os/PowerManager.WakeLock
+- Android progress-centric notifications: https://developer.android.com/about/versions/16/features/progress-centric-notifications
+
+## Twitch / Policy Risk
+
+This is the main blocker for a public Android app.
+
+Twitch Terms of Service prohibit accessing Twitch services by robot, spider, scraper, crawler, or other automated means. The app's core purpose is automated watch/drop progress behavior, using Twitch endpoints and websocket events without an official Drops farming API. Twitch Developer docs also bind API/developer-product usage to the Developer Services Agreement, which restricts undocumented Program Materials and abusive request volume.
+
+Practical conclusion:
+
+- Sideload build: possible, same risk profile as the desktop tool.
+- Play Store build: out of scope.
+- Public marketing should avoid claims like "farm drops automatically in background".
+- If distributed, include clear user-controlled operation and account-risk disclosure.
+
+Sources:
+
+- Twitch Terms of Service, prohibited conduct: https://legal.twitch.com/en/legal/terms-of-service/
+- Twitch Developer Services Agreement: https://legal.twitch.com/legal/developer-agreement/
+- Twitch Developer docs terms: https://dev.twitch.tv/docs
+
+## Development Without Android Studio
+
+Android Studio is useful, but not required.
+
+Minimum local setup:
+
+- JDK 17 or newer.
+- Android SDK command-line tools.
+- `platform-tools` for `adb`.
+- Android SDK platform and build tools.
+- Gradle wrapper checked into the Android project.
+
+You do not need to install Kotlin manually. A Gradle Android project downloads the Kotlin/Android Gradle plugins declared by the project. You write Kotlin files, then build with the wrapper:
+
+```powershell
+.\gradlew.bat assembleDebug
+```
+
+Testing without a virtual device:
+
+- Best option: use a real Android phone with USB debugging enabled.
+- Install debug APK with `adb install -r app\build\outputs\apk\debug\app-debug.apk`.
+- Use `adb logcat` for runtime logs.
+- Use GitHub Actions to build APKs if the local machine is missing SDK pieces.
+- Skip the emulator unless needed; emulator system images are large and slower than testing on a real phone.
+
+Command-line-only emulator is possible with `sdkmanager`, `avdmanager`, and `emulator`, but it still requires downloading a system image. For this project, physical-device testing is the practical path.
+
+Sources:
+
+- Android command-line tools: https://developer.android.com/tools
+- Build from command line: https://developer.android.com/build/building-cmdline
+- `sdkmanager`: https://developer.android.com/tools/sdkmanager
+- `avdmanager`: https://developer.android.com/tools/avdmanager
+- Start emulator from command line: https://developer.android.com/studio/run/emulator-commandline
+- Android SDK environment variables: https://developer.android.com/tools/variables
+
+## Recommended Architecture
+
+### Option A: Fastest APK, Reuse Python Core
+
+Use a native Android app shell and embed the Python runtime.
+
+Architecture:
+
+- Kotlin + Jetpack Compose UI.
+- Android foreground service starts/stops the miner.
+- Foreground notification shows campaign/drop progress and a Stop action.
+- Optional widget starts/stops the same service and mirrors status/progress.
+- Partial wake lock is acquired only during active sessions.
+- Embedded Python runs existing `network/`, `models/`, and `core/` logic.
+- Android storage replaces current path assumptions for `cookies.jar`, `settings.json`, and cache.
+- UI talks to the service through a local in-process bridge/events.
+
+Pros:
+
+- Reuses most current mining logic.
+- Fastest route to a real APK.
+- Good for sideload/internal builds.
+
+Cons:
+
+- Packaging Python and dependencies into APK is fragile.
+- Debugging Android/Python bridge issues will be annoying.
+- Python packaging remains the main technical risk.
+
+Estimated effort: 2-4 weeks for a usable sideload MVP, assuming one developer familiar with Android.
+
+### Option B: Native Kotlin Rewrite
+
+Port the Twitch runtime to Kotlin.
+
+Architecture:
+
+- Kotlin + Jetpack Compose UI.
+- Ktor/OkHttp networking.
+- Kotlin coroutines for fetch loops and websocket handling.
+- EncryptedSharedPreferences or Android Keystore-backed storage for tokens/cookies.
+- Room/DataStore for settings and cached inventory.
+- Foreground service for active farming.
+- App Widget + Glance or RemoteViews for status and Start/Stop.
+
+Pros:
+
+- Cleaner Android lifecycle.
+- Smaller runtime.
+- Easier long-term maintenance on Android.
+- Better UI/service integration.
+
+Cons:
+
+- More work.
+- Must port and retest all Twitch request/eligibility behavior.
+- Twitch endpoint fragility remains.
+
+Estimated effort: 5-8 weeks for a solid MVP; longer for parity with desktop.
+
+### Option C: WebView Wrapper
+
+Not recommended.
+
+It may produce a UI quickly, but it does not solve background execution, service lifecycle, token storage, or reliable drop farming. It is the shortest path to a bad app.
+
+## Proposed Android UI
+
+Design direction:
+
+- Swiss minimal interface: strict grid, high contrast, restrained color, large typography, strong whitespace, and very few decorative elements.
+- Preserve the desktop GUI's information model, but reshape it for mobile instead of copying the desktop layout.
+- Use campaign cover art and reward/badge/emote images as first-class UI content. The TUI/CLI cannot show this well; the Android app should.
+- No desktop clone. The app should feel like a native Android control panel for one account and one active farming session.
+- Primary action is always obvious: Start when idle, Stop when running.
+
+Screens:
+
+- Dashboard: Start/Stop button, current watched channel, active campaign cover image, reward image strip, drop progress, campaign progress, remaining time, websocket/service health, wake-lock status, notification status.
+- Campaigns: active/upcoming/expired filters, cover images, linked status, badge/emote support, priority/exclude controls, claim-ready state.
+- Campaign detail: large game/campaign artwork, all drops with reward images, required minutes, progress, claim state, allowed channels.
+- Channels: eligible online channels, game, viewer count, drops status, manual switch, ACL/allowed-channel indicator.
+- Login: single-account device-code login URL/code, cookie import/export fallback if needed.
+- Settings: priority mode, farm unlinked, badges/emotes, notification style, wake-lock toggle/explanation, battery optimization help, diagnostic/export controls.
+- Logs: compact live log with copy/export.
+- Widget: compact home-screen status with Idle/Running, current drop percentage, channel, and Start/Stop button.
+
+The mobile app should exceed CLI/TUI functionality in these areas:
+
+- Image-backed campaign inventory.
+- Reward/badge/emote thumbnails.
+- Notification progress indicators.
+- Home-screen widget.
+- One-tap Start/Stop.
+- Clear battery/wake-lock state.
+- Exportable diagnostics.
+
+Minimum controls:
+
+- Start farming.
+- Stop farming.
+- Reload inventory.
+- Switch channel.
+- Open/copy login URL.
+- Export diagnostic bundle.
+
+## MVP Scope
+
+Build only this first:
+
+1. Native Compose shell.
+2. Foreground service with notification and stop action.
+3. Notification progress indicators for campaign/drop progress.
+4. Wake lock permission and scoped partial wake-lock lifecycle.
+5. Home-screen widget with status and Start/Stop.
+6. Single-account login/device-code flow.
+7. Inventory/campaign list.
+8. Start/stop farming.
+9. Current drop progress.
+10. Settings needed for current behavior: priority, exclude, farm unlinked, badges/emotes, notifications, wake lock.
+11. Logs screen.
+
+Skip initially:
+
+- Play Store release.
+- Multi-account.
+- Background autostart.
+- Chromecast/video playback.
+- Full desktop GUI parity.
+
+## Major Risks
+
+| Risk | Severity | Mitigation |
+| --- | --- | --- |
+| Twitch account/platform enforcement | High | Sideload only; disclose risk; do not market as viewbotting; keep user-controlled. |
+| Android kills the service | Medium | Foreground service, stop action, battery optimization guidance. |
+| Excess battery drain | Medium | Use wake lock only during active sessions; release aggressively; expose Stop in UI, widget, and notification. |
+| Python APK packaging instability | Medium | Use native Kotlin rewrite if APK packaging becomes the bottleneck. |
+| Token/cookie handling | High | Use Android Keystore/EncryptedSharedPreferences; never store raw secrets in shared external storage. |
+| Twitch endpoint changes | Medium | Keep shared request definitions and diagnostics; expect maintenance. |
+
+## Recommendation
+
+Build a sideload APK. Do not target Play Store. Do not add multi-account.
+
+Use native Android UI plus either embedded Python for speed or Kotlin rewrite for durability. If the goal is a proper polished Android app, the Kotlin rewrite is the better final architecture. If the goal is to test quickly, embedded Python is acceptable as a temporary bridge.
+
+The app should run only when the user starts a session. While running, it should show the same state in three places: the app dashboard, the foreground notification with progress indicators, and the home-screen widget. Stop must be available from all three.
+
+Decision:
+
+- MVP/sideload: yes, feasible.
+- Production-quality Android app: feasible, moderate effort.
+- Play Store app: out of scope.
diff --git a/docs/android-category-flow.md b/docs/android-category-flow.md
new file mode 100644
index 0000000..d218005
--- /dev/null
+++ b/docs/android-category-flow.md
@@ -0,0 +1,36 @@
+# Android Drop Category Flow
+
+This mirrors the desktop GUI/CLI flow. The settings category picker is not a Twitch-wide category search.
+
+## Desktop GUI Flow
+
+1. `Twitch.fetch_inventory()` runs during `State.INVENTORY_FETCH`.
+2. It fetches current inventory with `GQL_QUERIES["Inventory"]`.
+3. It fetches available drop campaigns with `GQL_QUERIES["Campaigns"]` / `ViewerDropsDashboard`.
+4. It filters campaign statuses to `ACTIVE` and `UPCOMING`.
+5. It fetches campaign details and merges them with inventory campaign data.
+6. It builds `DropsCampaign` objects.
+7. It calls `gui.set_games(set(campaign.game for campaign in self.inventory))`.
+8. Settings priority/exclude values are game names from that loaded campaign inventory.
+9. The miner later uses those names against `settings.priority` and `settings.exclude` to build `wanted_games`.
+
+## Android Rule
+
+Android must only offer categories/games from loaded drop campaigns:
+
+- Source: `Inventory` + `ViewerDropsDashboard`.
+- Scope: games from in-progress, active, or upcoming drop campaigns.
+- Cache: persist the loaded game list locally for 1 hour.
+- Reload: expose a manual "Reload Drop Games" action that refreshes the same cached drop-campaign list immediately.
+- Typing: filter the cached loaded list locally.
+- Invalid names: show a not-found warning and do not save.
+- Defaults: add `Overwatch` and `Marvel Rivals` only when those names exist in the loaded drop-campaign list.
+- No global Twitch category search.
+- No manual freeform category save.
+
+## Android Files
+
+- `TwitchGql.fetchDropCategories()` loads drop-campaign games.
+- `MinerCore.loadCategories()` exposes that list to UI.
+- `MinerSettingsStore` persists the category cache with timestamp.
+- `TDMinerApp` loads cache on startup, refreshes if older than 1 hour, and filters the cached list while typing.
diff --git a/docs/android-ui-refinement-issues.md b/docs/android-ui-refinement-issues.md
new file mode 100644
index 0000000..8c77fcd
--- /dev/null
+++ b/docs/android-ui-refinement-issues.md
@@ -0,0 +1,24 @@
+# Android UI Refinement Issues
+
+## Checklist
+
+- [x] Make Priority Queue, Excluded Games, and Behavior collapsible sections.
+- [x] Expand one section at a time so lists have enough room for drag/reorder.
+- [x] Replace inline search fields with artboard-style Add buttons.
+- [x] Open the same browse/search panel from Priority and Excluded Add buttons.
+- [x] Match focused browse-field and category-result styling to the artboard.
+- [x] Keep each expanded game list independently scrollable.
+- [x] Match outlined button geometry, spacing, and typography from the approved artboard.
+- [x] Keep page titles fixed while only page content scrolls.
+- [x] Prevent the Home hero/header from participating in generic page scrolling.
+- [x] Use dark Android status and navigation bars with light system icons.
+- [x] Give the Prefs reload action stable width and artboard-style outline treatment.
+- [x] Build, install, and verify Home, Drops, Channels, Prefs, and Logs on-device.
+
+## Acceptance
+
+- The selected Prefs section expands without making the whole page scroll.
+- Priority and Excluded use identical browse-panel behavior and real cached drop games.
+- Dragging priority entries has a usable viewport.
+- Screen titles remain visible while lists scroll beneath them.
+- Android system bars visually merge with the app background.
diff --git a/gui/components.py b/gui/components.py
index 1a74bd3..b97fc5a 100644
--- a/gui/components.py
+++ b/gui/components.py
@@ -1864,6 +1864,14 @@ def __init__(self, manager: GUIManager, master: ttk.Widget):
text=_("gui", "settings", "reload"),
command=self._manager._twitch.state_change(State.INVENTORY_FETCH),
).grid(column=1, row=0)
+ ttk.Button(
+ reload_frame,
+ text="Invalidate auth",
+ command=lambda: (
+ self._manager._twitch.invalidate(),
+ self._manager._twitch.state_change(State.RESTART),
+ ),
+ ).grid(column=2, row=0, padx=(4, 0))
self._vars["autostart"].set(self._query_autostart())
self.priority_mode()
diff --git a/network/twitch.py b/network/twitch.py
index 6bb036d..ca0f95b 100644
--- a/network/twitch.py
+++ b/network/twitch.py
@@ -416,8 +416,18 @@ async def _validate(self):
jar.save(COOKIES_PATH)
self._logged_in.set()
- def invalidate(self):
- self._delattrs("access_token")
+ def invalidate(self, *, delete_cookies: bool = False) -> None:
+ self._delattrs("access_token", "user_id")
+ session = self._twitch._session
+ if session is None:
+ return
+ cookie_jar = cast(aiohttp.CookieJar, session.cookie_jar)
+ client_info: ClientInfo = self._twitch._client_type
+ for cookies in cookie_jar._cookies.values():
+ cookies.pop("auth-token", None)
+ if delete_cookies:
+ cookie_jar.clear_domain(client_info.CLIENT_URL.host)
+ COOKIES_PATH.unlink(missing_ok=True)
class Twitch:
@@ -886,6 +896,8 @@ async def _run(self):
self.gui.status.update(_("gui", "status", "exiting"))
# we've been requested to exit the application
break
+ elif self._state is State.RESTART:
+ raise ReloadRequest()
await self._state_change.wait()
async def _watch_sleep(self, delay: float) -> None:
diff --git a/tui/app.py b/tui/app.py
index 79f8897..3751480 100644
--- a/tui/app.py
+++ b/tui/app.py
@@ -140,6 +140,7 @@ class TwitchDropsTUI(App[None]):
("ctrl+q", "request_quit", "Quit"),
("ctrl+c", "request_quit", "Quit"),
("r", "reload", "Reload"),
+ ("i", "invalidate_auth", "Invalidate Auth"),
("s", "switch_channel", "Switch"),
("b", "open_browser", "Open Browser"),
("c", "copy_login_url", "Copy Login URL"),
@@ -153,6 +154,7 @@ def __init__(
*,
on_close: abc.Callable[[], None],
on_reload: abc.Callable[[], None],
+ on_invalidate_auth: abc.Callable[[], None],
login_confirm: asyncio_event_setter,
on_switch: abc.Callable[[], None],
on_save_settings: abc.Callable[[str, str], None],
@@ -170,6 +172,7 @@ def __init__(
self.state = state
self._on_close = on_close
self._on_reload = on_reload
+ self._on_invalidate_auth = on_invalidate_auth
self._login_confirm = login_confirm
self._on_switch = on_switch
self._on_save_settings = on_save_settings
@@ -248,6 +251,7 @@ def compose(self) -> ComposeResult:
yield Button("+ priority", id="add-priority", compact=True, flat=True)
yield Button("+ exclude", id="add-exclude", compact=True, flat=True)
yield Button("reload", id="reload", compact=True, flat=True)
+ yield Button("invalidate", id="invalidate-auth", compact=True, flat=True)
with Vertical(classes="compact-panel grow"):
yield Label("Priority")
yield DataTable(id="priority-table")
@@ -509,6 +513,9 @@ def action_request_quit(self) -> None:
def action_reload(self) -> None:
self._on_reload()
+ def action_invalidate_auth(self) -> None:
+ self._on_invalidate_auth()
+
def action_switch_channel(self) -> None:
self._on_switch()
@@ -548,6 +555,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
self.copy_activation_url()
elif button_id == "reload":
self._on_reload()
+ elif button_id == "invalidate-auth":
+ self._on_invalidate_auth()
elif button_id == "add-priority":
self.action_add_priority()
elif button_id == "add-exclude":
diff --git a/tui/cli.py b/tui/cli.py
index 331f2a0..27beb60 100644
--- a/tui/cli.py
+++ b/tui/cli.py
@@ -140,6 +140,7 @@ class PortableCLIManager(TUIManager):
"/settings",
"/logs",
"/reload",
+ "/invalidate",
"/open",
"/copy",
"/switch",
@@ -366,6 +367,8 @@ def _handle_command(self, raw: str) -> None:
elif command == "reload":
self._loading = True
self._reload()
+ elif command == "invalidate":
+ self._invalidate_auth()
elif command == "open":
self._open_login_url()
elif command == "copy":
@@ -523,6 +526,7 @@ def _handle_filter(self, rest: str) -> None:
],
"control": [
("/reload", "Reload inventory and campaign data from Twitch"),
+ ("/invalidate", "Invalidate the saved auth token and restart login"),
("/switch ", "Switch to a specific channel by name or ID"),
("/priority add ", "Add a game to the priority list"),
("/priority remove ", "Remove a game from the priority list"),
diff --git a/tui/manager.py b/tui/manager.py
index 794eca5..9e52795 100644
--- a/tui/manager.py
+++ b/tui/manager.py
@@ -406,6 +406,7 @@ def start(self) -> None:
self.state,
on_close=self.close,
on_reload=self._reload,
+ on_invalidate_auth=self._invalidate_auth,
login_confirm=self.login.confirm,
on_switch=self._switch_channel,
on_save_settings=self._save_settings,
@@ -529,6 +530,10 @@ def refresh_settings(self) -> None:
def _reload(self) -> None:
self._twitch.change_state(State.INVENTORY_FETCH)
+ def _invalidate_auth(self) -> None:
+ self._twitch.invalidate()
+ self._twitch.change_state(State.RESTART)
+
def _switch_channel(self) -> None:
if self.selected_channel_id() is not None:
self._twitch.change_state(State.CHANNEL_SWITCH)