├── .gitignore ├── .idea └── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── org │ │ └── fcitx │ │ └── fcitx5 │ │ └── android │ │ └── updater │ │ ├── Const.kt │ │ ├── MainActivity.kt │ │ ├── PackageUtils.kt │ │ ├── UpdaterApplication.kt │ │ ├── Utils.kt │ │ ├── api │ │ ├── CommonApi.kt │ │ ├── FDroidApi.kt │ │ ├── FDroidArtifact.kt │ │ ├── FDroidPackage.kt │ │ ├── GitHubApi.kt │ │ ├── JenkinsAndroidJob.kt │ │ ├── JenkinsApi.kt │ │ ├── JenkinsArtifact.kt │ │ └── JenkinsJobBuild.kt │ │ ├── model │ │ ├── FDroidVersionViewModel.kt │ │ ├── FileOperation.kt │ │ ├── JenkinsVersionViewModel.kt │ │ ├── MainViewModel.kt │ │ ├── RemoteVersionUiState.kt │ │ ├── VersionUi.kt │ │ └── VersionViewModel.kt │ │ ├── network │ │ ├── DownloadEvent.kt │ │ └── DownloadTask.kt │ │ └── ui │ │ ├── components │ │ ├── VersionCard.kt │ │ ├── VersionCardAction.kt │ │ ├── VersionCardMenu.kt │ │ └── Versions.kt │ │ └── theme │ │ ├── Color.kt │ │ └── Theme.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── github_mark.xml │ └── ic_launcher_background.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── resources.properties │ ├── values-night │ └── themes.xml │ ├── values-ru │ └── strings.xml │ ├── values-zh-rCN │ └── strings.xml │ ├── values │ ├── strings.xml │ └── themes.xml │ └── xml │ └── provider_paths.xml ├── build.gradle.kts ├── flake.lock ├── flake.nix ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── transifex.yml /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea/* 5 | !.idea/codeStyles/ 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | .cxx 11 | local.properties 12 | /.kotlin 13 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 10 | 11 | 123 | 124 | 126 | 127 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fcitx5-android-updater 2 | 3 | Download latest [fcitx5-android](https://github.com/fcitx5-android/fcitx5-android) CI builds from our [Jenkins server](https://jenkins.fcitx-im.org/). 4 | 5 | ## Download 6 | 7 | Jenkins: [![build status](https://img.shields.io/jenkins/build.svg?jobUrl=https://jenkins.fcitx-im.org/job/android/job/fcitx5-android-updater/)](https://jenkins.fcitx-im.org/job/android/job/fcitx5-android-updater/) 8 | 9 | ## Features 10 | 11 | - Download/Update/Uninstall installed fcitx5-android App and Plugins 12 | - Export/Share APKs 13 | 14 | ## Screenshots 15 | 16 | |1|2| 17 | |:-:|:-:| 18 | |![Main screen](https://user-images.githubusercontent.com/13914967/233012559-64efd2b7-9a7c-4897-8322-61f98a8dc993.png)|![Navigation drawer](https://user-images.githubusercontent.com/13914967/233012577-b9cdde83-2c04-4374-9459-68f0cc79e39a.png)| 19 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.internal.dsl.SigningConfig 2 | import com.android.build.gradle.internal.tasks.CompileArtProfileTask 3 | import com.android.build.gradle.internal.tasks.ExpandArtProfileWildcardsTask 4 | import com.android.build.gradle.internal.tasks.MergeArtProfileTask 5 | import com.android.build.gradle.tasks.PackageApplication 6 | import org.gradle.api.internal.provider.AbstractProperty 7 | import org.gradle.api.internal.provider.Providers 8 | import java.io.ByteArrayOutputStream 9 | import kotlin.io.encoding.Base64 10 | import kotlin.io.encoding.ExperimentalEncodingApi 11 | 12 | plugins { 13 | id("com.android.application") 14 | kotlin("android") 15 | kotlin("plugin.compose") 16 | kotlin("plugin.parcelize") 17 | } 18 | 19 | android { 20 | namespace = "org.fcitx.fcitx5.android.updater" 21 | compileSdk = 35 22 | buildToolsVersion = "35.0.0" 23 | defaultConfig { 24 | applicationId = "org.fcitx.fcitx5.android.updater" 25 | minSdk = 23 26 | targetSdk = 35 27 | versionCode = 2 28 | versionName = exec("git describe --tags --long --always", "1.1.0") 29 | setProperty("archivesBaseName", "$applicationId-$versionName") 30 | } 31 | buildTypes { 32 | release { 33 | isMinifyEnabled = true 34 | isShrinkResources = true 35 | proguardFiles( 36 | getDefaultProguardFile("proguard-android-optimize.txt"), 37 | "proguard-rules.pro" 38 | ) 39 | signingConfig = signingConfigs.createSigningConfigFromEnv() 40 | } 41 | debug { 42 | applicationIdSuffix = ".debug" 43 | } 44 | all { 45 | // remove META-INF/version-control-info.textproto 46 | @Suppress("UnstableApiUsage") 47 | vcsInfo.include = false 48 | } 49 | } 50 | dependenciesInfo { 51 | includeInApk = false 52 | includeInBundle = false 53 | } 54 | packaging { 55 | resources { 56 | excludes += setOf( 57 | "/META-INF/*.version", 58 | "/META-INF/*.kotlin_module", 59 | "/kotlin/**", 60 | "/DebugProbesKt.bin", 61 | "/kotlin-tooling-metadata.json" 62 | ) 63 | } 64 | } 65 | androidResources { 66 | @Suppress("UnstableApiUsage") 67 | generateLocaleConfig = true 68 | } 69 | compileOptions { 70 | sourceCompatibility = JavaVersion.VERSION_11 71 | targetCompatibility = JavaVersion.VERSION_11 72 | } 73 | kotlinOptions { 74 | jvmTarget = JavaVersion.VERSION_11.toString() 75 | } 76 | buildFeatures { 77 | buildConfig = true 78 | compose = true 79 | } 80 | } 81 | 82 | // remove META-INF/com/android/build/gradle/app-metadata.properties 83 | tasks.withType { 84 | val valueField = 85 | AbstractProperty::class.java.declaredFields.find { it.name == "value" } ?: run { 86 | println("class AbstractProperty field value not found, something could have gone wrong") 87 | return@withType 88 | } 89 | valueField.isAccessible = true 90 | doFirst { 91 | valueField.set(appMetadata, Providers.notDefined()) 92 | allInputFilesWithNameOnlyPathSensitivity.removeAll { true } 93 | } 94 | } 95 | 96 | // remove assets/dexopt/baseline.prof{,m} (baseline profile) 97 | tasks.withType { enabled = false } 98 | tasks.withType { enabled = false } 99 | tasks.withType { enabled = false } 100 | 101 | dependencies { 102 | implementation("net.swiftzer.semver:semver:2.0.0") 103 | implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14") 104 | implementation("androidx.core:core-ktx:1.15.0") 105 | implementation("androidx.activity:activity-compose:1.9.3") 106 | implementation(platform("androidx.compose:compose-bom:2024.11.00")) 107 | implementation("androidx.compose.material:material") 108 | implementation("androidx.compose.material:material-icons-extended") 109 | implementation("androidx.compose.ui:ui") 110 | implementation("androidx.compose.ui:ui-tooling-preview") 111 | debugImplementation("androidx.compose.ui:ui-tooling") 112 | implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0") 113 | implementation("androidx.navigation:navigation-compose:2.8.4") 114 | val lifecycleVersion = "2.8.7" 115 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion") 116 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion") 117 | } 118 | 119 | configurations { 120 | all { 121 | // remove Baseline Profile Installer or whatever it is... 122 | exclude(group = "androidx.profileinstaller", module = "profileinstaller") 123 | // remove libandroidx.graphics.path.so 124 | exclude(group = "androidx.graphics", module = "graphics-path") 125 | } 126 | } 127 | 128 | fun exec(cmd: String, defaultValue: String = ""): String { 129 | val stdout = ByteArrayOutputStream() 130 | val result = stdout.use { 131 | project.exec { 132 | commandLine = cmd.split(" ") 133 | standardOutput = stdout 134 | } 135 | } 136 | return if (result.exitValue == 0) stdout.toString().trim() else defaultValue 137 | } 138 | 139 | fun env(name: String): String? = System.getenv(name) 140 | 141 | private var signKeyTempFile: File? = null 142 | 143 | fun NamedDomainObjectContainer.createSigningConfigFromEnv(): SigningConfig? { 144 | var signKeyFile: File? = null 145 | env("SIGN_KEY_FILE")?.let { 146 | val file = File(it) 147 | if (file.exists()) { 148 | signKeyFile = file 149 | } 150 | } 151 | @OptIn(ExperimentalEncodingApi::class) 152 | env("SIGN_KEY_BASE64")?.let { 153 | if (signKeyTempFile?.exists() == true) { 154 | signKeyFile = signKeyTempFile 155 | } else { 156 | val buildDir = layout.buildDirectory.asFile.get() 157 | buildDir.mkdirs() 158 | val file = File.createTempFile("sign-", ".ks", buildDir) 159 | try { 160 | file.writeBytes(Base64.decode(it)) 161 | file.deleteOnExit() 162 | signKeyFile = file 163 | signKeyTempFile = file 164 | } catch (e: Exception) { 165 | file.delete() 166 | } 167 | } 168 | } 169 | signKeyFile ?: return null 170 | return create("release") { 171 | storeFile = signKeyFile 172 | storePassword = env("SIGN_KEY_PWD") 173 | keyAlias = env("SIGN_KEY_ALIAS") 174 | keyPassword = env("SIGN_KEY_PWD") 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | # Keep `Companion` object fields of serializable classes. 23 | # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. 24 | 25 | -dontobfuscate 26 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 44 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/Const.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater 2 | 3 | import android.os.Build 4 | 5 | object Const { 6 | val deviceABI: String 7 | get() = Build.SUPPORTED_ABIS[0] 8 | const val apkMineType = "application/vnd.android.package-archive" 9 | const val updaterProviderName = BuildConfig.APPLICATION_ID + ".provider" 10 | const val retryDuration = 1500L 11 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.widget.Toast 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.SystemBarStyle 8 | import androidx.activity.compose.BackHandler 9 | import androidx.activity.compose.setContent 10 | import androidx.activity.enableEdgeToEdge 11 | import androidx.activity.result.ActivityResultLauncher 12 | import androidx.activity.result.contract.ActivityResultContracts.CreateDocument 13 | import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult 14 | import androidx.activity.viewModels 15 | import androidx.compose.foundation.background 16 | import androidx.compose.foundation.clickable 17 | import androidx.compose.foundation.layout.Box 18 | import androidx.compose.foundation.layout.Column 19 | import androidx.compose.foundation.layout.PaddingValues 20 | import androidx.compose.foundation.layout.Row 21 | import androidx.compose.foundation.layout.Spacer 22 | import androidx.compose.foundation.layout.WindowInsets 23 | import androidx.compose.foundation.layout.WindowInsetsSides 24 | import androidx.compose.foundation.layout.fillMaxSize 25 | import androidx.compose.foundation.layout.fillMaxWidth 26 | import androidx.compose.foundation.layout.navigationBars 27 | import androidx.compose.foundation.layout.only 28 | import androidx.compose.foundation.layout.padding 29 | import androidx.compose.foundation.layout.size 30 | import androidx.compose.foundation.layout.statusBars 31 | import androidx.compose.foundation.layout.windowInsetsBottomHeight 32 | import androidx.compose.foundation.layout.windowInsetsPadding 33 | import androidx.compose.foundation.layout.windowInsetsTopHeight 34 | import androidx.compose.foundation.rememberScrollState 35 | import androidx.compose.foundation.verticalScroll 36 | import androidx.compose.material.AppBarDefaults 37 | import androidx.compose.material.CircularProgressIndicator 38 | import androidx.compose.material.DrawerDefaults 39 | import androidx.compose.material.ExperimentalMaterialApi 40 | import androidx.compose.material.Icon 41 | import androidx.compose.material.IconButton 42 | import androidx.compose.material.ListItem 43 | import androidx.compose.material.MaterialTheme 44 | import androidx.compose.material.Scaffold 45 | import androidx.compose.material.Surface 46 | import androidx.compose.material.Text 47 | import androidx.compose.material.TopAppBar 48 | import androidx.compose.material.icons.Icons 49 | import androidx.compose.material.icons.automirrored.filled.LibraryBooks 50 | import androidx.compose.material.icons.filled.Book 51 | import androidx.compose.material.icons.filled.Extension 52 | import androidx.compose.material.icons.filled.Keyboard 53 | import androidx.compose.material.icons.filled.Menu 54 | import androidx.compose.material.icons.filled.SystemUpdate 55 | import androidx.compose.material.primarySurface 56 | import androidx.compose.material.pullrefresh.PullRefreshIndicator 57 | import androidx.compose.material.pullrefresh.pullRefresh 58 | import androidx.compose.material.pullrefresh.rememberPullRefreshState 59 | import androidx.compose.material.rememberScaffoldState 60 | import androidx.compose.runtime.Composable 61 | import androidx.compose.runtime.CompositionLocalProvider 62 | import androidx.compose.runtime.LaunchedEffect 63 | import androidx.compose.runtime.collectAsState 64 | import androidx.compose.runtime.compositionLocalOf 65 | import androidx.compose.runtime.getValue 66 | import androidx.compose.runtime.rememberCoroutineScope 67 | import androidx.compose.ui.Alignment 68 | import androidx.compose.ui.Modifier 69 | import androidx.compose.ui.graphics.Color 70 | import androidx.compose.ui.graphics.StrokeCap 71 | import androidx.compose.ui.platform.LocalUriHandler 72 | import androidx.compose.ui.res.painterResource 73 | import androidx.compose.ui.res.stringResource 74 | import androidx.compose.ui.text.font.FontWeight 75 | import androidx.compose.ui.unit.dp 76 | import androidx.lifecycle.lifecycleScope 77 | import androidx.navigation.NavHostController 78 | import androidx.navigation.compose.NavHost 79 | import androidx.navigation.compose.composable 80 | import androidx.navigation.compose.currentBackStackEntryAsState 81 | import androidx.navigation.compose.rememberNavController 82 | import kotlinx.coroutines.flow.launchIn 83 | import kotlinx.coroutines.flow.onEach 84 | import kotlinx.coroutines.launch 85 | import org.fcitx.fcitx5.android.updater.api.FDroidApi 86 | import org.fcitx.fcitx5.android.updater.api.JenkinsApi 87 | import org.fcitx.fcitx5.android.updater.model.FDroidVersionViewModel 88 | import org.fcitx.fcitx5.android.updater.model.FileOperation 89 | import org.fcitx.fcitx5.android.updater.model.JenkinsVersionViewModel 90 | import org.fcitx.fcitx5.android.updater.model.MainViewModel 91 | import org.fcitx.fcitx5.android.updater.model.VersionViewModel 92 | import org.fcitx.fcitx5.android.updater.ui.components.Versions 93 | import org.fcitx.fcitx5.android.updater.ui.theme.Fcitx5ForAndroidUpdaterTheme 94 | import java.io.File 95 | 96 | class MainActivity : ComponentActivity() { 97 | 98 | private val viewModel: MainViewModel by viewModels() 99 | 100 | private lateinit var intentLauncher: ActivityResultLauncher 101 | 102 | private lateinit var exportLauncher: ActivityResultLauncher 103 | private lateinit var exportFile: File 104 | 105 | private fun toast(msg: String, duration: Int = Toast.LENGTH_SHORT) { 106 | Toast.makeText(this, msg, duration).show() 107 | } 108 | 109 | private fun handleFileOperation(it: FileOperation) { 110 | when (it) { 111 | is FileOperation.Install -> { 112 | intentLauncher.launch(PackageUtils.installIntent(it.file)) 113 | } 114 | is FileOperation.Uninstall -> { 115 | intentLauncher.launch(PackageUtils.uninstallIntent(it.packageName)) 116 | } 117 | is FileOperation.Share -> { 118 | val shareIntent = PackageUtils.shareIntent(it.file, it.name) 119 | startActivity(Intent.createChooser(shareIntent, it.name)) 120 | } 121 | is FileOperation.Export -> { 122 | exportFile = it.file 123 | exportLauncher.launch(it.name) 124 | } 125 | // TODO: share installed apk with FileProvider 126 | } 127 | } 128 | 129 | override fun onCreate(savedInstanceState: Bundle?) { 130 | super.onCreate(savedInstanceState) 131 | enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(0)) 132 | intentLauncher = registerForActivityResult(StartActivityForResult()) { 133 | viewModel.versions.value.forEach { 134 | it.value.refreshIfInstalledChanged() 135 | } 136 | } 137 | exportLauncher = registerForActivityResult(CreateDocument(Const.apkMineType)) { 138 | val uri = it ?: return@registerForActivityResult 139 | lifecycleScope.launch { 140 | contentResolver.openOutputStream(uri)?.use { o -> 141 | exportFile.inputStream().use { i -> i.copyTo(o) } 142 | } 143 | } 144 | } 145 | lifecycleScope.launch { 146 | val loadedVersions: Map 147 | if (viewModel.loaded.value) { 148 | loadedVersions = viewModel.versions.value 149 | } else { 150 | loadedVersions = sortedMapOf() 151 | JenkinsApi.getAllAndroidJobs().forEach { (job, buildNumbers) -> 152 | val model = JenkinsVersionViewModel(job, buildNumbers) 153 | loadedVersions[model.name] = model 154 | } 155 | FDroidApi.getAllPackages().forEach { 156 | val model = FDroidVersionViewModel(it) 157 | loadedVersions[model.name] = model 158 | } 159 | viewModel.versions.value = loadedVersions 160 | viewModel.loaded.value = true 161 | } 162 | loadedVersions.forEach { (jobName, vvm) -> 163 | vvm.toastMessage.onEach { toast("$jobName: $it") }.launchIn(this) 164 | vvm.fileOperation.onEach { handleFileOperation(it) }.launchIn(this) 165 | } 166 | } 167 | setContent { 168 | Fcitx5ForAndroidUpdaterTheme { 169 | val loaded by viewModel.loaded.collectAsState() 170 | val versions by viewModel.versions.collectAsState() 171 | MainScreen(versions) { pv, nc, v -> 172 | if (loaded) NavScreen(paddingValues = pv, navController = nc, viewModels = v) 173 | else LoadingScreen() 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | @OptIn(ExperimentalMaterialApi::class) 181 | @Composable 182 | fun MainScreen( 183 | viewModels: Map, 184 | content: @Composable (PaddingValues, NavHostController, viewModels: Map) -> Unit 185 | ) { 186 | val navController = rememberNavController() 187 | val navBackStackEntry by navController.currentBackStackEntryAsState() 188 | val scaffoldState = rememberScaffoldState() 189 | val scope = rememberCoroutineScope() 190 | val scrimColor = Color.Black.copy(DrawerDefaults.ScrimOpacity) 191 | Scaffold( 192 | scaffoldState = scaffoldState, 193 | modifier = Modifier 194 | .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)), 195 | topBar = { 196 | TopAppBar( 197 | title = { Text(stringResource(R.string.app_name)) }, 198 | backgroundColor = MaterialTheme.colors.primarySurface, 199 | windowInsets = AppBarDefaults.topAppBarWindowInsets, 200 | navigationIcon = { 201 | IconButton(onClick = { scope.launch { scaffoldState.drawerState.open() } }) { 202 | Icon(imageVector = Icons.Default.Menu, contentDescription = null) 203 | } 204 | } 205 | ) 206 | }, 207 | drawerScrimColor = scrimColor, 208 | drawerContent = { 209 | Box { 210 | Box( 211 | modifier = Modifier 212 | .align(Alignment.TopCenter) 213 | .fillMaxWidth() 214 | .windowInsetsTopHeight(WindowInsets.statusBars) 215 | .background(scrimColor) 216 | ) 217 | Column(modifier = Modifier.verticalScroll(rememberScrollState())) { 218 | Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) 219 | viewModels.forEach { (name, _) -> 220 | val selected = navBackStackEntry?.destination?.route == name 221 | val color = 222 | if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface 223 | val icon = when { 224 | name == "fcitx5-android" -> Icons.Default.Keyboard 225 | name == "fcitx5-android-updater" -> Icons.Default.SystemUpdate 226 | name.startsWith("pinyin-") -> Icons.AutoMirrored.Filled.LibraryBooks 227 | name.startsWith("tables-") -> Icons.Default.Book 228 | else -> Icons.Default.Extension 229 | } 230 | ListItem( 231 | modifier = Modifier.clickable { 232 | scope.launch { 233 | scaffoldState.drawerState.close() 234 | } 235 | navController.navigate(name) { 236 | // clear navigation stack before navigation 237 | popUpTo(0) 238 | } 239 | }, 240 | icon = { Icon(icon, contentDescription = null, tint = color) }, 241 | text = { 242 | Text( 243 | text = name.removePrefix("fcitx5-android-"), 244 | color = color, 245 | fontWeight = FontWeight.SemiBold, 246 | ) 247 | } 248 | ) 249 | } 250 | Spacer( 251 | Modifier 252 | .padding(bottom = 16.dp) 253 | .windowInsetsBottomHeight(WindowInsets.navigationBars) 254 | ) 255 | } 256 | } 257 | } 258 | ) { paddingValues -> 259 | content(paddingValues, navController, viewModels) 260 | } 261 | BackHandler(enabled = scaffoldState.drawerState.isOpen) { 262 | scope.launch { 263 | scaffoldState.drawerState.close() 264 | } 265 | } 266 | } 267 | 268 | @Composable 269 | fun LoadingScreen() { 270 | Box( 271 | contentAlignment = Alignment.Center, 272 | modifier = Modifier.fillMaxSize() 273 | ) { 274 | CircularProgressIndicator( 275 | modifier = Modifier.size(64.dp), 276 | strokeWidth = 5.dp, 277 | strokeCap = StrokeCap.Square 278 | ) 279 | } 280 | } 281 | 282 | @Composable 283 | fun NavScreen( 284 | paddingValues: PaddingValues, 285 | navController: NavHostController, 286 | viewModels: Map 287 | ) { 288 | NavHost( 289 | navController = navController, 290 | startDestination = viewModels.keys.first(), 291 | modifier = Modifier.padding(paddingValues), 292 | contentAlignment = Alignment.TopCenter 293 | ) { 294 | viewModels.forEach { (jobName, viewModel) -> 295 | composable(jobName) { 296 | VersionScreen(viewModel) 297 | } 298 | } 299 | } 300 | } 301 | 302 | val LocalVersionViewModel = compositionLocalOf { error("No view model") } 303 | 304 | @Composable 305 | fun versionViewModel() = LocalVersionViewModel.current 306 | 307 | @OptIn(ExperimentalMaterialApi::class) 308 | @Composable 309 | fun VersionScreen(viewModel: VersionViewModel) { 310 | CompositionLocalProvider(LocalVersionViewModel provides viewModel) { 311 | LaunchedEffect(viewModel) { 312 | if (!viewModel.hasRefreshed) { 313 | viewModel.refresh() 314 | } 315 | } 316 | val refreshing by viewModel.isRefreshing.collectAsState() 317 | val pullRefreshState = rememberPullRefreshState(refreshing, { viewModel.refresh() }) 318 | val urlHandler = LocalUriHandler.current 319 | Box( 320 | Modifier 321 | .fillMaxSize() 322 | .pullRefresh(pullRefreshState) 323 | ) { 324 | Column(modifier = Modifier.verticalScroll(rememberScrollState())) { 325 | Surface( 326 | modifier = Modifier 327 | .fillMaxWidth() 328 | .clickable { 329 | urlHandler.openUri(viewModel.url) 330 | }, 331 | elevation = 2.dp 332 | ) { 333 | Row( 334 | modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), 335 | verticalAlignment = Alignment.CenterVertically 336 | ) { 337 | Icon( 338 | painter = painterResource(R.drawable.github_mark), 339 | contentDescription = "GitHub Logo", 340 | modifier = Modifier.size(18.dp), 341 | tint = MaterialTheme.colors.onSurface 342 | ) 343 | Text( 344 | text = viewModel.name, 345 | modifier = Modifier.padding(start = 10.dp), 346 | fontWeight = FontWeight.SemiBold, 347 | style = MaterialTheme.typography.body1 348 | ) 349 | } 350 | } 351 | Versions( 352 | stringResource(R.string.installed), 353 | listOf(viewModel.installedVersion) 354 | ) 355 | Versions( 356 | stringResource(R.string.versions), 357 | viewModel.sortedVersions 358 | ) 359 | Spacer( 360 | Modifier 361 | .padding(bottom = 16.dp) 362 | .windowInsetsBottomHeight(WindowInsets.navigationBars) 363 | ) 364 | } 365 | PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) 366 | } 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/PackageUtils.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.pm.PackageInfo 6 | import android.content.pm.PackageManager 7 | import android.net.Uri 8 | import android.os.Build 9 | import androidx.core.content.FileProvider 10 | import java.io.File 11 | import kotlin.math.pow 12 | 13 | object PackageUtils { 14 | 15 | private fun packageInfoToVersion(info: PackageInfo) = 16 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 17 | (info.versionName ?: "") to info.longVersionCode 18 | } else { 19 | @Suppress("DEPRECATION") 20 | (info.versionName ?: "") to info.versionCode.toLong() 21 | } 22 | 23 | fun getVersionInfo(context: Context, apkFilePath: String) = 24 | context.packageManager.run { 25 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 26 | getPackageArchiveInfo(apkFilePath, PackageManager.PackageInfoFlags.of(0)) 27 | } else { 28 | getPackageArchiveInfo(apkFilePath, 0) 29 | }?.let { packageInfoToVersion(it) } 30 | } 31 | 32 | fun getInstalledVersionInfo( 33 | context: Context, 34 | packageName: String 35 | ) = context.packageManager.runCatching { 36 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 37 | getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) 38 | } else { 39 | getPackageInfo(packageName, 0) 40 | }.let { packageInfoToVersion(it) } 41 | }.getOrNull() 42 | 43 | fun getInstalledPath( 44 | context: Context, 45 | packageName: String 46 | ) = context.packageManager.runCatching { 47 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 48 | getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0L)) 49 | } else { 50 | getApplicationInfo(packageName, 0) 51 | }.publicSourceDir 52 | }.getOrNull() 53 | 54 | fun getInstalledSize( 55 | context: Context, 56 | packageName: String 57 | ) = getInstalledPath(context, packageName)?.let { path -> 58 | File(path) 59 | .length() 60 | .takeIf { it != 0L } 61 | // Bytes to MiB 62 | ?.let { it / 2.0.pow(20) } 63 | } 64 | 65 | fun installIntent(apkFile: File) = 66 | Intent(Intent.ACTION_VIEW).apply { 67 | setDataAndType( 68 | FileProvider.getUriForFile( 69 | UpdaterApplication.context, 70 | Const.updaterProviderName, 71 | apkFile 72 | ), 73 | Const.apkMineType 74 | ) 75 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 76 | } 77 | 78 | fun uninstallIntent(packageName: String) = 79 | Intent(Intent.ACTION_DELETE).apply { 80 | data = Uri.parse("package:$packageName") 81 | } 82 | 83 | fun shareIntent(file: File, outputName: String) = 84 | Intent(Intent.ACTION_SEND).apply { 85 | putExtra(Intent.EXTRA_TITLE, outputName) 86 | type = Const.apkMineType 87 | putExtra( 88 | Intent.EXTRA_STREAM, 89 | FileProvider.getUriForFile( 90 | UpdaterApplication.context, 91 | Const.updaterProviderName, 92 | file, 93 | outputName 94 | ) 95 | ) 96 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 97 | } 98 | 99 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/UpdaterApplication.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | 6 | class UpdaterApplication : Application() { 7 | override fun onCreate() { 8 | super.onCreate() 9 | instance = this 10 | migrateOldDownloads() 11 | } 12 | 13 | private fun migrateOldDownloads() { 14 | val apks = externalDir.listFiles { _, name -> name.endsWith(".apk") } ?: return 15 | val appDownloadDir = externalDir.resolve("fcitx5-android") 16 | apks.forEach { 17 | it.renameTo(appDownloadDir.resolve(it.name)) 18 | } 19 | } 20 | 21 | companion object { 22 | private var instance: UpdaterApplication? = null 23 | 24 | val context: Context 25 | get() = instance!!.applicationContext 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/Utils.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater 2 | 3 | import kotlinx.coroutines.async 4 | import kotlinx.coroutines.awaitAll 5 | import kotlinx.coroutines.coroutineScope 6 | import kotlinx.coroutines.suspendCancellableCoroutine 7 | import okhttp3.Call 8 | import okhttp3.Callback 9 | import okhttp3.OkHttpClient 10 | import okhttp3.Response 11 | import org.fcitx.fcitx5.android.updater.api.JenkinsArtifact 12 | import java.io.IOException 13 | import kotlin.coroutines.resume 14 | import kotlin.coroutines.resumeWithException 15 | import kotlin.math.pow 16 | 17 | suspend fun Call.await() = suspendCancellableCoroutine { 18 | enqueue(object : Callback { 19 | override fun onFailure(call: Call, e: IOException) { 20 | it.resumeWithException(e) 21 | } 22 | 23 | override fun onResponse(call: Call, response: Response) { 24 | it.resume(response) 25 | } 26 | }) 27 | it.invokeOnCancellation { 28 | runCatching { cancel() } 29 | } 30 | } 31 | 32 | suspend fun Iterable.parallelMap(f: suspend (A) -> B): List = coroutineScope { 33 | map { async { f(it) } }.awaitAll() 34 | } 35 | 36 | fun List.selectByABI() = 37 | filter { it.fileName.endsWith(".apk") }.let { apks -> 38 | if (apks.size == 1) 39 | apks.first() 40 | else 41 | apks.find { it.fileName.contains(Const.deviceABI) } 42 | } 43 | 44 | 45 | fun JenkinsArtifact.extractVersionName() = artifactNameRegex.find(fileName)?.let { 46 | val groups = it.groupValues 47 | groups.getOrNull(1)?.let { tag -> 48 | groups.getOrNull(2)?.toIntOrNull()?.let { commitInc -> 49 | groups.getOrNull(3)?.let { hash -> 50 | "$tag-$commitInc-g$hash" 51 | } 52 | } 53 | } 54 | } 55 | 56 | private val artifactNameRegex = "\\S*-([^-]+)-([^-]+)-g([^-]+)-\\S*".toRegex() 57 | 58 | 59 | fun parseVersionNumber(raw: String): Result> = runCatching { 60 | val g = raw.split('-') 61 | require(g.size == 3) 62 | val tag = g[0] 63 | val commitInc = g[1].toInt() 64 | val hash = g[2].drop(1) 65 | Triple(tag, commitInc, hash) 66 | } 67 | 68 | val httpClient = OkHttpClient() 69 | 70 | val externalDir by lazy { UpdaterApplication.context.getExternalFilesDir(null)!! } 71 | 72 | inline fun Result.flatMap(block: (T) -> Result) = 73 | if (isFailure) 74 | Result.failure(exceptionOrNull()!!) 75 | else 76 | block(getOrNull()!!) 77 | 78 | @Suppress("NOTHING_TO_INLINE") 79 | inline fun Iterable>.catResults() = mapNotNull { it.getOrNull() } 80 | 81 | fun bytesToMiB(src: Long) = src.toDouble() / 2.0.pow(20) 82 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/api/CommonApi.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.api 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import okhttp3.Request 6 | import org.fcitx.fcitx5.android.updater.await 7 | import org.fcitx.fcitx5.android.updater.httpClient 8 | 9 | object CommonApi { 10 | suspend fun getContentLength(url: String) = withContext(Dispatchers.IO) { 11 | val request = Request.Builder() 12 | .url(url) 13 | .head() 14 | .build() 15 | runCatching { 16 | val response = httpClient.newCall(request).await() 17 | response.header("Content-Length")?.toLong() 18 | ?: throw RuntimeException("Unable to get content length") 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/api/FDroidApi.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.api 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import okhttp3.Request 6 | import org.fcitx.fcitx5.android.updater.await 7 | import org.fcitx.fcitx5.android.updater.bytesToMiB 8 | import org.fcitx.fcitx5.android.updater.httpClient 9 | import org.json.JSONObject 10 | 11 | object FDroidApi { 12 | private suspend fun getAll() = withContext(Dispatchers.IO) { 13 | val request = Request.Builder() 14 | .url("https://f5a.torus.icu/fdroid/repo/index-v2.json") 15 | .build() 16 | runCatching { 17 | val response = httpClient.newCall(request).await() 18 | val jObject = JSONObject(response.body.string()) 19 | val packages = jObject.getJSONObject("packages") 20 | val fDroidPackages = mutableListOf() 21 | packages.keys().forEach { pkgName -> 22 | fDroidPackages.add(parsePackage(pkgName, packages.getJSONObject(pkgName))) 23 | } 24 | fDroidPackages 25 | } 26 | } 27 | 28 | private fun parsePackage(pkgName: String, packageObj: JSONObject): FDroidPackage { 29 | val metadataObj = packageObj.getJSONObject("metadata") 30 | val url = metadataObj.getString("webSite") 31 | val versionsObj = packageObj.getJSONObject("versions") 32 | val versions = mutableListOf() 33 | versionsObj.keys().forEach { sha -> 34 | val versionObj = versionsObj.getJSONObject(sha) 35 | val version = parseVersion(versionObj) 36 | versions.add(version) 37 | } 38 | return FDroidPackage(pkgName, url, versions) 39 | } 40 | 41 | private fun parseVersion(versionObj: JSONObject): FDroidPackage.Version { 42 | val fileObj = versionObj.getJSONObject("file") 43 | // drop prefix / 44 | val fileName = fileObj.getString("name").drop(1) 45 | val fileSize = fileObj.getString("size").toLong() 46 | val manifestObj = versionObj.getJSONObject("manifest") 47 | val versionCode = manifestObj.getLong("versionCode") 48 | val versionName = manifestObj.getString("versionName") 49 | val abi = manifestObj.optJSONArray("nativecode") 50 | val abiList = abi?.let { 51 | val list = mutableListOf() 52 | for (i in 0.., 7 | ) { 8 | data class Version( 9 | val versionCode: Long, 10 | val versionName: String, 11 | val artifact: FDroidArtifact, 12 | val abi: List?, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/api/GitHubApi.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.api 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import okhttp3.Request 6 | import org.fcitx.fcitx5.android.updater.await 7 | import org.fcitx.fcitx5.android.updater.httpClient 8 | 9 | object GitHubApi { 10 | 11 | suspend fun getCommitNumber(owner: String, repo: String, gitHash: String): Result = 12 | withContext(Dispatchers.IO) { 13 | val request = Request.Builder() 14 | .url("https://api.github.com/repos/$owner/$repo/commits?per_page=1&sha=$gitHash") 15 | .header("User-Agent", "request") 16 | .head() 17 | .build() 18 | runCatching { 19 | val response = 20 | httpClient.newCall(request).await() 21 | val links = response.header("Link") 22 | checkNotNull(links) { "Unable to find 'Link' in headers" } 23 | val result = REGEX.find(links) 24 | result?.groupValues?.getOrNull(1) ?: error("Failed to parse $links") 25 | } 26 | } 27 | 28 | private val REGEX = "next.*page=(\\d+).*last.*".toRegex() 29 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/api/JenkinsAndroidJob.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.api 2 | 3 | data class JenkinsAndroidJob( 4 | val jobName: String, 5 | val pkgName: String, 6 | val url: String 7 | ) 8 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/api/JenkinsApi.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.api 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import okhttp3.Request 6 | import org.fcitx.fcitx5.android.updater.await 7 | import org.fcitx.fcitx5.android.updater.catResults 8 | import org.fcitx.fcitx5.android.updater.flatMap 9 | import org.fcitx.fcitx5.android.updater.httpClient 10 | import org.fcitx.fcitx5.android.updater.parallelMap 11 | import org.json.JSONObject 12 | 13 | object JenkinsApi { 14 | 15 | private suspend fun getAndroidJobs(): Result> = withContext(Dispatchers.IO) { 16 | val request = Request.Builder() 17 | .url("https://jenkins.fcitx-im.org/job/android/api/json") 18 | .build() 19 | runCatching { 20 | val response = httpClient.newCall(request).await() 21 | val jObject = JSONObject(response.body.string()) 22 | val jArray = jObject.getJSONArray("jobs") 23 | val result = mutableListOf() 24 | for (i in 0 until jArray.length()) { 25 | val jobObj = jArray.getJSONObject(i) 26 | val color = jobObj.getString("color") 27 | if (color == "disabled") 28 | continue 29 | result.add(jobObj.getString("name")) 30 | } 31 | result 32 | } 33 | } 34 | 35 | private suspend fun getJobBuildNumbersAndDescription(job: String): Result, String>> = 36 | withContext(Dispatchers.IO) { 37 | val request = Request.Builder() 38 | .url("https://jenkins.fcitx-im.org/job/android/job/$job/api/json") 39 | .build() 40 | runCatching { 41 | val response = httpClient.newCall(request).await() 42 | val jObject = JSONObject(response.body.string()) 43 | val jArray = jObject.getJSONArray("builds") 44 | val numbers = mutableListOf() 45 | for (i in 0 until jArray.length()) { 46 | numbers.add(jArray.getJSONObject(i).getInt("number")) 47 | } 48 | val description = jObject.getString("description") 49 | numbers to description 50 | } 51 | } 52 | 53 | private suspend fun getJobBuild(job: String, buildNumber: Int): Result = 54 | withContext(Dispatchers.IO) { 55 | val request = Request.Builder() 56 | .url("https://jenkins.fcitx-im.org/job/android/job/$job/$buildNumber/api/json") 57 | .build() 58 | runCatching { 59 | val response = httpClient.newCall(request).await() 60 | val jObject = JSONObject(response.body.string()) 61 | val actions = jObject.getJSONArray("actions") 62 | var buildData: JSONObject? = null 63 | for (i in 0 until actions.length()) { 64 | val action = actions.getJSONObject(i) 65 | if (action.optString("_class") == "hudson.plugins.git.util.BuildData") { 66 | buildData = action 67 | break 68 | } 69 | } 70 | requireNotNull(buildData) { "Failed to find buildData" } 71 | val sha1 = buildData.getJSONObject("lastBuiltRevision").getString("SHA1") 72 | val artifacts = mutableListOf() 73 | val artifactArray = jObject.getJSONArray("artifacts") 74 | for (i in 0 until artifactArray.length()) { 75 | val artifact = artifactArray.getJSONObject(i) 76 | artifacts.add( 77 | JenkinsArtifact( 78 | artifact.getString("fileName"), 79 | artifact.getString("relativePath"), 80 | "https://jenkins.fcitx-im.org/job/android/job/$job/$buildNumber/artifact/${ 81 | artifact.getString( 82 | "relativePath" 83 | ) 84 | }" 85 | ) 86 | ) 87 | } 88 | JenkinsJobBuild(job, buildNumber, sha1.take(7), artifacts) 89 | } 90 | } 91 | 92 | private fun getPackageNameAndUrlFromDescription(description: String): Result> = 93 | runCatching { 94 | val jObject = JSONObject(description) 95 | jObject.getString("pkgName") to jObject.getString("url") 96 | } 97 | 98 | suspend fun getAllAndroidJobs() = 99 | getAndroidJobs() 100 | .getOrElse { emptyList() } 101 | .parallelMap { 102 | getJobBuildNumbersAndDescription(it).flatMap { (numbers, description) -> 103 | getPackageNameAndUrlFromDescription(description).flatMap { (pkgName, url) -> 104 | Result.success(JenkinsAndroidJob(it, pkgName, url) to numbers) 105 | } 106 | } 107 | } 108 | .catResults() 109 | .associate { it } 110 | .toSortedMap { a, b -> a.jobName.compareTo(b.jobName) } 111 | 112 | suspend fun getJobBuildsByBuildNumbers(job: JenkinsAndroidJob, buildNumbers: List) = 113 | buildNumbers 114 | .parallelMap { getJobBuild(job.jobName, it) } 115 | .catResults() 116 | 117 | suspend fun getJobBuilds(job: JenkinsAndroidJob) = 118 | getJobBuildNumbersAndDescription(job.jobName) 119 | .getOrNull() 120 | ?.let { getJobBuildsByBuildNumbers(job, it.first) } 121 | ?: emptyList() 122 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/api/JenkinsArtifact.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.api 2 | 3 | data class JenkinsArtifact( 4 | val fileName: String, 5 | val relativePath: String, 6 | val url: String 7 | ) -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/api/JenkinsJobBuild.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.api 2 | 3 | data class JenkinsJobBuild( 4 | val jobName: String, 5 | val buildNumber: Int, 6 | val revision: String, 7 | val artifacts: List 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/model/FDroidVersionViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.model 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import kotlinx.coroutines.launch 5 | import org.fcitx.fcitx5.android.updater.Const 6 | import org.fcitx.fcitx5.android.updater.api.FDroidApi 7 | import org.fcitx.fcitx5.android.updater.api.FDroidPackage 8 | 9 | class FDroidVersionViewModel(private val pkg: FDroidPackage) : VersionViewModel( 10 | pkg.pkgName.removePrefix("org.fcitx.fcitx5.android.plugin.").replace('_', '-'), 11 | pkg.pkgName, 12 | pkg.url 13 | ) { 14 | override fun refresh() { 15 | if (isRefreshing.value) 16 | return 17 | refreshInstalledVersion() 18 | allVersions.forEach { (k, v) -> 19 | val isInstalled = v.versionCode == installedVersion.versionCode 20 | allVersions[k] = when (v) { 21 | is VersionUi.Installed -> v 22 | is VersionUi.Local -> v.copy(isInstalled = isInstalled) 23 | is VersionUi.Remote -> v.copy(isInstalled = isInstalled) 24 | } 25 | } 26 | viewModelScope.launch { 27 | _isRefreshing.emit(true) 28 | remoteVersions.clear() 29 | FDroidApi.getPackageVersions(pkg.pkgName) 30 | .mapNotNull { 31 | if (it.abi?.contains(Const.deviceABI) != false) { 32 | VersionUi.Remote( 33 | pkgName, 34 | it.versionCode, 35 | it.versionName, 36 | it.artifact.size, 37 | it.versionCode == installedVersion.versionCode, 38 | it.artifact.url, 39 | ) 40 | } else null 41 | }.also { 42 | allVersions.clear() 43 | refreshLocalVersions() 44 | }.forEach { 45 | remoteVersions[it.versionName] = it 46 | if (it.versionName !in localVersions) 47 | allVersions[it.versionName] = it 48 | } 49 | _isRefreshing.emit(false) 50 | } 51 | } 52 | 53 | override val sortedVersions: List 54 | get() = allVersions.values.sortedByDescending { it.versionCode } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/model/FileOperation.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.model 2 | 3 | import java.io.File 4 | 5 | sealed interface FileOperation { 6 | data class Uninstall(val packageName: String) : FileOperation 7 | data class Install(val file: File) : FileOperation 8 | data class Share(val file: File, val name: String) : FileOperation 9 | data class Export(val file: File, val name: String) : FileOperation 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/model/JenkinsVersionViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.model 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import kotlinx.coroutines.launch 5 | import net.swiftzer.semver.SemVer 6 | import org.fcitx.fcitx5.android.updater.api.CommonApi 7 | import org.fcitx.fcitx5.android.updater.api.JenkinsAndroidJob 8 | import org.fcitx.fcitx5.android.updater.api.JenkinsApi 9 | import org.fcitx.fcitx5.android.updater.bytesToMiB 10 | import org.fcitx.fcitx5.android.updater.extractVersionName 11 | import org.fcitx.fcitx5.android.updater.parallelMap 12 | import org.fcitx.fcitx5.android.updater.parseVersionNumber 13 | import org.fcitx.fcitx5.android.updater.selectByABI 14 | 15 | class JenkinsVersionViewModel(private val jenkinsAndroidJob: JenkinsAndroidJob, initialBuildNumbers: List) : 16 | VersionViewModel(jenkinsAndroidJob.jobName, jenkinsAndroidJob.pkgName, jenkinsAndroidJob.url) { 17 | 18 | private var buildNumbers = initialBuildNumbers 19 | 20 | override fun refresh() { 21 | if (isRefreshing.value) 22 | return 23 | refreshInstalledVersion() 24 | allVersions.forEach { (k, v) -> 25 | val isInstalled = v.versionName == installedVersion.versionName 26 | allVersions[k] = when (v) { 27 | is VersionUi.Installed -> v 28 | is VersionUi.Local -> v.copy(isInstalled = isInstalled) 29 | is VersionUi.Remote -> v.copy(isInstalled = isInstalled) 30 | } 31 | } 32 | viewModelScope.launch { 33 | _isRefreshing.emit(true) 34 | remoteVersions.clear() 35 | if (hasRefreshed) { 36 | JenkinsApi.getJobBuilds(jenkinsAndroidJob).also { 37 | buildNumbers = it.map { b -> b.buildNumber } 38 | } 39 | } else { 40 | JenkinsApi.getJobBuildsByBuildNumbers(jenkinsAndroidJob, buildNumbers).also { 41 | hasRefreshed = true 42 | } 43 | }.mapNotNull { 44 | it.artifacts.selectByABI()?.let { artifact -> 45 | artifact.extractVersionName()?.let { versionName -> 46 | artifact to versionName 47 | } 48 | } 49 | }.parallelMap { (artifact, versionName) -> 50 | VersionUi.Remote( 51 | pkgName, 52 | -1, 53 | versionName, 54 | // Bytes to MiB 55 | CommonApi.getContentLength(artifact.url) 56 | .getOrNull() 57 | ?.let { bytesToMiB(it) } 58 | ?: .0, 59 | versionName == installedVersion.versionName, 60 | artifact.url 61 | ) 62 | }.also { 63 | allVersions.clear() 64 | refreshLocalVersions() 65 | }.forEach { 66 | remoteVersions[it.versionName] = it 67 | if (it.versionName !in localVersions) 68 | allVersions[it.versionName] = it 69 | } 70 | _isRefreshing.emit(false) 71 | } 72 | } 73 | 74 | override val sortedVersions: List 75 | get() = allVersions.values.sortedByDescending { 76 | parseVersionNumber(it.versionName).getOrNull()?.let { (a, b, _) -> 77 | SemVer.parseOrNull("$a-$b") 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/model/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.model 2 | 3 | import androidx.lifecycle.ViewModel 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | 6 | class MainViewModel : ViewModel() { 7 | 8 | val loaded = MutableStateFlow(false) 9 | 10 | val versions = MutableStateFlow(sortedMapOf()) 11 | 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/model/RemoteVersionUiState.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.model 2 | 3 | sealed interface RemoteVersionUiState { 4 | data class Downloading(val operable: Boolean, val progress: Float) : RemoteVersionUiState 5 | data class Pausing(val operable: Boolean, val progress: Float) : RemoteVersionUiState 6 | data object Downloaded : RemoteVersionUiState 7 | data object Pending : RemoteVersionUiState 8 | data class Idle(val operable: Boolean) : RemoteVersionUiState 9 | data object WaitingRetry : RemoteVersionUiState 10 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/model/VersionUi.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | import org.fcitx.fcitx5.android.updater.Const 6 | import java.io.File 7 | 8 | sealed interface VersionUi : Parcelable { 9 | 10 | val versionCode: Long 11 | 12 | val versionName: String 13 | 14 | val size: Double 15 | 16 | val isInstalled: Boolean 17 | 18 | val pkgName: String 19 | 20 | @Parcelize 21 | data class Installed( 22 | override val versionCode: Long, 23 | override val pkgName: String, 24 | override val versionName: String, 25 | override val size: Double, 26 | override val isInstalled: Boolean = true, 27 | ) : VersionUi 28 | 29 | @Parcelize 30 | data class Remote( 31 | override val pkgName: String, 32 | override val versionCode: Long, 33 | override val versionName: String, 34 | override val size: Double, 35 | override val isInstalled: Boolean, 36 | val downloadUrl: String, 37 | ) : VersionUi 38 | 39 | @Parcelize 40 | data class Local( 41 | override val versionCode: Long, 42 | override val pkgName: String, 43 | override val versionName: String, 44 | override val size: Double, 45 | override val isInstalled: Boolean, 46 | val archiveFile: File, 47 | ) : VersionUi 48 | 49 | val displayName: String 50 | get() = "$pkgName-$versionName-${Const.deviceABI}.apk" 51 | 52 | companion object { 53 | val NotInstalled = Installed(-1, "N/A", "N/A", .0, false) 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/model/VersionViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.model 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateMapOf 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.setValue 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.flow.MutableSharedFlow 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.asSharedFlow 15 | import kotlinx.coroutines.flow.asStateFlow 16 | import kotlinx.coroutines.flow.collect 17 | import kotlinx.coroutines.flow.onEach 18 | import kotlinx.coroutines.launch 19 | import org.fcitx.fcitx5.android.updater.PackageUtils 20 | import org.fcitx.fcitx5.android.updater.UpdaterApplication 21 | import org.fcitx.fcitx5.android.updater.bytesToMiB 22 | import org.fcitx.fcitx5.android.updater.externalDir 23 | import org.fcitx.fcitx5.android.updater.network.DownloadEvent 24 | import org.fcitx.fcitx5.android.updater.network.DownloadTask 25 | import java.io.File 26 | 27 | abstract class VersionViewModel( 28 | val name: String, 29 | val pkgName: String, 30 | val url: String 31 | ) : ViewModel() { 32 | 33 | var hasRefreshed = false 34 | protected set 35 | 36 | protected val _isRefreshing = MutableStateFlow(false) 37 | 38 | private val remoteVersionUiStates: MutableMap> = 39 | mutableMapOf() 40 | 41 | private val remoteDownloadTasks: MutableMap = 42 | mutableMapOf() 43 | 44 | val isRefreshing: StateFlow 45 | get() = _isRefreshing.asStateFlow() 46 | 47 | var installedVersion by mutableStateOf(VersionUi.NotInstalled) 48 | private set 49 | protected val remoteVersions = mutableMapOf() 50 | protected val localVersions = mutableMapOf() 51 | 52 | protected val allVersions = mutableStateMapOf() 53 | 54 | private val VersionUi.isNowInstalled 55 | get() = installedVersion.versionName == versionName 56 | 57 | private var lastVersionName = "" 58 | 59 | private val _toastMessage = MutableSharedFlow() 60 | val toastMessage = _toastMessage.asSharedFlow() 61 | 62 | private val _fileOperation = MutableSharedFlow() 63 | val fileOperation = _fileOperation.asSharedFlow() 64 | 65 | private val downloadDir = File(externalDir, name).apply { 66 | mkdirs() 67 | } 68 | 69 | fun getRemoteUiState(remote: VersionUi.Remote) = 70 | remoteVersionUiStates.getOrPut(remote) { 71 | MutableStateFlow(RemoteVersionUiState.Idle(true)) 72 | }.asStateFlow() 73 | 74 | fun uninstall() { 75 | viewModelScope.launch { 76 | _fileOperation.emit(FileOperation.Uninstall(pkgName)) 77 | } 78 | } 79 | 80 | fun download(remote: VersionUi.Remote) { 81 | val flow = remoteVersionUiStates.getValue(remote) 82 | val task = DownloadTask(remote.downloadUrl, File(downloadDir, remote.versionName + ".apk")) 83 | var progress = .0f 84 | task.eventFlow.onEach { event -> 85 | when (event) { 86 | DownloadEvent.StartCreating -> { 87 | flow.emit(RemoteVersionUiState.Idle(false)) 88 | } 89 | DownloadEvent.Created -> { 90 | flow.emit(RemoteVersionUiState.Pending) 91 | } 92 | DownloadEvent.StartPausing -> { 93 | flow.emit(RemoteVersionUiState.Downloading(false, progress)) 94 | } 95 | DownloadEvent.StartResuming -> { 96 | flow.emit(RemoteVersionUiState.Pausing(false, progress)) 97 | } 98 | DownloadEvent.Resumed -> { 99 | flow.emit(RemoteVersionUiState.Pending) 100 | } 101 | DownloadEvent.Downloaded -> { 102 | flow.emit(RemoteVersionUiState.Downloaded) 103 | val local = VersionUi.Local( 104 | remote.versionCode, 105 | pkgName, 106 | remote.versionName, 107 | remote.size, 108 | remote.isNowInstalled, 109 | task.file 110 | ) 111 | localVersions[remote.versionName] = local 112 | allVersions[remote.versionName] = local 113 | } 114 | is DownloadEvent.Failed -> { 115 | _toastMessage.emit(event.cause.message ?: event.cause.stackTraceToString()) 116 | } 117 | DownloadEvent.Paused -> { 118 | flow.emit(RemoteVersionUiState.Pausing(true, progress)) 119 | } 120 | DownloadEvent.Purged -> { 121 | flow.emit(RemoteVersionUiState.Idle(true)) 122 | } 123 | is DownloadEvent.Downloading -> { 124 | progress = event.progress.toFloat() 125 | flow.emit(RemoteVersionUiState.Downloading(true, progress)) 126 | } 127 | DownloadEvent.StartPurging -> { 128 | flow.emit(RemoteVersionUiState.Downloading(false, progress)) 129 | } 130 | DownloadEvent.StartWaitingRetry -> { 131 | flow.emit(RemoteVersionUiState.WaitingRetry) 132 | } 133 | } 134 | }.let { 135 | viewModelScope.launch(Dispatchers.Default) { 136 | it.collect() 137 | } 138 | } 139 | task.start() 140 | remoteDownloadTasks[remote] = task 141 | } 142 | 143 | fun pauseDownload(remote: VersionUi.Remote) { 144 | remoteDownloadTasks[remote]?.pause() 145 | } 146 | 147 | fun resumeDownload(remote: VersionUi.Remote) { 148 | remoteDownloadTasks[remote]?.resume() 149 | } 150 | 151 | fun cancelDownload(remote: VersionUi.Remote) { 152 | remoteDownloadTasks[remote]?.purge() 153 | } 154 | 155 | fun getRemoteUrl(local: VersionUi.Local): String? { 156 | return remoteVersions[local.versionName]?.downloadUrl 157 | } 158 | 159 | fun delete(local: VersionUi.Local) { 160 | local.archiveFile.delete() 161 | val version = local.versionName 162 | localVersions.remove(version) 163 | remoteVersions[version]?.let { 164 | allVersions[version] = it 165 | viewModelScope.launch { 166 | remoteVersionUiStates[it]?.emit(RemoteVersionUiState.Idle(true)) 167 | } 168 | } ?: run { 169 | allVersions.remove(version) 170 | } 171 | } 172 | 173 | fun install(local: VersionUi.Local) { 174 | viewModelScope.launch { 175 | _fileOperation.emit(FileOperation.Install(local.archiveFile)) 176 | } 177 | } 178 | 179 | fun share(local: VersionUi.Local) { 180 | viewModelScope.launch { 181 | _fileOperation.emit(FileOperation.Share(local.archiveFile, local.displayName)) 182 | } 183 | } 184 | 185 | fun export(local: VersionUi.Local) { 186 | viewModelScope.launch { 187 | _fileOperation.emit(FileOperation.Export(local.archiveFile, local.displayName)) 188 | } 189 | } 190 | 191 | fun exportInstalled() { 192 | val installedPath = 193 | PackageUtils.getInstalledPath(UpdaterApplication.context, pkgName) ?: return 194 | viewModelScope.launch { 195 | _fileOperation.emit( 196 | FileOperation.Export(File(installedPath), installedVersion.displayName) 197 | ) 198 | } 199 | } 200 | 201 | init { 202 | refreshInstalledVersion() 203 | refreshLocalVersions() 204 | } 205 | 206 | private fun getInstalled(context: Context) = 207 | PackageUtils.getInstalledVersionInfo(context, pkgName) 208 | ?.let { (versionName, versionCode) -> 209 | PackageUtils.getInstalledSize(context, pkgName) 210 | ?.let { size -> 211 | VersionUi.Installed(versionCode, pkgName, versionName, size) 212 | } 213 | } ?: VersionUi.NotInstalled 214 | 215 | fun refreshIfInstalledChanged() { 216 | if (getInstalled(UpdaterApplication.context).versionName != lastVersionName) 217 | refresh() 218 | } 219 | 220 | fun refreshInstalledVersion() { 221 | installedVersion = getInstalled(UpdaterApplication.context) 222 | lastVersionName = installedVersion.versionName 223 | } 224 | 225 | fun refreshLocalVersions() { 226 | localVersions.clear() 227 | downloadDir 228 | .listFiles { file: File -> file.extension == "apk" } 229 | ?.mapNotNull { 230 | PackageUtils.getVersionInfo(UpdaterApplication.context, it.absolutePath) 231 | ?.let { (versionName, versionCode) -> 232 | VersionUi.Local( 233 | versionCode, 234 | pkgName, 235 | versionName, 236 | // Bytes to MiB 237 | bytesToMiB(it.length()), 238 | installedVersion.versionName == versionName, 239 | it 240 | ) 241 | } 242 | } 243 | ?.forEach { 244 | localVersions[it.versionName] = it 245 | allVersions[it.versionName] = it 246 | } 247 | } 248 | 249 | abstract fun refresh() 250 | 251 | abstract val sortedVersions: List 252 | } 253 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/network/DownloadEvent.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.network 2 | 3 | sealed interface DownloadEvent { 4 | data class Downloading(val progress: Double) : DownloadEvent 5 | data object Purged : DownloadEvent 6 | data object Paused : DownloadEvent 7 | data object Resumed : DownloadEvent 8 | data object Created : DownloadEvent 9 | data object StartCreating : DownloadEvent 10 | data object StartResuming : DownloadEvent 11 | data object StartPurging : DownloadEvent 12 | data object StartPausing : DownloadEvent 13 | data object Downloaded : DownloadEvent 14 | data object StartWaitingRetry : DownloadEvent 15 | data class Failed(val cause: Throwable) : DownloadEvent 16 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/network/DownloadTask.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.network 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.cancelAndJoin 7 | import kotlinx.coroutines.channels.BufferOverflow 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.flow.MutableSharedFlow 10 | import kotlinx.coroutines.flow.asSharedFlow 11 | import kotlinx.coroutines.isActive 12 | import kotlinx.coroutines.launch 13 | import okhttp3.Request 14 | import org.fcitx.fcitx5.android.updater.Const 15 | import org.fcitx.fcitx5.android.updater.api.CommonApi 16 | import org.fcitx.fcitx5.android.updater.await 17 | import org.fcitx.fcitx5.android.updater.httpClient 18 | import java.io.File 19 | import java.io.RandomAccessFile 20 | import java.util.concurrent.atomic.AtomicBoolean 21 | 22 | // not thread-safe 23 | class DownloadTask( 24 | private val url: String, 25 | val file: File, 26 | ) : CoroutineScope by CoroutineScope(Dispatchers.IO) { 27 | 28 | private val cacheFile = File(file.parent!!, file.nameWithoutExtension + ".$TEMP_EXT") 29 | 30 | private val _eventFlow: MutableSharedFlow = MutableSharedFlow( 31 | replay = 0, 32 | extraBufferCapacity = 3, 33 | onBufferOverflow = BufferOverflow.DROP_OLDEST 34 | ) 35 | 36 | val eventFlow = _eventFlow.asSharedFlow() 37 | 38 | @Volatile 39 | var job: Job? = null 40 | 41 | @Volatile 42 | private var created = false 43 | 44 | @Volatile 45 | private var finished = false 46 | 47 | @Volatile 48 | private var contentLength = -1L 49 | 50 | private val stopRetry = AtomicBoolean(false) 51 | 52 | private fun createJob(startEvent: DownloadEvent, notify: suspend () -> Unit) { 53 | job = launch { 54 | _eventFlow.emit(startEvent) 55 | var start = 0L 56 | if (cacheFile.exists()) 57 | start = cacheFile.length() 58 | val request = Request.Builder() 59 | .addHeader("RANGE", "bytes=$start-") 60 | .url(url) 61 | .build() 62 | notify() 63 | runCatching { 64 | if (contentLength == -1L) 65 | contentLength = CommonApi.getContentLength(url).getOrThrow() 66 | if (start == contentLength) { 67 | finished = true 68 | return@runCatching 69 | } 70 | val response = httpClient.newCall(request).await() 71 | response.body.byteStream().use { 72 | var bytesWritten = 0L 73 | val buffer = ByteArray(DEFAULT_BUFFER_SIZE) 74 | var bytes = it.read(buffer) 75 | val f = RandomAccessFile(cacheFile, "rw") 76 | f.seek(start) 77 | while (isActive && bytes >= 0) { 78 | f.write(buffer, 0, bytes) 79 | bytesWritten += bytes 80 | _eventFlow.emit(DownloadEvent.Downloading(((bytesWritten + start) / contentLength.toDouble()))) 81 | bytes = it.read(buffer) 82 | } 83 | f.fd.sync() 84 | finished = f.length() == contentLength 85 | } 86 | } 87 | .onSuccess { 88 | job = null 89 | if (finished) { 90 | cacheFile.renameTo(file) 91 | _eventFlow.emit(DownloadEvent.Downloaded) 92 | } 93 | } 94 | .onFailure { 95 | job = null 96 | created = false 97 | _eventFlow.emit(DownloadEvent.Failed(it)) 98 | _eventFlow.emit(DownloadEvent.StartWaitingRetry) 99 | delay(Const.retryDuration) 100 | if (!stopRetry.get()) { 101 | start() 102 | } 103 | stopRetry.compareAndSet(true, false) 104 | } 105 | 106 | } 107 | } 108 | 109 | 110 | private fun assertNotFinished() { 111 | if (finished) 112 | error("Task is finished") 113 | } 114 | 115 | fun start() { 116 | assertNotFinished() 117 | if (created || job != null) 118 | error("Task is already created") 119 | createJob(DownloadEvent.StartCreating) { 120 | _eventFlow.emit(DownloadEvent.Created) 121 | created = true 122 | } 123 | } 124 | 125 | fun pause() { 126 | assertNotFinished() 127 | if (!created || job == null) 128 | error("Task is already paused") 129 | launch { 130 | _eventFlow.emit(DownloadEvent.StartPausing) 131 | job?.cancelAndJoin() 132 | job = null 133 | _eventFlow.emit(DownloadEvent.Paused) 134 | } 135 | } 136 | 137 | fun resume() { 138 | assertNotFinished() 139 | if (!created || job != null) 140 | error("Task is not paused") 141 | createJob(DownloadEvent.StartResuming) { 142 | _eventFlow.emit(DownloadEvent.Resumed) 143 | } 144 | } 145 | 146 | fun purge() { 147 | launch { 148 | stopRetry.set(true) 149 | job?.cancelAndJoin() 150 | job = null 151 | if (!finished) 152 | cacheFile.delete() 153 | else 154 | file.delete() 155 | finished = false 156 | created = false 157 | _eventFlow.emit(DownloadEvent.Purged) 158 | } 159 | } 160 | 161 | companion object { 162 | const val TEMP_EXT = "tmp" 163 | } 164 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/ui/components/VersionCard.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.ui.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.layout.size 8 | import androidx.compose.material.ContentAlpha 9 | import androidx.compose.material.Icon 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.Text 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.CheckCircle 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.unit.dp 17 | import androidx.constraintlayout.compose.ConstraintLayout 18 | import androidx.constraintlayout.compose.Dimension 19 | import org.fcitx.fcitx5.android.updater.model.VersionUi 20 | import java.util.Locale 21 | 22 | @Composable 23 | fun VersionCard(version: VersionUi) { 24 | Box(Modifier.clickable { }) { 25 | ConstraintLayout( 26 | Modifier 27 | .fillMaxWidth() 28 | .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp) 29 | ) { 30 | val (title, installed, size, menu, action) = createRefs() 31 | Text( 32 | text = version.versionName, 33 | style = MaterialTheme.typography.body1, 34 | modifier = Modifier.constrainAs(title) { 35 | top.linkTo(parent.top) 36 | start.linkTo(parent.start) 37 | } 38 | ) 39 | if (version !is VersionUi.Installed && version.isInstalled) { 40 | Icon( 41 | imageVector = Icons.Default.CheckCircle, 42 | tint = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), 43 | contentDescription = null, 44 | modifier = Modifier 45 | .size(16.dp) 46 | .constrainAs(installed) { 47 | top.linkTo(title.top) 48 | bottom.linkTo(title.bottom) 49 | start.linkTo(title.end, 4.dp) 50 | } 51 | ) 52 | } 53 | Text( 54 | text = String.format(Locale.ROOT, "%.2f MiB", version.size), 55 | color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), 56 | style = MaterialTheme.typography.body2, 57 | modifier = Modifier 58 | .padding(bottom = 8.dp) 59 | .constrainAs(size) { 60 | top.linkTo(title.bottom, 4.dp) 61 | start.linkTo(parent.start) 62 | } 63 | ) 64 | VersionCardMenu(version, modifier = Modifier.constrainAs(menu) { 65 | top.linkTo(parent.top) 66 | end.linkTo(parent.end) 67 | }) 68 | VersionCardAction(version, modifier = Modifier.constrainAs(action) { 69 | width = Dimension.fillToConstraints 70 | top.linkTo(menu.bottom) 71 | start.linkTo(parent.start) 72 | end.linkTo(parent.end) 73 | }) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/ui/components/VersionCardAction.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.ui.components 2 | 3 | import androidx.compose.foundation.layout.defaultMinSize 4 | import androidx.compose.material.ContentAlpha 5 | import androidx.compose.material.LinearProgressIndicator 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Text 8 | import androidx.compose.material.TextButton 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.text.style.TextAlign 15 | import androidx.compose.ui.unit.dp 16 | import androidx.constraintlayout.compose.ConstraintLayout 17 | import androidx.constraintlayout.compose.Dimension 18 | import org.fcitx.fcitx5.android.updater.R 19 | import org.fcitx.fcitx5.android.updater.model.RemoteVersionUiState 20 | import org.fcitx.fcitx5.android.updater.model.VersionUi 21 | import org.fcitx.fcitx5.android.updater.model.VersionViewModel 22 | import org.fcitx.fcitx5.android.updater.versionViewModel 23 | 24 | @Composable 25 | fun VersionCardAction( 26 | version: VersionUi, 27 | modifier: Modifier 28 | ) { 29 | when (version) { 30 | is VersionUi.Installed -> VersionCardActionInstalled(version, modifier) 31 | is VersionUi.Local -> VersionCardActionLocal(version, modifier) 32 | is VersionUi.Remote -> VersionCardActionRemote(version, modifier) 33 | } 34 | } 35 | 36 | @Composable 37 | fun VersionCardActionInstalled( 38 | version: VersionUi.Installed, 39 | modifier: Modifier 40 | ) { 41 | val viewModel: VersionViewModel = versionViewModel() 42 | if (version.isInstalled) { 43 | ConstraintLayout(modifier = modifier) { 44 | val (action) = createRefs() 45 | TextButton( 46 | onClick = { viewModel.uninstall() }, 47 | modifier = Modifier.constrainAs(action) { 48 | top.linkTo(parent.top) 49 | end.linkTo(parent.end) 50 | }, 51 | content = { Text(text = stringResource(R.string.uninstall)) } 52 | ) 53 | } 54 | } 55 | } 56 | 57 | @Composable 58 | fun VersionCardActionLocal( 59 | version: VersionUi.Local, 60 | modifier: Modifier 61 | ) { 62 | val viewModel: VersionViewModel = versionViewModel() 63 | ConstraintLayout(modifier = modifier) { 64 | val (action) = createRefs() 65 | TextButton( 66 | onClick = { viewModel.install(version) }, 67 | modifier = Modifier.constrainAs(action) { 68 | top.linkTo(parent.top) 69 | end.linkTo(parent.end) 70 | }, 71 | content = { Text(stringResource(R.string.install)) } 72 | ) 73 | } 74 | } 75 | 76 | @Composable 77 | fun VersionCardActionRemote( 78 | version: VersionUi.Remote, 79 | modifier: Modifier 80 | ) { 81 | val viewModel: VersionViewModel = versionViewModel() 82 | val state by viewModel.getRemoteUiState(version).collectAsState() 83 | ConstraintLayout(modifier = modifier) { 84 | val (button, progressText, progressBar) = createRefs() 85 | val buttonModifier = Modifier 86 | .defaultMinSize(minWidth = 72.dp) 87 | .constrainAs(button) { 88 | top.linkTo(parent.top) 89 | end.linkTo(parent.end) 90 | bottom.linkTo(parent.bottom) 91 | } 92 | val progressTextModifier = Modifier.constrainAs(progressText) { 93 | width = Dimension.value(36.dp) 94 | top.linkTo(parent.top) 95 | end.linkTo(button.start, 8.dp) 96 | bottom.linkTo(parent.bottom) 97 | } 98 | val progressBarModifier = Modifier.constrainAs(progressBar) { 99 | width = Dimension.fillToConstraints 100 | top.linkTo(parent.top) 101 | start.linkTo(parent.start) 102 | end.linkTo(progressText.start, 8.dp) 103 | bottom.linkTo(parent.bottom) 104 | } 105 | when (state) { 106 | RemoteVersionUiState.Downloaded -> { 107 | TextButton( 108 | onClick = { }, 109 | modifier = buttonModifier, 110 | enabled = false, 111 | content = { Text(text = stringResource(R.string.downloaded)) } 112 | ) 113 | } 114 | is RemoteVersionUiState.Downloading -> { 115 | val operable = (state as RemoteVersionUiState.Downloading).operable 116 | TextButton( 117 | onClick = { if (operable) viewModel.pauseDownload(version) }, 118 | modifier = buttonModifier, 119 | content = { Text(text = stringResource(R.string.pause)) } 120 | ) 121 | val progress = (state as RemoteVersionUiState.Downloading).progress 122 | Text( 123 | text = "${(progress * 100).toInt()}%", 124 | color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), 125 | modifier = progressTextModifier, 126 | textAlign = TextAlign.End, 127 | style = MaterialTheme.typography.body2 128 | ) 129 | LinearProgressIndicator(progress = progress, modifier = progressBarModifier) 130 | } 131 | is RemoteVersionUiState.Idle -> { 132 | val operable = (state as RemoteVersionUiState.Idle).operable 133 | TextButton( 134 | onClick = { if (operable) viewModel.download(version) }, 135 | modifier = buttonModifier, 136 | content = { Text(text = stringResource(R.string.download)) } 137 | ) 138 | } 139 | is RemoteVersionUiState.Pausing -> { 140 | val operable = (state as RemoteVersionUiState.Pausing).operable 141 | TextButton( 142 | onClick = { if (operable) viewModel.resumeDownload(version) }, 143 | modifier = buttonModifier, 144 | content = { Text(text = stringResource(R.string.resume)) } 145 | ) 146 | val progress = (state as RemoteVersionUiState.Pausing).progress 147 | Text( 148 | text = "${(progress * 100).toInt()}%", 149 | color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), 150 | modifier = progressTextModifier, 151 | textAlign = TextAlign.End, 152 | style = MaterialTheme.typography.body2 153 | ) 154 | LinearProgressIndicator(progress = progress, modifier = progressBarModifier) 155 | } 156 | RemoteVersionUiState.Pending -> { 157 | TextButton( 158 | onClick = { viewModel.cancelDownload(version) }, 159 | modifier = buttonModifier, 160 | content = { Text(text = stringResource(R.string.cancel)) } 161 | ) 162 | Text( 163 | text = "", 164 | modifier = progressTextModifier, 165 | style = MaterialTheme.typography.body2 166 | ) 167 | LinearProgressIndicator(modifier = progressBarModifier) 168 | } 169 | RemoteVersionUiState.WaitingRetry -> { 170 | TextButton( 171 | onClick = { viewModel.cancelDownload(version) }, 172 | modifier = buttonModifier, 173 | content = { Text(text = stringResource(R.string.cancel)) } 174 | ) 175 | Text( 176 | text = "", 177 | modifier = progressTextModifier, 178 | style = MaterialTheme.typography.body2 179 | ) 180 | LinearProgressIndicator(modifier = progressBarModifier) 181 | } 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/ui/components/VersionCardMenu.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.ui.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.ColumnScope 5 | import androidx.compose.foundation.layout.defaultMinSize 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material.ContentAlpha 8 | import androidx.compose.material.DropdownMenu 9 | import androidx.compose.material.DropdownMenuItem 10 | import androidx.compose.material.Icon 11 | import androidx.compose.material.IconButton 12 | import androidx.compose.material.MaterialTheme 13 | import androidx.compose.material.Text 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.MoreVert 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.collectAsState 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.runtime.mutableStateOf 20 | import androidx.compose.runtime.remember 21 | import androidx.compose.runtime.setValue 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.platform.LocalClipboardManager 24 | import androidx.compose.ui.res.stringResource 25 | import androidx.compose.ui.text.AnnotatedString 26 | import androidx.compose.ui.unit.dp 27 | import org.fcitx.fcitx5.android.updater.R 28 | import org.fcitx.fcitx5.android.updater.model.RemoteVersionUiState 29 | import org.fcitx.fcitx5.android.updater.model.VersionUi 30 | import org.fcitx.fcitx5.android.updater.model.VersionViewModel 31 | import org.fcitx.fcitx5.android.updater.versionViewModel 32 | 33 | @Composable 34 | fun VersionCardMenu(version: VersionUi, modifier: Modifier) { 35 | when (version) { 36 | is VersionUi.Installed -> { 37 | if (!version.isInstalled) return 38 | VersionCardMenuIcon(modifier = modifier) { dismissMenu -> 39 | VersionCardMenuInstalled(version, dismissMenu) 40 | } 41 | } 42 | is VersionUi.Local -> { 43 | VersionCardMenuIcon(modifier = modifier) { dismissMenu -> 44 | VersionCardMenuLocal(version, dismissMenu) 45 | } 46 | } 47 | is VersionUi.Remote -> { 48 | VersionCardMenuIcon(modifier = modifier) { dismissMenu -> 49 | VersionCardMenuRemote(version, dismissMenu) 50 | } 51 | } 52 | } 53 | } 54 | 55 | @Composable 56 | fun VersionCardMenuIcon( 57 | modifier: Modifier, 58 | content: @Composable ColumnScope.(dismissMenu: () -> Unit) -> Unit 59 | ) { 60 | Box(modifier = modifier) { 61 | var menuExpanded by remember { mutableStateOf(false) } 62 | val dismissMenu = { menuExpanded = false } 63 | IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(48.dp)) { 64 | Icon( 65 | imageVector = Icons.Filled.MoreVert, 66 | tint = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), 67 | contentDescription = null 68 | ) 69 | } 70 | DropdownMenu( 71 | expanded = menuExpanded, 72 | onDismissRequest = dismissMenu, 73 | modifier = Modifier.defaultMinSize(minWidth = 180.dp), 74 | content = { content(dismissMenu) } 75 | ) 76 | } 77 | } 78 | 79 | @Composable 80 | fun VersionCardMenuInstalled(version: VersionUi.Installed, dismissMenu: () -> Unit) { 81 | if (version.isInstalled) { 82 | val viewModel: VersionViewModel = versionViewModel() 83 | DropdownMenuItem( 84 | onClick = { 85 | dismissMenu() 86 | viewModel.exportInstalled() 87 | } 88 | ) { 89 | Text(stringResource(R.string.export)) 90 | } 91 | } 92 | } 93 | 94 | @Composable 95 | fun VersionCardMenuLocal(version: VersionUi.Local, dismissMenu: () -> Unit) { 96 | val viewModel: VersionViewModel = versionViewModel() 97 | DropdownMenuItem( 98 | onClick = { 99 | dismissMenu() 100 | viewModel.share(version) 101 | } 102 | ) { 103 | Text(stringResource(R.string.share)) 104 | } 105 | DropdownMenuItem( 106 | onClick = { 107 | dismissMenu() 108 | viewModel.export(version) 109 | } 110 | ) { 111 | Text(stringResource(R.string.export)) 112 | } 113 | val remoteUrl by remember { mutableStateOf(viewModel.getRemoteUrl(version)) } 114 | remoteUrl?.let { 115 | val clipboardManager = LocalClipboardManager.current 116 | DropdownMenuItem( 117 | onClick = { 118 | dismissMenu() 119 | clipboardManager.setText(AnnotatedString(it)) 120 | } 121 | ) { 122 | Text(stringResource(R.string.copy_url)) 123 | } 124 | } 125 | DropdownMenuItem( 126 | onClick = { 127 | dismissMenu() 128 | viewModel.delete(version) 129 | } 130 | ) { 131 | Text(stringResource(R.string.delete_apk)) 132 | } 133 | } 134 | 135 | @Composable 136 | fun VersionCardMenuRemote(version: VersionUi.Remote, dismissMenu: () -> Unit) { 137 | val viewModel: VersionViewModel = versionViewModel() 138 | val state by viewModel.getRemoteUiState(version).collectAsState() 139 | when (state) { 140 | RemoteVersionUiState.Downloaded -> { 141 | } 142 | is RemoteVersionUiState.Downloading -> { 143 | DropdownMenuItem( 144 | onClick = { 145 | dismissMenu() 146 | viewModel.cancelDownload(version) 147 | } 148 | ) { 149 | Text(stringResource(R.string.cancel)) 150 | } 151 | } 152 | is RemoteVersionUiState.Idle -> { 153 | } 154 | is RemoteVersionUiState.Pausing -> { 155 | DropdownMenuItem( 156 | onClick = { 157 | dismissMenu() 158 | viewModel.cancelDownload(version) 159 | } 160 | ) { 161 | Text(stringResource(R.string.cancel)) 162 | } 163 | } 164 | RemoteVersionUiState.Pending -> { 165 | } 166 | RemoteVersionUiState.WaitingRetry -> { 167 | } 168 | } 169 | val clipboardManager = LocalClipboardManager.current 170 | DropdownMenuItem( 171 | onClick = { 172 | dismissMenu() 173 | clipboardManager.setText(AnnotatedString(version.downloadUrl)) 174 | } 175 | ) { 176 | Text(stringResource(R.string.copy_url)) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/ui/components/Versions.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.ui.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Divider 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Surface 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | import org.fcitx.fcitx5.android.updater.model.VersionUi 13 | 14 | @Composable 15 | fun Versions(name: String, versions: List) { 16 | Column { 17 | Text( 18 | text = name, 19 | modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), 20 | style = MaterialTheme.typography.h6 21 | ) 22 | Surface(elevation = 2.dp) { 23 | Column { 24 | val last = versions.size - 1 25 | val dividerColor = MaterialTheme.colors.onSurface.copy(alpha = 0.06f) 26 | versions.forEachIndexed { index, version -> 27 | VersionCard(version) 28 | if (index != last) Divider(color = dividerColor) 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) -------------------------------------------------------------------------------- /app/src/main/java/org/fcitx/fcitx5/android/updater/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package org.fcitx.fcitx5.android.updater.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.shape.RoundedCornerShape 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.material.Shapes 7 | import androidx.compose.material.Typography 8 | import androidx.compose.material.darkColors 9 | import androidx.compose.material.lightColors 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.text.TextStyle 12 | import androidx.compose.ui.text.font.FontWeight 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | 16 | private val DarkColors = darkColors( 17 | primary = Purple200, 18 | primaryVariant = Purple700, 19 | secondary = Teal200 20 | ) 21 | 22 | private val LightColors = lightColors( 23 | primary = Purple500, 24 | primaryVariant = Purple700, 25 | secondary = Teal200 26 | ) 27 | 28 | val DenseTypography = Typography( 29 | body1 = TextStyle( 30 | fontSize = 16.sp, 31 | fontWeight = FontWeight.Normal 32 | ), 33 | body2 = TextStyle( 34 | fontSize = 14.sp, 35 | fontWeight = FontWeight.Normal 36 | ), 37 | button = TextStyle( 38 | fontSize = 14.sp, 39 | fontWeight = FontWeight.Medium, 40 | letterSpacing = 0.sp 41 | ) 42 | ) 43 | 44 | private val SharpShapes = Shapes( 45 | small = RoundedCornerShape(2.dp), 46 | medium = RoundedCornerShape(2.dp), 47 | large = RoundedCornerShape(0.dp), 48 | ) 49 | 50 | @Composable 51 | fun Fcitx5ForAndroidUpdaterTheme( 52 | darkTheme: Boolean = isSystemInDarkTheme(), 53 | content: @Composable () -> Unit 54 | ) { 55 | MaterialTheme( 56 | colors = if (darkTheme) DarkColors else LightColors, 57 | typography = DenseTypography, 58 | shapes = SharpShapes, 59 | content = content 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/github_mark.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/resources.properties: -------------------------------------------------------------------------------- 1 | #Enable automatic per-app language support 2 | # https://developer.android.com/guide/topics/resources/app-languages#auto-localeconfig 3 | unqualifiedResLocale=en-US 4 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values-ru/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Средство обновления Fcitx5 3 | Загрузить 4 | Загружено 5 | Удалить 6 | Установить 7 | Удалить APK 8 | Установлено 9 | Версии 10 | Отменить 11 | Возобновить 12 | Пауза 13 | Ожидание повторной попытки 14 | Поделиться 15 | Экспорт 16 | Копировать URL 17 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh-rCN/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 小企鹅输入法更新器 3 | 下载 4 | 已下载 5 | 卸载 6 | 安装 7 | 删除 APK 8 | 已安装 9 | 可用版本 10 | 取消 11 | 恢复 12 | 暂停 13 | 等待重试中 14 | 分享 15 | 导出 16 | 复制链接 17 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Fcitx5 Updater 3 | Download 4 | Downloaded 5 | Uninstall 6 | Install 7 | Delete APK 8 | Installed 9 | Versions 10 | Cancel 11 | Resume 12 | Pause 13 | Waiting for retry 14 | Share 15 | Export 16 | Copy URL 17 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/xml/provider_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") version "8.7.3" apply false 3 | kotlin("android") version "2.0.20" apply false 4 | kotlin("plugin.compose") version "2.0.20" apply false 5 | } 6 | 7 | tasks.register("clean", Delete::class) { 8 | delete(rootProject.layout.buildDirectory) 9 | } 10 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "fcitx5-android": { 4 | "inputs": { 5 | "flake-compat": "flake-compat", 6 | "flake-utils": "flake-utils", 7 | "nixpkgs": "nixpkgs" 8 | }, 9 | "locked": { 10 | "lastModified": 1728271164, 11 | "narHash": "sha256-Zl7VpQpJzapwC5PK6NSmZ2ISVT9v/WPJnbzwZ8OcpWQ=", 12 | "owner": "fcitx5-android", 13 | "repo": "fcitx5-android", 14 | "rev": "56638444321aa9d405b09bea038461803cb09c39", 15 | "type": "github" 16 | }, 17 | "original": { 18 | "owner": "fcitx5-android", 19 | "repo": "fcitx5-android", 20 | "type": "github" 21 | } 22 | }, 23 | "flake-compat": { 24 | "flake": false, 25 | "locked": { 26 | "lastModified": 1696426674, 27 | "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 28 | "owner": "edolstra", 29 | "repo": "flake-compat", 30 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "edolstra", 35 | "repo": "flake-compat", 36 | "type": "github" 37 | } 38 | }, 39 | "flake-compat_2": { 40 | "flake": false, 41 | "locked": { 42 | "lastModified": 1696426674, 43 | "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 44 | "owner": "edolstra", 45 | "repo": "flake-compat", 46 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "edolstra", 51 | "repo": "flake-compat", 52 | "type": "github" 53 | } 54 | }, 55 | "flake-utils": { 56 | "inputs": { 57 | "systems": "systems" 58 | }, 59 | "locked": { 60 | "lastModified": 1726560853, 61 | "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", 62 | "owner": "numtide", 63 | "repo": "flake-utils", 64 | "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", 65 | "type": "github" 66 | }, 67 | "original": { 68 | "owner": "numtide", 69 | "repo": "flake-utils", 70 | "type": "github" 71 | } 72 | }, 73 | "nixpkgs": { 74 | "locked": { 75 | "lastModified": 1728018373, 76 | "narHash": "sha256-NOiTvBbRLIOe5F6RbHaAh6++BNjsb149fGZd1T4+KBg=", 77 | "owner": "NixOS", 78 | "repo": "nixpkgs", 79 | "rev": "bc947f541ae55e999ffdb4013441347d83b00feb", 80 | "type": "github" 81 | }, 82 | "original": { 83 | "owner": "NixOS", 84 | "ref": "nixos-unstable", 85 | "repo": "nixpkgs", 86 | "type": "github" 87 | } 88 | }, 89 | "root": { 90 | "inputs": { 91 | "fcitx5-android": "fcitx5-android", 92 | "flake-compat": "flake-compat_2" 93 | } 94 | }, 95 | "systems": { 96 | "locked": { 97 | "lastModified": 1681028828, 98 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 99 | "owner": "nix-systems", 100 | "repo": "default", 101 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 102 | "type": "github" 103 | }, 104 | "original": { 105 | "owner": "nix-systems", 106 | "repo": "default", 107 | "type": "github" 108 | } 109 | } 110 | }, 111 | "root": "root", 112 | "version": 7 113 | } 114 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Dev shell flake for fcitx5-android-updater"; 3 | 4 | inputs.fcitx5-android.url = "github:fcitx5-android/fcitx5-android"; 5 | inputs.flake-compat = { 6 | url = "github:edolstra/flake-compat"; 7 | flake = false; 8 | }; 9 | 10 | outputs = { self, fcitx5-android, ... }: 11 | let 12 | nixpkgs = fcitx5-android.inputs.nixpkgs; 13 | pkgs = import nixpkgs { 14 | system = "x86_64-linux"; 15 | config.android_sdk.accept_license = true; 16 | config.allowUnfree = true; 17 | overlays = [ fcitx5-android.overlays.default ]; 18 | }; 19 | in with pkgs; 20 | let sdk = pkgs.fcitx5-android.sdk; 21 | in { devShells.x86_64-linux.default = sdk.shell; }; 22 | } 23 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcitx5-android/fcitx5-android-updater/e21e8ae3b3e005bfcc11fe75c0b5def9f40debba/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Dec 07 22:36:59 CST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 11 | repositories { 12 | google() 13 | mavenCentral() 14 | } 15 | } 16 | 17 | include (":app") 18 | rootProject.name = "fcitx5-android-updater" -------------------------------------------------------------------------------- /transifex.yml: -------------------------------------------------------------------------------- 1 | git: 2 | filters: 3 | - filter_type: file 4 | file_format: Android 5 | source_language: en 6 | source_file: app/src/main/res/values/strings.xml 7 | translation_files_expression: app/src/main/res/values-/strings.xml 8 | --------------------------------------------------------------------------------