Skip to content

Commit c43cdd0

Browse files
committed
Add update check for FOSS builds
1 parent f02dee3 commit c43cdd0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1216
-20
lines changed

app-common/src/main/java/eu/darken/octi/common/serialization/SerializationModule.kt

+14-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import dagger.Module
55
import dagger.Provides
66
import dagger.hilt.InstallIn
77
import dagger.hilt.components.SingletonComponent
8+
import eu.darken.octi.common.serialization.adapter.ByteStringAdapter
9+
import eu.darken.octi.common.serialization.adapter.DurationAdapter
10+
import eu.darken.octi.common.serialization.adapter.InstantAdapter
11+
import eu.darken.octi.common.serialization.adapter.LocaleAdapter
12+
import eu.darken.octi.common.serialization.adapter.OffsetDateTimeAdapter
13+
import eu.darken.octi.common.serialization.adapter.RegexAdapter
14+
import eu.darken.octi.common.serialization.adapter.UUIDAdapter
15+
import eu.darken.octi.common.serialization.adapter.UriAdapter
816
import javax.inject.Singleton
917

1018
@InstallIn(SingletonComponent::class)
@@ -14,8 +22,13 @@ class SerializationModule {
1422
@Provides
1523
@Singleton
1624
fun moshi(): Moshi = Moshi.Builder().apply {
25+
add(ByteStringAdapter())
26+
add(DurationAdapter())
1727
add(InstantAdapter())
28+
add(LocaleAdapter())
29+
add(OffsetDateTimeAdapter())
30+
add(RegexAdapter())
31+
add(UriAdapter())
1832
add(UUIDAdapter())
19-
add(ByteStringAdapter())
2033
}.build()
2134
}

app-common/src/main/java/eu/darken/octi/common/serialization/ByteStringAdapter.kt app-common/src/main/java/eu/darken/octi/common/serialization/adapter/ByteStringAdapter.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package eu.darken.octi.common.serialization
1+
package eu.darken.octi.common.serialization.adapter
22

33
import com.squareup.moshi.FromJson
44
import com.squareup.moshi.ToJson
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package eu.darken.octi.common.serialization.adapter
2+
3+
import com.squareup.moshi.FromJson
4+
import com.squareup.moshi.ToJson
5+
import java.time.Duration
6+
7+
class DurationAdapter {
8+
@ToJson
9+
fun toJson(value: Duration): String = value.toString()
10+
11+
@FromJson
12+
fun fromJson(raw: String): Duration = Duration.parse(raw)
13+
}

app-common/src/main/java/eu/darken/octi/common/serialization/InstantAdapter.kt app-common/src/main/java/eu/darken/octi/common/serialization/adapter/InstantAdapter.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package eu.darken.octi.common.serialization
1+
package eu.darken.octi.common.serialization.adapter
22

33
import com.squareup.moshi.FromJson
44
import com.squareup.moshi.ToJson
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package eu.darken.octi.common.serialization.adapter
2+
3+
import com.squareup.moshi.FromJson
4+
import com.squareup.moshi.ToJson
5+
import java.util.Locale
6+
7+
class LocaleAdapter {
8+
@ToJson
9+
fun toJson(value: Locale): String = value.toLanguageTag()
10+
11+
@FromJson
12+
fun fromJson(raw: String) = Locale.forLanguageTag(raw)
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package eu.darken.octi.common.serialization.adapter
2+
3+
import com.squareup.moshi.FromJson
4+
import com.squareup.moshi.ToJson
5+
import java.time.OffsetDateTime
6+
import java.time.format.DateTimeFormatter
7+
8+
class OffsetDateTimeAdapter {
9+
@ToJson
10+
fun toJson(value: OffsetDateTime): String = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value)
11+
12+
@FromJson
13+
fun fromJson(value: String): OffsetDateTime = OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME)
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package eu.darken.octi.common.serialization.adapter
2+
3+
import com.squareup.moshi.FromJson
4+
import com.squareup.moshi.Json
5+
import com.squareup.moshi.JsonClass
6+
import com.squareup.moshi.ToJson
7+
8+
class RegexAdapter {
9+
10+
@ToJson
11+
fun toJson(value: Regex): Wrapper = Wrapper(
12+
pattern = value.pattern,
13+
options = value.options.map { it.toWrapperOption() }.toSet(),
14+
)
15+
16+
@FromJson
17+
fun fromJson(raw: Wrapper): Regex = Regex(
18+
pattern = raw.pattern,
19+
options = raw.options.map { it.toRegexOption() }.toSet()
20+
)
21+
22+
private fun Wrapper.Option.toRegexOption() = when (this) {
23+
Wrapper.Option.IGNORE_CASE -> RegexOption.IGNORE_CASE
24+
Wrapper.Option.MULTILINE -> RegexOption.MULTILINE
25+
Wrapper.Option.LITERAL -> RegexOption.LITERAL
26+
Wrapper.Option.UNIX_LINES -> RegexOption.UNIX_LINES
27+
Wrapper.Option.COMMENTS -> RegexOption.COMMENTS
28+
Wrapper.Option.DOT_MATCHES_ALL -> RegexOption.DOT_MATCHES_ALL
29+
Wrapper.Option.CANON_EQ -> RegexOption.CANON_EQ
30+
}
31+
32+
private fun RegexOption.toWrapperOption() = when (this) {
33+
RegexOption.IGNORE_CASE -> Wrapper.Option.IGNORE_CASE
34+
RegexOption.MULTILINE -> Wrapper.Option.MULTILINE
35+
RegexOption.LITERAL -> Wrapper.Option.LITERAL
36+
RegexOption.UNIX_LINES -> Wrapper.Option.UNIX_LINES
37+
RegexOption.COMMENTS -> Wrapper.Option.COMMENTS
38+
RegexOption.DOT_MATCHES_ALL -> Wrapper.Option.DOT_MATCHES_ALL
39+
RegexOption.CANON_EQ -> Wrapper.Option.CANON_EQ
40+
}
41+
42+
@JsonClass(generateAdapter = true)
43+
data class Wrapper(
44+
@Json(name = "pattern") val pattern: String,
45+
@Json(name = "options") val options: Set<Option>
46+
) {
47+
@JsonClass(generateAdapter = false)
48+
enum class Option {
49+
@Json(name = "IGNORE_CASE") IGNORE_CASE,
50+
@Json(name = "MULTILINE") MULTILINE,
51+
@Json(name = "LITERAL") LITERAL,
52+
@Json(name = "UNIX_LINES") UNIX_LINES,
53+
@Json(name = "COMMENTS") COMMENTS,
54+
@Json(name = "DOT_MATCHES_ALL") DOT_MATCHES_ALL,
55+
@Json(name = "CANON_EQ") CANON_EQ,
56+
;
57+
}
58+
}
59+
}

app-common/src/main/java/eu/darken/octi/common/serialization/UUIDAdapter.kt app-common/src/main/java/eu/darken/octi/common/serialization/adapter/UUIDAdapter.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
package eu.darken.octi.common.serialization
1+
package eu.darken.octi.common.serialization.adapter
22

33
import com.squareup.moshi.FromJson
44
import com.squareup.moshi.ToJson
5-
import java.util.*
5+
import java.util.UUID
66

77
class UUIDAdapter {
88
@ToJson
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package eu.darken.octi.common.serialization.adapter
2+
3+
import android.net.Uri
4+
import com.squareup.moshi.FromJson
5+
import com.squareup.moshi.ToJson
6+
7+
class UriAdapter {
8+
@ToJson
9+
fun toJson(uri: Uri): String = uri.toString()
10+
11+
@FromJson
12+
fun fromJson(uriString: String): Uri = Uri.parse(uriString)
13+
}

app-common/src/main/res/values/strings.xml

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<string name="general_manage_action">Manage</string>
1717
<string name="general_quota_label">Quota</string>
1818
<string name="general_upgrade_action">Upgrade</string>
19+
<string name="general_update_action">Update</string>
1920
<string name="general_donate_action">Donate</string>
2021
<string name="general_check_action">Check</string>
2122
<string name="general_internal_not_available_msg">Internet connection unavailable</string>

app/build.gradle.kts

+4-2
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,10 @@ android {
9292
val variantOutputImpl = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
9393
val variantName: String = variantOutputImpl.name
9494

95-
if (listOf("release", "beta").any { variantName.toLowerCase().contains(it) }) {
95+
if (listOf("release", "beta").any { variantName.lowercase().contains(it) }) {
9696
val outputFileName = packageName +
9797
"-v${defaultConfig.versionName}-${defaultConfig.versionCode}" +
98-
"-${variantName.toUpperCase()}.apk"
98+
"-${variantName.uppercase()}.apk"
9999

100100
variantOutputImpl.outputFileName = outputFileName
101101
}
@@ -172,4 +172,6 @@ dependencies {
172172
"gplayImplementation"("com.android.billingclient:billing-ktx:7.0.0")
173173

174174
implementation("io.coil-kt:coil:2.0.0-rc02")
175+
176+
implementation("io.github.z4kn4fein:semver:1.4.2")
175177
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package eu.darken.octi.main.core
2+
3+
import dagger.Reusable
4+
import eu.darken.octi.common.WebpageTool
5+
import eu.darken.octi.common.datastore.value
6+
import eu.darken.octi.common.debug.logging.Logging.Priority.ERROR
7+
import eu.darken.octi.common.debug.logging.Logging.Priority.INFO
8+
import eu.darken.octi.common.debug.logging.Logging.Priority.WARN
9+
import eu.darken.octi.common.debug.logging.asLog
10+
import eu.darken.octi.common.debug.logging.log
11+
import eu.darken.octi.common.debug.logging.logTag
12+
import eu.darken.octi.main.core.updater.UpdateChecker
13+
import java.time.Duration
14+
import java.time.Instant
15+
import javax.inject.Inject
16+
17+
@Reusable
18+
class FossUpdateChecker @Inject constructor(
19+
private val checker: GithubReleaseCheck,
20+
private val webpageTool: WebpageTool,
21+
private val settings: FossUpdateSettings,
22+
) : UpdateChecker {
23+
24+
override suspend fun getLatest(channel: UpdateChecker.Channel): UpdateChecker.Update? {
25+
log(TAG) { "getLatest($channel) checking..." }
26+
27+
val release: GithubApi.ReleaseInfo? = try {
28+
if (Duration.between(settings.lastReleaseCheck.value(), Instant.now()) < UPDATE_CHECK_INTERVAL) {
29+
log(TAG) { "Using cached release data" }
30+
when (channel) {
31+
UpdateChecker.Channel.BETA -> settings.lastReleaseBeta.value()
32+
UpdateChecker.Channel.PROD -> settings.lastReleaseProd.value()
33+
}
34+
} else {
35+
log(TAG) { "Fetching new release data" }
36+
when (channel) {
37+
UpdateChecker.Channel.BETA -> checker.allReleases(OWNER, REPO).first()
38+
UpdateChecker.Channel.PROD -> checker.latestRelease(OWNER, REPO)
39+
}.also {
40+
log(TAG, INFO) { "getLatest($channel) new data is $it" }
41+
settings.lastReleaseCheck.value(Instant.now())
42+
when (channel) {
43+
UpdateChecker.Channel.BETA -> settings.lastReleaseBeta.value(it)
44+
UpdateChecker.Channel.PROD -> settings.lastReleaseProd.value(it)
45+
}
46+
}
47+
}
48+
} catch (e: Exception) {
49+
log(TAG, ERROR) { "getLatest($channel) failed: ${e.asLog()}" }
50+
null
51+
}
52+
53+
log(TAG, INFO) { "getLatest($channel) is ${release?.tagName}" }
54+
55+
val update = release?.let { rel ->
56+
Update(
57+
channel = channel,
58+
versionName = rel.tagName,
59+
changelogLink = rel.htmlUrl,
60+
downloadLink = rel.assets.singleOrNull { it.name.endsWith(".apk") }?.downloadUrl,
61+
)
62+
}
63+
64+
return update
65+
}
66+
67+
override suspend fun startUpdate(update: UpdateChecker.Update) {
68+
log(TAG, INFO) { "startUpdate($update)" }
69+
update as Update
70+
if (update.downloadLink != null) {
71+
webpageTool.open(update.downloadLink)
72+
} else {
73+
log(TAG, WARN) { "No download link available for $update" }
74+
}
75+
}
76+
77+
override suspend fun viewUpdate(update: UpdateChecker.Update) {
78+
log(TAG, INFO) { "viewUpdate($update)" }
79+
update as Update
80+
webpageTool.open(update.changelogLink)
81+
}
82+
83+
override suspend fun dismissUpdate(update: UpdateChecker.Update) {
84+
log(TAG, INFO) { "dismissUpdate($update)" }
85+
update as Update
86+
settings.dismiss(update)
87+
}
88+
89+
override suspend fun isDismissed(update: UpdateChecker.Update): Boolean {
90+
update as Update
91+
return settings.isDismissed(update)
92+
}
93+
94+
override fun isEnabledByDefault(): Boolean {
95+
val isEnabled = false
96+
log(TAG, INFO) { "Update check default isEnabled=$isEnabled" }
97+
return isEnabled
98+
}
99+
100+
override suspend fun isCheckSupported(): Boolean {
101+
return true
102+
}
103+
104+
data class Update(
105+
override val channel: UpdateChecker.Channel,
106+
override val versionName: String,
107+
val changelogLink: String,
108+
val downloadLink: String?,
109+
) : UpdateChecker.Update
110+
111+
companion object {
112+
private val UPDATE_CHECK_INTERVAL = Duration.ofHours(6)
113+
private const val OWNER = "d4rken-org"
114+
private const val REPO = "octi"
115+
private val TAG = logTag("Updater", "Checker", "FOSS")
116+
}
117+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package eu.darken.octi.main.core
2+
3+
import android.content.Context
4+
import androidx.datastore.core.DataStore
5+
import androidx.datastore.preferences.core.Preferences
6+
import androidx.datastore.preferences.preferencesDataStore
7+
import com.squareup.moshi.Moshi
8+
import dagger.hilt.android.qualifiers.ApplicationContext
9+
import eu.darken.octi.common.datastore.DataStoreValue
10+
import eu.darken.octi.common.datastore.createValue
11+
import eu.darken.octi.common.datastore.value
12+
import eu.darken.octi.common.debug.logging.logTag
13+
import java.time.Instant
14+
import javax.inject.Inject
15+
import javax.inject.Singleton
16+
17+
@Singleton
18+
class FossUpdateSettings @Inject constructor(
19+
@ApplicationContext private val context: Context,
20+
moshi: Moshi,
21+
) {
22+
23+
private val Context.dataStore by preferencesDataStore(name = "settings_updater_foss")
24+
25+
private val dataStore: DataStore<Preferences>
26+
get() = context.dataStore
27+
28+
private fun FossUpdateChecker.Update.getSetting(): DataStoreValue<Boolean> {
29+
return dataStore.createValue("update.${this.versionName}.dismissed", false)
30+
}
31+
32+
suspend fun dismiss(update: FossUpdateChecker.Update) {
33+
update.getSetting().value(true)
34+
}
35+
36+
suspend fun isDismissed(update: FossUpdateChecker.Update): Boolean {
37+
return update.getSetting().value()
38+
}
39+
40+
val lastReleaseCheck = dataStore.createValue("check.last", Instant.EPOCH, moshi)
41+
val lastReleaseProd = dataStore.createValue<GithubApi.ReleaseInfo?>("check.last.prod", null, moshi)
42+
val lastReleaseBeta = dataStore.createValue<GithubApi.ReleaseInfo?>("check.last.beta", null, moshi)
43+
44+
companion object {
45+
private val TAG = logTag("Updater", "Checker", "FOSS", "Settings")
46+
}
47+
}

0 commit comments

Comments
 (0)