Add update check for GitHub source

This commit is contained in:
Ash 2022-04-24 03:39:32 +08:00
parent 3c630e8a5b
commit 0cb60d15b2
15 changed files with 436 additions and 32 deletions

View File

@ -11,6 +11,7 @@ import kotlinx.coroutines.launch
import me.ash.reader.data.module.ApplicationScope
import me.ash.reader.data.module.DispatcherDefault
import me.ash.reader.data.repository.*
import me.ash.reader.data.source.AppNetworkDataSource
import me.ash.reader.data.source.OpmlLocalDataSource
import me.ash.reader.data.source.ReaderDatabase
import me.ash.reader.data.source.RssNetworkDataSource
@ -30,6 +31,9 @@ class App : Application(), Configuration.Provider {
@Inject
lateinit var workManager: WorkManager
@Inject
lateinit var appNetworkDataSource: AppNetworkDataSource
@Inject
lateinit var opmlLocalDataSource: OpmlLocalDataSource
@ -39,6 +43,9 @@ class App : Application(), Configuration.Provider {
@Inject
lateinit var rssHelper: RssHelper
@Inject
lateinit var appRepository: AppRepository
@Inject
lateinit var stringsRepository: StringsRepository
@ -72,6 +79,7 @@ class App : Application(), Configuration.Provider {
applicationScope.launch(dispatcherDefault) {
accountInit()
workerInit()
checkUpdate()
}
}
@ -90,6 +98,10 @@ class App : Application(), Configuration.Provider {
rssRepository.get().doSync()
}
private suspend fun checkUpdate() {
appRepository.checkUpdate()
}
override fun getWorkManagerConfiguration(): Configuration =
Configuration.Builder()
.setWorkerFactory(workerFactory)

View File

@ -1,6 +1,7 @@
package me.ash.reader
import android.content.Context
import android.os.Looper
import android.util.Log
import android.widget.Toast
import java.lang.Thread.UncaughtExceptionHandler
@ -12,7 +13,9 @@ class CrashHandler(private val context: Context) : UncaughtExceptionHandler {
}
override fun uncaughtException(p0: Thread, p1: Throwable) {
Looper.prepare()
Toast.makeText(context, p1.message, Toast.LENGTH_LONG).show()
Looper.loop()
p1.printStackTrace()
Log.e("RLog", "uncaughtException: ${p1.message}" )
android.os.Process.killProcess(android.os.Process.myPid());

View File

@ -0,0 +1,23 @@
package me.ash.reader.data.entity
data class GitHubRelease(
val html_url: String? = null,
val tag_name: String? = null,
val name: String? = null,
val draft: Boolean? = null,
val prerelease: Boolean? = null,
val created_at: String? = null,
val published_at: String? = null,
val assets: List<AssetsItem>? = null,
val body: String? = null,
)
data class AssetsItem(
val name: String? = null,
val content_type: String? = null,
val size: Int? = null,
val download_count: Int? = null,
val created_at: String? = null,
val updated_at: String? = null,
val browser_download_url: String? = null,
)

View File

@ -4,6 +4,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.ash.reader.data.source.AppNetworkDataSource
import me.ash.reader.data.source.FeverApiDataSource
import me.ash.reader.data.source.GoogleReaderApiDataSource
import me.ash.reader.data.source.RssNetworkDataSource
@ -13,6 +14,11 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object RetrofitModule {
@Provides
@Singleton
fun provideAppNetworkDataSource(): AppNetworkDataSource =
AppNetworkDataSource.getInstance()
@Provides
@Singleton
fun provideRssNetworkDataSource(): RssNetworkDataSource =

View File

@ -0,0 +1,78 @@
package me.ash.reader.data.repository
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import me.ash.reader.data.module.ApplicationScope
import me.ash.reader.data.module.DispatcherIO
import me.ash.reader.data.source.AppNetworkDataSource
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
import me.ash.reader.ui.ext.skipVersionNumber
import javax.inject.Inject
class AppRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val appNetworkDataSource: AppNetworkDataSource,
@ApplicationScope
private val applicationScope: CoroutineScope,
@DispatcherIO
private val dispatcherIO: CoroutineDispatcher,
) {
suspend fun checkUpdate() {
withContext(dispatcherIO) {
try {
val latest = appNetworkDataSource.getReleaseLatest()
val latestVersion = latest.tag_name?.formatVersion() ?: listOf()
val latestLog = latest.body ?: ""
val latestPublishDate = latest.published_at ?: ""
val latestSize = latest.assets
?.first()
?.size
?: 0
val latestDownloadUrl = latest.assets
?.first()
?.browser_download_url
?: ""
val currentVersion = context
.packageManager
.getPackageInfo(context.packageName, 0)
.versionName
.formatVersion()
Log.i("RLog", "current version ${currentVersion.joinToString(".")}")
if (latestVersion > context.skipVersionNumber.formatVersion() && latestVersion > currentVersion) {
Log.i("RLog", "new version ${latestVersion.joinToString(".")}")
context.dataStore.put(
DataStoreKeys.NewVersionNumber,
latestVersion.joinToString(".")
)
context.dataStore.put(DataStoreKeys.NewVersionLog, latestLog)
context.dataStore.put(DataStoreKeys.NewVersionPublishDate, latestPublishDate)
context.dataStore.put(DataStoreKeys.NewVersionSize, latestSize)
context.dataStore.put(DataStoreKeys.NewVersionDownloadUrl, latestDownloadUrl)
}
this
} catch (e: Exception) {
Log.e("RLog", "checkUpdate: ${e.message}")
}
}
}
}
fun String.formatVersion(): List<String> = this.split(".")
operator fun List<String>.compareTo(target: List<String>): Int {
for (i in 0 until minOf(size, target.size)) {
val a = this[i].toIntOrNull() ?: 0
val b = target[i].toIntOrNull() ?: 0
if (a < b) return -1
if (a > b) return 1
}
return if (size == target.size) 0 else if (size > target.size) 1 else -1
}

View File

@ -0,0 +1,26 @@
package me.ash.reader.data.source
import me.ash.reader.data.entity.GitHubRelease
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
interface AppNetworkDataSource {
@GET("https://api.github.com/repos/Ashinch/ReadYou/releases/latest")
suspend fun getReleaseLatest(): GitHubRelease
companion object {
private var instance: AppNetworkDataSource? = null
fun getInstance(): AppNetworkDataSource {
return instance ?: synchronized(this) {
instance ?: Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(GsonConverterFactory.create())
.build().create(AppNetworkDataSource::class.java).also {
instance = it
}
}
}
}
}

View File

@ -31,6 +31,7 @@ fun Banner(
modifier: Modifier = Modifier,
title: String,
desc: String? = null,
backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer,
icon: ImageVector? = null,
action: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {},
@ -46,7 +47,7 @@ fun Banner(
.fillMaxSize()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(32.dp))
.background(MaterialTheme.colorScheme.primaryContainer alwaysLight true)
.background(backgroundColor alwaysLight true)
.clickable { onClick() }
.padding(16.dp, 20.dp),
verticalAlignment = Alignment.CenterVertically

View File

@ -2,14 +2,16 @@ package me.ash.reader.ui.component
import android.view.HapticFeedbackConstants
import android.view.SoundEffectConstants
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
@Composable
fun FeedbackIconButton(
@ -17,6 +19,7 @@ fun FeedbackIconButton(
imageVector: ImageVector,
contentDescription: String?,
tint: Color = LocalContentColor.current,
showBadge: Boolean = false,
isHaptic: Boolean? = true,
isSound: Boolean? = true,
onClick: () -> Unit = {},
@ -30,11 +33,32 @@ fun FeedbackIconButton(
onClick()
},
) {
Icon(
modifier = modifier,
imageVector = imageVector,
contentDescription = contentDescription,
tint = tint,
)
if (showBadge) {
BadgedBox(
badge = {
Badge(
modifier = Modifier
.size(8.dp)
.clip(CircleShape),
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
)
}
) {
Icon(
modifier = modifier,
imageVector = imageVector,
contentDescription = contentDescription,
tint = tint,
)
}
} else {
Icon(
modifier = modifier,
imageVector = imageVector,
contentDescription = contentDescription,
tint = tint,
)
}
}
}

View File

@ -12,6 +12,19 @@ import kotlinx.coroutines.runBlocking
import java.io.IOException
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val Context.newVersionPublishDate: String
get() = this.dataStore.get(DataStoreKeys.NewVersionPublishDate) ?: ""
val Context.newVersionLog: String
get() = this.dataStore.get(DataStoreKeys.NewVersionLog) ?: ""
val Context.newVersionSize: Int
get() = this.dataStore.get(DataStoreKeys.NewVersionSize) ?: 0
val Context.newVersionDownloadUrl: String
get() = this.dataStore.get(DataStoreKeys.NewVersionDownloadUrl) ?: ""
val Context.newVersionNumber: String
get() = this.dataStore.get(DataStoreKeys.NewVersionNumber) ?: ""
val Context.skipVersionNumber: String
get() = this.dataStore.get(DataStoreKeys.SkipVersionNumber) ?: ""
val Context.isFirstLaunch: Boolean
get() = this.dataStore.get(DataStoreKeys.IsFirstLaunch) ?: true
val Context.currentAccountId: Int
@ -62,6 +75,36 @@ sealed class DataStoreKeys<T> {
get() = booleanPreferencesKey("isFirstLaunch")
}
object NewVersionPublishDate : DataStoreKeys<String>() {
override val key: Preferences.Key<String>
get() = stringPreferencesKey("newVersionPublishDate")
}
object NewVersionLog : DataStoreKeys<String>() {
override val key: Preferences.Key<String>
get() = stringPreferencesKey("newVersionLog")
}
object NewVersionSize : DataStoreKeys<Int>() {
override val key: Preferences.Key<Int>
get() = intPreferencesKey("newVersionSize")
}
object NewVersionDownloadUrl : DataStoreKeys<String>() {
override val key: Preferences.Key<String>
get() = stringPreferencesKey("newVersionDownloadUrl")
}
object NewVersionNumber : DataStoreKeys<String>() {
override val key: Preferences.Key<String>
get() = stringPreferencesKey("newVersionNumber")
}
object SkipVersionNumber : DataStoreKeys<String>() {
override val key: Preferences.Key<String>
get() = stringPreferencesKey("skipVersionNumber")
}
object CurrentAccountId : DataStoreKeys<Int>() {
override val key: Preferences.Key<Int>
get() = intPreferencesKey("currentAccountId")

View File

@ -1,5 +1,6 @@
package me.ash.reader.ui.page.home.feeds
import android.annotation.SuppressLint
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.*
@ -26,15 +27,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.LiveData
import androidx.navigation.NavHostController
import androidx.work.WorkInfo
import kotlinx.coroutines.flow.map
import me.ash.reader.R
import me.ash.reader.data.repository.SyncWorker.Companion.getIsSyncing
import me.ash.reader.data.repository.compareTo
import me.ash.reader.data.repository.formatVersion
import me.ash.reader.ui.component.Banner
import me.ash.reader.ui.component.DisplayText
import me.ash.reader.ui.component.FeedbackIconButton
import me.ash.reader.ui.component.Subtitle
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.getDesc
import me.ash.reader.ui.ext.getName
import me.ash.reader.ui.ext.*
import me.ash.reader.ui.page.common.RouteName
import me.ash.reader.ui.page.home.FilterBar
import me.ash.reader.ui.page.home.FilterState
@ -42,6 +44,7 @@ import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeDialog
import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeViewAction
import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeViewModel
@SuppressLint("FlowOperatorInvokedInComposition")
@OptIn(
ExperimentalMaterial3Api::class, com.google.accompanist.pager.ExperimentalPagerApi::class,
androidx.compose.foundation.ExperimentalFoundationApi::class
@ -59,6 +62,19 @@ fun FeedsPage(
onScrollToPage: (targetPage: Int) -> Unit = {},
) {
val context = LocalContext.current
val skipVersionNumber =
context.dataStore.data
.map { it[DataStoreKeys.SkipVersionNumber.key] ?: "" }
.collectAsState(initial = "")
.value
.formatVersion()
val newVersionNumber =
context.dataStore.data
.map { it[DataStoreKeys.NewVersionNumber.key] ?: "" }
.collectAsState(initial = "")
.value
.formatVersion()
val viewState = feedsViewModel.viewState.collectAsStateValue()
val owner = LocalLifecycleOwner.current
@ -116,6 +132,7 @@ fun FeedsPage(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.settings),
tint = MaterialTheme.colorScheme.onSurface,
showBadge = newVersionNumber > skipVersionNumber,
) {
navController.navigate(RouteName.SETTINGS)
}

View File

@ -8,6 +8,7 @@
package me.ash.reader.ui.page.settings
import android.view.SoundEffectConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@ -25,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -38,8 +40,15 @@ fun SelectableSettingGroupItem(
icon: ImageVector? = null,
onClick: () -> Unit,
) {
val view = LocalView.current
Surface(
modifier = modifier.clickable { onClick() }.alpha(if (enable) 1f else 0.5f),
modifier = modifier
.clickable {
view.playSoundEffect(SoundEffectConstants.CLICK)
onClick()
}
.alpha(if (enable) 1f else 0.5f),
color = Color.Unspecified,
) {
Row(

View File

@ -1,33 +1,54 @@
package me.ash.reader.ui.page.settings
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.map
import me.ash.reader.R
import me.ash.reader.data.repository.compareTo
import me.ash.reader.data.repository.formatVersion
import me.ash.reader.ui.component.Banner
import me.ash.reader.ui.component.DisplayText
import me.ash.reader.ui.component.FeedbackIconButton
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.page.common.RouteName
import me.ash.reader.ui.theme.palette.onLight
@SuppressLint("FlowOperatorInvokedInComposition")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsPage(
navController: NavHostController,
) {
val context = LocalContext.current
var updateDialogVisible by remember { mutableStateOf(false) }
val skipVersionNumber =
context.dataStore.data
.map { it[DataStoreKeys.SkipVersionNumber.key] ?: "" }
.collectAsState(initial = "")
.value
.formatVersion()
val newVersionNumber =
context.dataStore.data
.map { it[DataStoreKeys.NewVersionNumber.key] ?: "" }
.collectAsState(initial = "")
.value
.formatVersion()
Scaffold(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface)
@ -56,17 +77,30 @@ fun SettingsPage(
DisplayText(text = stringResource(R.string.settings), desc = "")
}
item {
Banner(
title = stringResource(R.string.in_coding),
desc = stringResource(R.string.coming_soon),
icon = Icons.Outlined.Lightbulb,
action = {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(R.string.close),
)
},
)
Box {
if (newVersionNumber > skipVersionNumber) {
Banner(
modifier = Modifier.zIndex(1f),
title = stringResource(R.string.get_new_updates),
desc = stringResource(
R.string.get_new_updates_desc,
newVersionNumber.joinToString(".")
),
icon = Icons.Outlined.Lightbulb,
action = {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(R.string.close),
)
},
) { updateDialogVisible = true }
}
Banner(
title = stringResource(R.string.in_coding),
desc = stringResource(R.string.coming_soon),
icon = Icons.Outlined.Lightbulb,
)
}
Spacer(modifier = Modifier.height(16.dp))
}
item {
@ -114,4 +148,9 @@ fun SettingsPage(
}
}
)
UpdateDialog(
visible = updateDialogVisible,
onDismissRequest = { updateDialogVisible = false },
)
}

View File

@ -0,0 +1,115 @@
package me.ash.reader.ui.page.settings
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Update
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.pager.ExperimentalPagerApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import me.ash.reader.R
import me.ash.reader.ui.component.Dialog
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
@SuppressLint("FlowOperatorInvokedInComposition")
@OptIn(ExperimentalPagerApi::class)
@Composable
fun UpdateDialog(
modifier: Modifier = Modifier,
visible: Boolean = false,
onDismissRequest: () -> Unit = {},
onConfirm: (String) -> Unit = {},
) {
val context = LocalContext.current
val scope = rememberCoroutineScope { Dispatchers.IO }
val newVersionNumber = context.dataStore.data
.map { it[DataStoreKeys.NewVersionNumber.key] ?: "" }
.collectAsState(initial = "")
.value
val newVersionPublishDate = context.dataStore.data
.map { it[DataStoreKeys.NewVersionPublishDate.key] ?: "" }
.collectAsState(initial = "")
.value
val newVersionLog = context.dataStore.data
.map { it[DataStoreKeys.NewVersionLog.key] ?: "" }
.collectAsState(initial = "")
.value
val newVersionSize = context.dataStore.data
.map { it[DataStoreKeys.NewVersionSize.key] ?: 0 }
.map { it / 1024f / 1024f }
.collectAsState(initial = 0)
.value
Dialog(
modifier = modifier.heightIn(max = 400.dp),
visible = visible,
onDismissRequest = onDismissRequest,
icon = {
Icon(
imageVector = Icons.Rounded.Update,
contentDescription = stringResource(R.string.change_log),
)
},
title = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = stringResource(R.string.change_log))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = newVersionPublishDate,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
}
},
text = {
SelectionContainer {
Text(
modifier = Modifier.verticalScroll(rememberScrollState()),
text = newVersionLog,
)
}
},
confirmButton = {
TextButton(
onClick = {
}
) {
Text(text = stringResource(R.string.update, String.format("%.2f", newVersionSize)))
}
},
dismissButton = {
TextButton(onClick = {
scope.launch {
context.dataStore.put(DataStoreKeys.SkipVersionNumber, newVersionNumber)
onDismissRequest()
}
}) {
Text(text = stringResource(R.string.skip_this_version))
}
},
)
}

View File

@ -73,7 +73,7 @@
<string name="seven_days">7天</string>
<string name="close">关闭</string>
<string name="get_new_updates">获取新的更新</string>
<string name="get_new_updates_desc">版本 0.6.1 现已发布</string>
<string name="get_new_updates_desc">版本 %1$s 现已发布</string>
<string name="in_coding">施工中</string>
<string name="coming_soon">正在路上</string>
<string name="accounts">账户</string>
@ -111,4 +111,8 @@
<string name="open_source_licenses">开放源代码许可</string>
<string name="github_link">https://github.com/Ashinch/ReadYou</string>
<string name="telegram_link">https://t.me/ReadYouApp</string>
<string name="update_link">https://api.github.com/repos/Ashinch/ReadYou/releases/latest</string>
<string name="change_log">更新日志</string>
<string name="update">更新 %1$s MB</string>
<string name="skip_this_version">跳过这个版本</string>
</resources>

View File

@ -73,7 +73,7 @@
<string name="seven_days">7d</string>
<string name="close">Close</string>
<string name="get_new_updates">Get new updates</string>
<string name="get_new_updates_desc">Version 0.6.1 has been released</string>
<string name="get_new_updates_desc">Version %1$s has been released</string>
<string name="in_coding">In coding</string>
<string name="coming_soon">Coming soon</string>
<string name="accounts">Accounts</string>
@ -111,4 +111,8 @@
<string name="open_source_licenses">Open Source Licenses</string>
<string name="github_link">https://github.com/Ashinch/ReadYou</string>
<string name="telegram_link">https://t.me/ReadYouApp</string>
<string name="update_link">https://api.github.com/repos/Ashinch/ReadYou/releases/latest</string>
<string name="change_log">Change Log</string>
<string name="update">Update %1$s MB</string>
<string name="skip_this_version">Skip This Version</string>
</resources>