diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 530c214b..66331a35 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -30,7 +30,13 @@ class AndroidApplicationConventionPlugin : Plugin { } buildTypes { getByName("release") { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true + + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index c4d47438..d41796cf 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -1,124 +1,198 @@ -# === CRITICAL: Keep Everything for Networking === --keeppackagenames io.ktor.** --keeppackagenames okhttp3.** --keeppackagenames okio.** - -# Kotlin --keep class kotlin.** { *; } --keep class kotlinx.** { *; } --keepclassmembers class kotlin.** { *; } - -# Coroutines --keep class kotlinx.coroutines.** { *; } +# ============================================================================ +# ProGuard / R8 Rules for GitHub Store (KMP + Compose Multiplatform) +# ============================================================================ +# Used with: proguard-android-optimize.txt (enables optimization passes) +# ============================================================================ + +# ── General Attributes ────────────────────────────────────────────────────── +-keepattributes Signature +-keepattributes *Annotation* +-keepattributes InnerClasses,EnclosingMethod +-keepattributes SourceFile,LineNumberTable +-keepattributes Exceptions + +# ── Kotlin Core ───────────────────────────────────────────────────────────── +# Keep Kotlin metadata for reflection used by serialization & Koin +-keep class kotlin.Metadata { *; } +-keep class kotlin.reflect.jvm.internal.** { *; } +-dontwarn kotlin.** +-dontwarn kotlinx.** + +# ── Kotlin Coroutines ────────────────────────────────────────────────────── -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} -keepclassmembernames class kotlinx.** { volatile ; } +-dontwarn kotlinx.coroutines.** -# === Ktor - Keep EVERYTHING === --keep class io.ktor.** { *; } --keep interface io.ktor.** { *; } --keepclassmembers class io.ktor.** { *; } +# ── Kotlinx Serialization ────────────────────────────────────────────────── +# Serialization engine internals +-keep class kotlinx.serialization.** { *; } +-keepclassmembers class kotlinx.serialization.json.** { *** Companion; } +-dontnote kotlinx.serialization.** + +# Generated serializers for ALL @Serializable classes +-keep class **$$serializer { *; } +-keepclassmembers @kotlinx.serialization.Serializable class ** { + *** Companion; + *** INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# App @Serializable classes (DTOs, models, navigation routes) across all packages +-keep @kotlinx.serialization.Serializable class zed.rainxch.** { *; } +-keep,includedescriptorclasses class zed.rainxch.**$$serializer { *; } +-keepclassmembers @kotlinx.serialization.Serializable class zed.rainxch.** { + *** Companion; +} + +# ── Navigation Routes ────────────────────────────────────────────────────── +# Type-safe navigation requires these classes to survive R8 +-keep class zed.rainxch.githubstore.app.navigation.GithubStoreGraph { *; } +-keep class zed.rainxch.githubstore.app.navigation.GithubStoreGraph$* { *; } + +# ── Network DTOs – Core Module ───────────────────────────────────────────── +-keep class zed.rainxch.core.data.dto.** { *; } + +# ── Network DTOs – Feature Modules ───────────────────────────────────────── +-keep class zed.rainxch.search.data.dto.** { *; } +-keep class zed.rainxch.devprofile.data.dto.** { *; } +-keep class zed.rainxch.home.data.dto.** { *; } + +# ── Domain Models ────────────────────────────────────────────────────────── +-keep class zed.rainxch.core.domain.model.GithubRepoSummary { *; } +-keep class zed.rainxch.core.domain.model.GithubUser { *; } + +# Keep enums used by Room TypeConverters and serialization +-keep class zed.rainxch.core.domain.model.InstallSource { *; } +-keep class zed.rainxch.core.domain.model.AppTheme { *; } +-keep class zed.rainxch.core.domain.model.FontTheme { *; } +-keep class zed.rainxch.core.domain.model.Platform { *; } +-keep class zed.rainxch.core.domain.model.SystemArchitecture { *; } +-keep class zed.rainxch.core.domain.model.PackageChangeType { *; } + +# ── Room Database ────────────────────────────────────────────────────────── +# Database class and generated implementation +-keep class zed.rainxch.core.data.local.db.AppDatabase { *; } +-keep class zed.rainxch.core.data.local.db.AppDatabase_Impl { *; } + +# Entities +-keep class zed.rainxch.core.data.local.db.entities.** { *; } + +# DAOs +-keep interface zed.rainxch.core.data.local.db.dao.** { *; } +-keep class zed.rainxch.core.data.local.db.dao.** { *; } + +# Room runtime +-keep class androidx.room.** { *; } +-dontwarn androidx.room.** + +# ── Ktor ─────────────────────────────────────────────────────────────────── +# Engine discovery, plugin system, and content negotiation use reflection +-keep class io.ktor.client.engine.** { *; } +-keep class io.ktor.client.plugins.** { *; } +-keep class io.ktor.serialization.** { *; } +-keep class io.ktor.utils.io.** { *; } +-keep class io.ktor.http.** { *; } -keepnames class io.ktor.** { *; } -dontwarn io.ktor.** - -# Ktor Debug -dontwarn java.lang.management.** -# === OkHttp - Keep EVERYTHING === --keep class okhttp3.** { *; } --keep interface okhttp3.** { *; } --keepclassmembers class okhttp3.** { *; } --keepnames class okhttp3.** { *; } +# ── OkHttp (Ktor engine) ────────────────────────────────────────────────── +-keep class okhttp3.internal.platform.** { *; } +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase -dontwarn okhttp3.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** -# === Okio - Keep EVERYTHING === --keep class okio.** { *; } --keepclassmembers class okio.** { *; } --keepnames class okio.** { *; } +# ── Okio ─────────────────────────────────────────────────────────────────── -dontwarn okio.** -# === Network Stack - Keep EVERYTHING === --keep class java.net.** { *; } --keep class javax.net.** { *; } --keep class sun.security.ssl.** { *; } --keepclassmembers class java.net.** { *; } --keepclassmembers class javax.net.** { *; } - -# DNS Resolution --keep class java.net.InetAddress { *; } --keep class java.net.Inet4Address { *; } --keep class java.net.Inet6Address { *; } --keep class java.net.InetSocketAddress { *; } - -# SSL/TLS --keep class javax.net.ssl.** { *; } +# ── SSL/TLS ──────────────────────────────────────────────────────────────── -keep class org.conscrypt.** { *; } -dontwarn org.conscrypt.** -# === Kotlinx Serialization === --keepattributes *Annotation*, InnerClasses --dontnote kotlinx.serialization.** --keep,includedescriptorclasses class zed.rainxch.githubstore.**$$serializer { *; } --keep @kotlinx.serialization.Serializable class zed.rainxch.githubstore.** { *; } --keepclassmembers @kotlinx.serialization.Serializable class zed.rainxch.githubstore.** { - *** Companion; -} - -# Keep your models --keep class zed.rainxch.githubstore.core.domain.model.** { *; } +# ── Koin DI ──────────────────────────────────────────────────────────────── +# Koin uses reflection for constructor injection +-keep class org.koin.** { *; } +-keep interface org.koin.** { *; } +-dontwarn org.koin.** + +# Keep ViewModels so Koin can instantiate them +-keep class zed.rainxch.**.presentation.**ViewModel { *; } +-keep class zed.rainxch.**.presentation.**ViewModel$* { *; } + +# ── Compose / AndroidX ──────────────────────────────────────────────────── +# Compose runtime and navigation (most rules come bundled with the library) +-dontwarn androidx.compose.** +-dontwarn androidx.lifecycle.** + +# ── DataStore ────────────────────────────────────────────────────────────── +-keep class androidx.datastore.** { *; } +-keepclassmembers class androidx.datastore.preferences.** { *; } +-dontwarn androidx.datastore.** + +# ── Landscapist / Coil3 (Image Loading) ──────────────────────────────────── +-keep class com.skydoves.landscapist.** { *; } +-keep interface com.skydoves.landscapist.** { *; } +-keep class coil3.** { *; } +-dontwarn coil3.** +-dontwarn com.skydoves.landscapist.** + +# ── Multiplatform Markdown Renderer ──────────────────────────────────────── +-keep class com.mikepenz.markdown.** { *; } +-keep class org.intellij.markdown.** { *; } +-dontwarn com.mikepenz.markdown.** +-dontwarn org.intellij.markdown.** + +# ── Kermit Logging ───────────────────────────────────────────────────────── +-keep class co.touchlab.kermit.** { *; } +-dontwarn co.touchlab.kermit.** + +# ── MOKO Permissions ────────────────────────────────────────────────────── +-keep class dev.icerock.moko.permissions.** { *; } +-dontwarn dev.icerock.moko.** + +# ── BuildKonfig (Generated Build Constants) ──────────────────────────────── +-keep class zed.rainxch.githubstore.BuildConfig { *; } +-keep class zed.rainxch.**.BuildKonfig { *; } +-keep class **.BuildKonfig { *; } -# === AndroidX Security === +# ── AndroidX Security / Crypto ───────────────────────────────────────────── -keep class androidx.security.crypto.** { *; } -keep class com.google.crypto.tink.** { *; } -dontwarn com.google.crypto.tink.** -dontwarn com.google.errorprone.annotations.** -# BuildConfig --keep class zed.rainxch.githubstore.BuildConfig { *; } - -# General --keepattributes Signature --keepattributes Exceptions --keepattributes *Annotation* --keepattributes SourceFile,LineNumberTable - -# === START: Auth Fix === --dontoptimize --keepattributes *Annotation*,Signature,Exception,InnerClasses,EnclosingMethod +# ── Firebase (if integrated) ────────────────────────────────────────────── +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** -# Keep serialization infrastructure --keep class kotlinx.serialization.** { *; } --keep class **$$serializer { *; } --keepclassmembers @kotlinx.serialization.Serializable class ** { - *** Companion; - *** INSTANCE; - kotlinx.serialization.KSerializer serializer(...); +# ── Enum safety ──────────────────────────────────────────────────────────── +# Keep all enum values and valueOf methods (used by serialization/Room) +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); } -# Keep Ktor plugins --keep class io.ktor.client.plugins.** { *; } --keep class io.ktor.serialization.** { *; } - -# Keep your entire core package (narrow this down later) --keep class zed.rainxch.githubstore.core.** { *; } --keepclassmembers class zed.rainxch.githubstore.core.** { *; } -# === END: Auth Fix === - --keep class zed.rainxch.githubstore.core.data.remote.dto.** { *; } --keep class zed.rainxch.githubstore.core.domain.model.auth.** { *; } - -# If your models are in different packages, list them: --keep class zed.rainxch.githubstore.**.*DeviceStart* { *; } --keep class zed.rainxch.githubstore.**.*DeviceToken* { *; } --keep class zed.rainxch.githubstore.**.*AuthConfig* { *; } - -# Keep the companion objects explicitly --keepclassmembers class zed.rainxch.githubstore.**.DeviceStart { - public static ** Companion; +# ── Parcelable ───────────────────────────────────────────────────────────── +-keepclassmembers class * implements android.os.Parcelable { + public static final ** CREATOR; } --keepclassmembers class zed.rainxch.githubstore.**.DeviceTokenSuccess { - public static ** Companion; + +# ── Java Serializable Compatibility ─────────────────────────────────────── +-keepnames class * implements java.io.Serializable +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); } --keepclassmembers class zed.rainxch.githubstore.**.DeviceTokenError { - public static ** Companion; -} \ No newline at end of file + +# ── Suppress Warnings for Missing Classes ────────────────────────────────── +-dontwarn java.lang.invoke.StringConcatFactory +-dontwarn javax.annotation.** +-dontwarn org.slf4j.** +-dontwarn org.codehaus.mojo.animal_sniffer.** diff --git a/composeApp/release/baselineProfiles/0/composeApp-release.dm b/composeApp/release/baselineProfiles/0/composeApp-release.dm index 07706a1c..fe88920b 100644 Binary files a/composeApp/release/baselineProfiles/0/composeApp-release.dm and b/composeApp/release/baselineProfiles/0/composeApp-release.dm differ diff --git a/composeApp/release/baselineProfiles/1/composeApp-release.dm b/composeApp/release/baselineProfiles/1/composeApp-release.dm index 1ef032f7..6fc1d2b7 100644 Binary files a/composeApp/release/baselineProfiles/1/composeApp-release.dm and b/composeApp/release/baselineProfiles/1/composeApp-release.dm differ diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index a8dd5c0d..2c6cc402 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,26 +1,30 @@ - + - - + android:usesCleartextTraffic="false" + tools:targetApi="29"> + + + + + + + - - + + + @@ -73,10 +82,11 @@ - + + + + + diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt index 17334fc0..6624fe83 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.util.Consumer +import zed.rainxch.githubstore.app.deeplink.DeepLinkParser class MainActivity : ComponentActivity() { @@ -20,19 +21,16 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() - enableEdgeToEdge() super.onCreate(savedInstanceState) - deepLinkUri = intent?.data?.toString() + handleIncomingIntent(intent) setContent { DisposableEffect(Unit) { val listener = Consumer { newIntent -> - newIntent.data?.toString()?.let { - deepLinkUri = it - } + handleIncomingIntent(newIntent) } addOnNewIntentListener(listener) onDispose { @@ -43,6 +41,23 @@ class MainActivity : ComponentActivity() { App(deepLinkUri = deepLinkUri) } } + + private fun handleIncomingIntent(intent: Intent?) { + if (intent == null) return + + val uriString = when (intent.action) { + Intent.ACTION_VIEW -> intent.data?.toString() + + Intent.ACTION_SEND -> { + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + sharedText?.let { DeepLinkParser.extractSupportedUrl(it) } + } + + else -> null + } + + uriString?.let { deepLinkUri = it } + } } @Preview diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt index e114987c..686cef05 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt @@ -38,7 +38,8 @@ fun App(deepLinkUri: String? = null) { ) } - DeepLinkDestination.None -> { /* ignore unrecognized deep links */ + DeepLinkDestination.None -> { + /* ignore unrecognized deep links */ } } } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt index 0a10a064..54b83ea6 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt @@ -174,4 +174,9 @@ object DeepLinkParser { } return null } + + fun extractSupportedUrl(text: String): String? { + val regex = """https?://(?:www\.)?(?:github\.com|github-store\.org)(?=[/\s?#]|$)[^\s<>"')\],;.!]*""".toRegex(RegexOption.IGNORE_CASE) + return regex.find(text)?.value + } } \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index 05965557..ae77c890 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -346,4 +346,28 @@ কোনো ভার্সন নির্বাচিত নয় ভার্সনসমূহ + + ইনস্টল মুলতুবি + + + আনইনস্টল + খুলুন + ডাউনগ্রেডের জন্য আনইনস্টল প্রয়োজন + সংস্করণ %1$s ইনস্টল করতে বর্তমান সংস্করণ (%2$s) প্রথমে আনইনস্টল করতে হবে। অ্যাপের ডেটা মুছে যাবে। + প্রথমে আনইনস্টল করুন + %1$s ইনস্টল করুন + %1$s খুলতে ব্যর্থ + %1$s আনইনস্টল করতে ব্যর্থ + + + সর্বশেষ + + + সর্বশেষ পরীক্ষা: %1$s + কখনো পরীক্ষা করা হয়নি + এইমাত্র + %1$d মিনিট আগে + %1$d ঘণ্টা আগে + আপডেট পরীক্ষা করা হচ্ছে… + diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index dec690f8..b8db96bf 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -293,4 +293,46 @@ Ninguna versión seleccionada Versiones + + Abrir repositorio + Abrir en navegador + Cancelar descarga + Mostrar opciones de instalación + + + Sin descripción proporcionada. + Sin notas de versión. + + + No disponible + Actualizar app + Instalación pendiente + + + Abrir en Obtainium + Gestionar actualizaciones automáticamente + Inspeccionar con AppManager + Verificar permisos, rastreadores y seguridad + + + Desinstalar + Abrir + La degradación requiere desinstalar + Instalar la versión %1$s requiere desinstalar la versión actual (%2$s) primero. Los datos de la app se perderán. + Desinstalar primero + Instalar %1$s + Error al abrir %1$s + Error al desinstalar %1$s + + + Última + + + Última comprobación: %1$s + Nunca comprobado + justo ahora + hace %1$d min + hace %1$d h + Comprobando actualizaciones… + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index b36488e1..31df05ce 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -293,4 +293,46 @@ Aucune version sélectionnée Versions + + Ouvrir le dépôt + Ouvrir dans le navigateur + Annuler le téléchargement + Afficher les options d\'installation + + + Aucune description fournie. + Aucune note de version. + + + Non disponible + Mettre à jour + Installation en attente + + + Ouvrir dans Obtainium + Gérer les mises à jour automatiquement + Inspecter avec AppManager + Vérifier les permissions, trackers et sécurité + + + Désinstaller + Ouvrir + La rétrogradation nécessite la désinstallation + L\'installation de la version %1$s nécessite la désinstallation de la version actuelle (%2$s). Les données de l\'application seront perdues. + Désinstaller d\'abord + Installer %1$s + Impossible d\'ouvrir %1$s + Impossible de désinstaller %1$s + + + Dernière + + + Dernière vérification : %1$s + Jamais vérifié + à l\'instant + il y a %1$d min + il y a %1$d h + Vérification des mises à jour… + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index f9eb660b..dfe2da02 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -215,8 +215,6 @@ डाउनलोड की गई फ़ाइल नहीं मिली रुझान - नया - हाल ही में अपडेट किया गया रिपॉजिटरी ढूंढी जा रही हैं... और लोड हो रहा है... @@ -343,4 +341,32 @@ प्री-रिलीज़ कोई संस्करण चयनित नहीं संस्करण + + + हॉट रिलीज़ + सबसे लोकप्रिय + + + इंस्टॉल लंबित + + + अनइंस्टॉल + खोलें + डाउनग्रेड के लिए अनइंस्टॉल आवश्यक + संस्करण %1$s इंस्टॉल करने के लिए पहले वर्तमान संस्करण (%2$s) को अनइंस्टॉल करना होगा। ऐप डेटा खो जाएगा। + पहले अनइंस्टॉल करें + %1$s इंस्टॉल करें + %1$s खोलने में विफल + %1$s अनइंस्टॉल करने में विफल + + + नवीनतम + + + अंतिम जाँच: %1$s + कभी जाँच नहीं की + अभी + %1$d मिनट पहले + %1$d घंटे पहले + अपडेट की जाँच हो रही है… diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index 61ecc718..b9aee12f 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -342,4 +342,33 @@ Nessuna versione selezionata Versioni + + Installazione in sospeso + + + Disinstalla + Apri + Il downgrade richiede la disinstallazione + L\'installazione della versione %1$s richiede la disinstallazione della versione corrente (%2$s). I dati dell\'app verranno persi. + Disinstalla prima + Installa %1$s + Impossibile aprire %1$s + Impossibile disinstallare %1$s + + + Ultima + + + Chiaro + Scuro + Sistema + + + Ultimo controllo: %1$s + Mai controllato + proprio ora + %1$d min fa + %1$d h fa + Controllo aggiornamenti… + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 1735bded..79d01447 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -293,4 +293,46 @@ バージョン未選択 バージョン + + リポジトリを開く + ブラウザで開く + ダウンロードをキャンセル + インストールオプションを表示 + + + 説明はありません。 + リリースノートはありません。 + + + 利用不可 + アプリを更新 + インストール待ち + + + Obtainiumで開く + 自動的にアップデートを管理 + AppManagerで検査 + 権限、トラッカー、セキュリティを確認 + + + アンインストール + 開く + ダウングレードにはアンインストールが必要 + バージョン%1$sのインストールには、現在のバージョン(%2$s)のアンインストールが必要です。アプリデータは失われます。 + 先にアンインストール + %1$sをインストール + %1$sを開けませんでした + %1$sのアンインストールに失敗しました + + + 最新 + + + 最終確認: %1$s + 未確認 + たった今 + %1$d分前 + %1$d時間前 + アップデートを確認中… + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml index 8c724d84..27195656 100644 --- a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml +++ b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml @@ -344,4 +344,28 @@ 선택된 버전 없음 버전 + + 설치 대기 중 + + + 제거 + 열기 + 다운그레이드를 위해 제거가 필요합니다 + 버전 %1$s을(를) 설치하려면 현재 버전(%2$s)을 먼저 제거해야 합니다. 앱 데이터가 삭제됩니다. + 먼저 제거 + %1$s 설치 + %1$s 열기 실패 + %1$s 제거 실패 + + + 최신 + + + 마지막 확인: %1$s + 확인한 적 없음 + 방금 + %1$d분 전 + %1$d시간 전 + 업데이트 확인 중… + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index 292020c7..d9e9edab 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -309,4 +309,28 @@ Nie wybrano wersji Wersje + + Oczekuje na instalację + + + Odinstaluj + Otwórz + Obniżenie wersji wymaga odinstalowania + Instalacja wersji %1$s wymaga odinstalowania bieżącej wersji (%2$s). Dane aplikacji zostaną utracone. + Najpierw odinstaluj + Zainstaluj %1$s + Nie udało się otworzyć %1$s + Nie udało się odinstalować %1$s + + + Najnowsza + + + Ostatnio sprawdzono: %1$s + Nigdy nie sprawdzano + właśnie teraz + %1$d min temu + %1$d godz. temu + Sprawdzanie aktualizacji… + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index 2a8c1a81..30c3991f 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -311,4 +311,28 @@ Версия не выбрана Версии + + Ожидает установки + + + Удалить + Открыть + Для понижения версии требуется удаление + Для установки версии %1$s необходимо сначала удалить текущую версию (%2$s). Данные приложения будут потеряны. + Сначала удалить + Установить %1$s + Не удалось открыть %1$s + Не удалось удалить %1$s + + + Последняя + + + Последняя проверка: %1$s + Не проверялось + только что + %1$d мин назад + %1$d ч назад + Проверка обновлений… + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index a5853ef1..0b43b913 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -232,9 +232,9 @@ Hız Sınırı Aşıldı Tüm %1$d API isteklerini kullandınız. - Tüm %1$s ücretsiz API isteğinizi kullandınız. + Tüm %1$d ücretsiz API isteğinizi kullandınız. %1$d dakika içinde yenilenir - Saat başı 60 yerine 5,000 istek için giriş yapın! + 💡 Saat başı 60 yerine 5.000 istek için giriş yapın! Giriş Yap Tamam Kapat @@ -343,4 +343,28 @@ Sürüm seçilmedi Sürümler + + Kurulum bekleniyor + + + Kaldır + + Sürüm düşürme kaldırma gerektirir + %1$s sürümünü yüklemek için önce mevcut sürümü (%2$s) kaldırmanız gerekir. Uygulama verileri kaybolacaktır. + Önce kaldır + %1$s yükle + %1$s açılamadı + %1$s kaldırılamadı + + + En son + + + Son kontrol: %1$s + Hiç kontrol edilmedi + az önce + %1$d dk önce + %1$d sa önce + Güncellemeler kontrol ediliyor… + diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index dc9cf014..4fe5e750 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -294,4 +294,46 @@ 未选择版本 版本 + + 打开仓库 + 在浏览器中打开 + 取消下载 + 显示安装选项 + + + 暂无描述。 + 暂无发行说明。 + + + 不可用 + 更新应用 + 等待安装 + + + 在 Obtainium 中打开 + 自动管理更新 + 使用 AppManager 检查 + 检查权限、追踪器和安全性 + + + 卸载 + 打开 + 降级需要先卸载 + 安装版本 %1$s 需要先卸载当前版本(%2$s)。应用数据将丢失。 + 先卸载 + 安装 %1$s + 无法打开 %1$s + 无法卸载 %1$s + + + 最新 + + + 上次检查:%1$s + 从未检查 + 刚刚 + %1$d 分钟前 + %1$d 小时前 + 正在检查更新… + \ No newline at end of file diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index e5c7f9de..9837d49f 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -33,6 +33,8 @@ data class DetailsState( val isDownloading: Boolean = false, val downloadProgressPercent: Int? = null, + val downloadedBytes: Long = 0L, + val totalBytes: Long? = null, val isInstalling: Boolean = false, val downloadError: String? = null, val installError: String? = null, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 00a269dd..7028d87b 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -67,6 +67,8 @@ class DetailsViewModel( private var currentDownloadJob: Job? = null private var currentAssetName: String? = null + private var cachedDownloadAssetName: String? = null + private val _state = MutableStateFlow(DetailsState()) val state = _state .onStart { @@ -316,21 +318,16 @@ class DetailsViewModel( if (primary != null && release != null) { if (installedApp != null && !installedApp.isPendingInstall && - !installedApp.isUpdateAvailable && normalizeVersion(release.tagName) != normalizeVersion(installedApp.installedVersion) && platform == Platform.ANDROID ) { - val isConfirmedDowngrade = if ( - normalizeVersion(release.tagName) == normalizeVersion(installedApp.latestVersion) && - (installedApp.latestVersionCode ?: 0L) > 0 - ) { - installedApp.installedVersionCode > (installedApp.latestVersionCode - ?: 0L) - } else { - true - } + val isDowngrade = isDowngradeVersion( + candidate = release.tagName, + current = installedApp.installedVersion, + allReleases = _state.value.allReleases + ) - if (isConfirmedDowngrade) { + if (isDowngrade) { viewModelScope.launch { _events.send( DetailsEvent.ShowDowngradeWarning( @@ -369,21 +366,16 @@ class DetailsViewModel( val assetName = currentAssetName if (assetName != null) { - viewModelScope.launch { - try { - val deleted = downloader.cancelDownload(assetName) - logger.debug("Cancel download - file deleted: $deleted") - - appendLog( - assetName = assetName, - size = 0L, - tag = _state.value.selectedRelease?.tagName ?: "", - result = LogResult.Cancelled - ) - } catch (t: Throwable) { - logger.error("Failed to cancel download: ${t.message}") - } - } + cachedDownloadAssetName = assetName + val releaseTag = _state.value.selectedRelease?.tagName ?: "" + val totalSize = _state.value.totalBytes ?: _state.value.downloadedBytes + appendLog( + assetName = assetName, + tag = releaseTag, + size = totalSize, + result = LogResult.Cancelled + ) + logger.debug("Download cancelled – keeping file for potential reuse: $assetName") } currentAssetName = null @@ -723,16 +715,42 @@ class DetailsViewModel( extOrMime = assetName.substringAfterLast('.', "").lowercase() ) - _state.value = _state.value.copy(downloadStage = DownloadStage.DOWNLOADING) - downloader.download(downloadUrl, assetName).collect { p -> - _state.value = _state.value.copy(downloadProgressPercent = p.percent) - if (p.percent == 100) { - _state.value = _state.value.copy(downloadStage = DownloadStage.VERIFYING) + val existingPath = downloader.getDownloadedFilePath(assetName) + val filePath: String + + val existingFile = existingPath?.let { java.io.File(it) } + if (existingFile != null && existingFile.exists() && existingFile.length() == sizeBytes) { + logger.debug("Reusing already downloaded file: $assetName") + filePath = existingPath + _state.value = _state.value.copy( + downloadProgressPercent = 100, + downloadedBytes = sizeBytes, + totalBytes = sizeBytes, + downloadStage = DownloadStage.VERIFYING + ) + } else { + _state.value = _state.value.copy( + downloadStage = DownloadStage.DOWNLOADING, + downloadedBytes = 0L, + totalBytes = sizeBytes + ) + downloader.download(downloadUrl, assetName).collect { p -> + _state.value = _state.value.copy( + downloadProgressPercent = p.percent, + downloadedBytes = p.bytesDownloaded, + totalBytes = p.totalBytes ?: sizeBytes + ) + if (p.percent == 100) { + _state.value = + _state.value.copy(downloadStage = DownloadStage.VERIFYING) + } } - } - val filePath = downloader.getDownloadedFilePath(assetName) - ?: throw IllegalStateException("Downloaded file not found") + filePath = downloader.getDownloadedFilePath(assetName) + ?: throw IllegalStateException("Downloaded file not found") + + cachedDownloadAssetName = assetName + } appendLog( assetName = assetName, @@ -983,9 +1001,17 @@ class DetailsViewModel( super.onCleared() currentDownloadJob?.cancel() - currentAssetName?.let { assetName -> + val assetsToClean = listOfNotNull(currentAssetName, cachedDownloadAssetName).distinct() + if (assetsToClean.isNotEmpty()) { viewModelScope.launch { - downloader.cancelDownload(assetName) + for (asset in assetsToClean) { + try { + downloader.cancelDownload(asset) + logger.debug("Cleaned up download on screen leave: $asset") + } catch (t: Throwable) { + logger.error("Failed to clean download on leave: ${t.message}") + } + } } } } @@ -994,6 +1020,58 @@ class DetailsViewModel( return version?.removePrefix("v")?.removePrefix("V")?.trim() ?: "" } + /** + * Returns true if [candidate] is strictly older than [current]. + * Uses list-index order as primary heuristic (releases are newest-first), + * and falls back to semantic version comparison when list lookup fails. + */ + private fun isDowngradeVersion( + candidate: String, + current: String, + allReleases: List + ): Boolean { + val normalizedCandidate = normalizeVersion(candidate) + val normalizedCurrent = normalizeVersion(current) + + if (normalizedCandidate == normalizedCurrent) return false + + val candidateIndex = allReleases.indexOfFirst { + normalizeVersion(it.tagName) == normalizedCandidate + } + val currentIndex = allReleases.indexOfFirst { + normalizeVersion(it.tagName) == normalizedCurrent + } + + if (candidateIndex != -1 && currentIndex != -1) { + return candidateIndex > currentIndex + } + + return compareSemanticVersions(normalizedCandidate, normalizedCurrent) < 0 + } + + /** + * Compares two semantic version strings. Returns positive if a > b, negative if a < b, 0 if equal. + */ + private fun compareSemanticVersions(a: String, b: String): Int { + val aCore = a.split("-", limit = 2) + val bCore = b.split("-", limit = 2) + val aParts = aCore[0].split(".") + val bParts = bCore[0].split(".") + + val maxLen = maxOf(aParts.size, bParts.size) + for (i in 0 until maxLen) { + val aPart = aParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L + val bPart = bParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L + if (aPart != bPart) return aPart.compareTo(bPart) + } + + val aHasPre = aCore.size > 1 + val bHasPre = bCore.size > 1 + if (aHasPre != bHasPre) return if (aHasPre) -1 else 1 + + return 0 + } + private companion object { const val OBTAINIUM_REPO_ID: Long = 523534328 const val APP_MANAGER_REPO_ID: Long = 268006778 diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt index b1fec6d4..d24603da 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt @@ -251,8 +251,13 @@ fun SmartInstallButton( fontWeight = FontWeight.Bold ) + val progressText = if (state.totalBytes != null && state.totalBytes > 0) { + "${formatFileSize(state.downloadedBytes)} / ${formatFileSize(state.totalBytes)}" + } else { + "${progress ?: 0}%" + } Text( - text = "${progress ?: 0}%", + text = progressText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) ) @@ -323,6 +328,9 @@ fun SmartInstallButton( if (primaryAsset != null) { val assetArch = extractArchitectureFromName(primaryAsset.name) val systemArch = state.systemArchitecture + val sizeText = formatFileSize(primaryAsset.size) + val archLabel = assetArch ?: systemArch.name.lowercase() + val subtitle = "$archLabel \u2022 $sizeText" Spacer(modifier = Modifier.height(2.dp)) @@ -331,7 +339,7 @@ fun SmartInstallButton( verticalAlignment = Alignment.CenterVertically ) { Text( - text = assetArch ?: systemArch.name.lowercase(), + text = subtitle, color = if (enabled) { when { isUpdateAvailable -> MaterialTheme.colorScheme.onTertiary.copy( @@ -449,6 +457,15 @@ private fun normalizeVersion(version: String): String { return version.removePrefix("v").removePrefix("V").trim() } +private fun formatFileSize(bytes: Long): String { + return when { + bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) + bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) + bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) + else -> "$bytes B" + } +} + @Preview @Composable fun SmartInstallButtonDownloadingPreview() {