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 @@ + + + + + + + + +