├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | xmlns:android
22 |
23 | ^$
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | xmlns:.*
33 |
34 | ^$
35 |
36 |
37 | BY_NAME
38 |
39 |
40 |
41 |
42 |
43 |
44 | .*:id
45 |
46 | http://schemas.android.com/apk/res/android
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | .*:name
56 |
57 | http://schemas.android.com/apk/res/android
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | name
67 |
68 | ^$
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | style
78 |
79 | ^$
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | .*
89 |
90 | ^$
91 |
92 |
93 | BY_NAME
94 |
95 |
96 |
97 |
98 |
99 |
100 | .*
101 |
102 | http://schemas.android.com/apk/res/android
103 |
104 |
105 | ANDROID_ATTRIBUTE_ORDER
106 |
107 |
108 |
109 |
110 |
111 |
112 | .*
113 |
114 | .*
115 |
116 |
117 | BY_NAME
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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: [](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 | |||
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 |
--------------------------------------------------------------------------------