Sync on demand

This commit is contained in:
Koitharu
2022-05-14 18:29:38 +03:00
parent 1be8760c00
commit d31d302896
18 changed files with 178 additions and 55 deletions

View File

@@ -13,6 +13,9 @@
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<application
android:name="org.koitharu.kotatsu.KotatsuApp"

View File

@@ -30,7 +30,6 @@ import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule
import org.koitharu.kotatsu.sync.syncModule
import org.koitharu.kotatsu.tracker.trackerModule
import org.koitharu.kotatsu.widget.WidgetUpdater
import org.koitharu.kotatsu.widget.appWidgetModule
class KotatsuApp : Application() {
@@ -44,9 +43,6 @@ class KotatsuApp : Application() {
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
registerActivityLifecycleCallbacks(get<AppProtectHelper>())
val widgetUpdater = WidgetUpdater(applicationContext)
widgetUpdater.subscribeToFavourites(get())
widgetUpdater.subscribeToHistory(get())
}
private fun initKoin() {

View File

@@ -1,9 +1,16 @@
package org.koitharu.kotatsu.core.db
import androidx.room.InvalidationTracker
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val databaseModule
get() = module {
single { MangaDatabase(androidContext()) }
single {
MangaDatabase(androidContext()).also { db ->
getAll<InvalidationTracker.Observer>().forEach {
db.invalidationTracker.addObserver(it)
}
}
}
}

View File

@@ -27,6 +27,7 @@ import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
@@ -56,6 +57,7 @@ import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract
@@ -440,6 +442,8 @@ class MainActivity :
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
}
yield()
get<SyncController>().requestFullSync()
}
}

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.settings
import android.accounts.AccountManager
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
@@ -7,7 +9,10 @@ import android.provider.Settings
import android.view.View
import androidx.preference.ListPreference
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
@@ -17,6 +22,8 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.sync.domain.AUTHORITY_FAVOURITES
import org.koitharu.kotatsu.sync.domain.AUTHORITY_HISTORY
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -60,6 +67,11 @@ class ContentSettingsFragment :
settings.subscribe(this)
}
override fun onResume() {
super.onResume()
bindSyncSummary()
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
@@ -78,10 +90,6 @@ class ContentSettingsFragment :
AppSettings.KEY_SOURCES_HIDDEN -> {
bindRemoteSourcesSummary()
}
AppSettings.KEY_SYNC -> {
val intent = Intent(Settings.ACTION_SYNC_SETTINGS)
startActivity(intent)
}
}
}
@@ -96,6 +104,23 @@ class ContentSettingsFragment :
.show()
true
}
AppSettings.KEY_SYNC -> {
val am = AccountManager.get(requireContext())
val accountType = getString(R.string.account_type_sync)
if (am.getAccountsByType(accountType).firstOrNull() == null) {
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
} else {
val intent = Intent(Settings.ACTION_SYNC_SETTINGS)
intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf(accountType))
intent.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(AUTHORITY_HISTORY, AUTHORITY_FAVOURITES))
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
@@ -119,4 +144,16 @@ class ContentSettingsFragment :
)
}
}
private fun bindSyncSummary() {
viewLifecycleScope.launch {
val account = withContext(Dispatchers.Default) {
val type = getString(R.string.account_type_sync)
AccountManager.get(requireContext()).getAccountsByType(type).firstOrNull()
}
findPreference<Preference>(AppSettings.KEY_SYNC)?.run {
summary = account?.name ?: getString(R.string.sync_title)
}
}
}
}

View File

@@ -1,14 +1,19 @@
package org.koitharu.kotatsu.sync
import androidx.room.InvalidationTracker
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.sync.ui.SyncAuthViewModel
val syncModule
get() = module {
single { SyncController(androidContext()) } bind InvalidationTracker.Observer::class
factory { SyncAuthApi(androidContext(), get()) }
viewModel { SyncAuthViewModel(get()) }

View File

@@ -0,0 +1,71 @@
package org.koitharu.kotatsu.sync.domain
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
import androidx.room.InvalidationTracker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
class SyncController(
context: Context,
) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
private val am = AccountManager.get(context)
private val accountType = context.getString(R.string.account_type_sync)
override fun onInvalidated(tables: MutableSet<String>) {
requestSync(
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
history = TABLE_HISTORY in tables,
)
}
suspend fun requestFullSync() = withContext(Dispatchers.Default) {
requestSyncImpl(favourites = true, history = true)
}
private fun requestSync(favourites: Boolean, history: Boolean) = processLifecycleScope.launch(Dispatchers.Default) {
requestSyncImpl(favourites, history)
}
@Synchronized
private fun requestSyncImpl(favourites: Boolean, history: Boolean) {
if (!favourites && !history) {
return
}
val account = peekAccount() ?: return
if (!ContentResolver.getMasterSyncAutomatically()) {
return
}
// TODO limit frequency
if (favourites) {
requestSyncForAuthority(account, AUTHORITY_FAVOURITES)
}
if (history) {
requestSyncForAuthority(account, AUTHORITY_HISTORY)
}
}
private fun requestSyncForAuthority(account: Account, authority: String) {
if (
ContentResolver.getSyncAutomatically(account, AUTHORITY_FAVOURITES) &&
!ContentResolver.isSyncActive(account, authority) &&
!ContentResolver.isSyncPending(account, authority)
) {
ContentResolver.requestSync(account, authority, Bundle.EMPTY)
}
}
private fun peekAccount(): Account? {
return am.getAccountsByType(accountType).firstOrNull()
}
}

View File

@@ -13,17 +13,17 @@ import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.*
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.data.SyncAuthenticator
import org.koitharu.kotatsu.sync.data.SyncInterceptor
import org.koitharu.kotatsu.utils.GZipInterceptor
import org.koitharu.kotatsu.utils.ext.parseJsonOrNull
import org.koitharu.kotatsu.utils.ext.toContentValues
import org.koitharu.kotatsu.utils.ext.toJson
import org.koitharu.kotatsu.utils.ext.toRequestBody
private const val AUTHORITY_HISTORY = "org.koitharu.kotatsu.history"
private const val AUTHORITY_FAVOURITES = "org.koitharu.kotatsu.favourites"
const val AUTHORITY_HISTORY = "org.koitharu.kotatsu.history"
const val AUTHORITY_FAVOURITES = "org.koitharu.kotatsu.favourites"
private const val FIELD_TIMESTAMP = "timestamp"
@@ -53,7 +53,7 @@ class SyncHelper(
.url("$baseUrl/resource/$TABLE_FAVOURITES")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJson()
val response = httpClient.newCall(request).execute().parseJsonOrNull() ?: return
val timestamp = response.getLong(FIELD_TIMESTAMP)
val categoriesResult = upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES), timestamp)
syncResult.stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L
@@ -71,7 +71,7 @@ class SyncHelper(
.url("$baseUrl/resource/$TABLE_HISTORY")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJson()
val response = httpClient.newCall(request).execute().parseJsonOrNull() ?: return
val result = upsertHistory(
json = response.getJSONArray(TABLE_HISTORY),
timestamp = response.getLong(FIELD_TIMESTAMP),

View File

@@ -9,7 +9,7 @@ import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
class SyncAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
class SyncAccountAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle? = null
@@ -52,7 +52,6 @@ class SyncAuthenticator(private val context: Context) : AbstractAccountAuthentic
} else {
val intent = Intent(context, SyncAuthActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
// intent.putExtra(SyncAuthActivity.EXTRA_TOKEN_TYPE, authTokenType)
val bundle = Bundle()
if (options != null) {
bundle.putAll(options)

View File

@@ -6,11 +6,11 @@ import android.os.IBinder
class SyncAuthenticatorService : Service() {
private lateinit var authenticator: SyncAuthenticator
private lateinit var authenticator: SyncAccountAuthenticator
override fun onCreate() {
super.onCreate()
authenticator = SyncAuthenticator(this)
authenticator = SyncAccountAuthenticator(this)
}
override fun onBind(intent: Intent?): IBinder? {

View File

@@ -2,8 +2,19 @@ package org.koitharu.kotatsu.utils.ext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.util.parseJson
import java.net.HttpURLConnection
private val TYPE_JSON = "application/json".toMediaType()
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
fun Response.parseJsonOrNull(): JSONObject? {
return if (code == HttpURLConnection.HTTP_NO_CONTENT) {
null
} else {
parseJson()
}
}

View File

@@ -1,10 +1,15 @@
package org.koitharu.kotatsu.widget
import androidx.room.InvalidationTracker
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.widget.shelf.ShelfConfigViewModel
val appWidgetModule
get() = module {
single<InvalidationTracker.Observer> { WidgetUpdater(androidContext()) }
viewModel { ShelfConfigViewModel(get()) }
}

View File

@@ -4,36 +4,26 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import androidx.room.InvalidationTracker
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider
import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider
class WidgetUpdater(private val context: Context) {
class WidgetUpdater(
private val context: Context
) : InvalidationTracker.Observer(TABLE_HISTORY, TABLE_FAVOURITES) {
fun subscribeToFavourites(repository: FavouritesRepository) {
repository.observeAll(SortOrder.NEWEST)
.onEach { updateWidget(ShelfWidgetProvider::class.java) }
.retry { error -> error !is CancellationException }
.launchIn(processLifecycleScope + Dispatchers.Default)
override fun onInvalidated(tables: MutableSet<String>) {
if (TABLE_HISTORY in tables) {
updateWidgets(RecentWidgetProvider::class.java)
}
if (TABLE_FAVOURITES in tables) {
updateWidgets(ShelfWidgetProvider::class.java)
}
}
fun subscribeToHistory(repository: HistoryRepository) {
repository.observeAll()
.onEach { updateWidget(RecentWidgetProvider::class.java) }
.retry { error -> error !is CancellationException }
.launchIn(processLifecycleScope + Dispatchers.Default)
}
private fun updateWidget(cls: Class<*>) {
private fun updateWidgets(cls: Class<*>) {
val intent = Intent(context, cls)
intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
val ids = AppWidgetManager.getInstance(context)

View File

@@ -107,7 +107,7 @@
android:layout_alignParentEnd="true"
android:layout_marginTop="12dp"
android:gravity="center_horizontal"
android:text="Enter your email to continue"
android:text="@string/enter_email_text"
android:textAppearance="?textAppearanceSubtitle1" />
<com.google.android.material.textfield.TextInputLayout
@@ -119,6 +119,7 @@
android:layout_alignParentStart="true"
android:layout_alignParentEnd="true"
android:layout_marginTop="30dp"
app:endIconMode="password_toggle"
app:errorIconDrawable="@null"
app:helperText="You can sign in into an existing account or create a new one"
app:hintEnabled="false">

View File

@@ -5,7 +5,7 @@
<string name="url_forpda" translatable="false">https://4pda.to/forum/index.php?showtopic=697669</string>
<string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
<string name="url_sync_server" translatable="false">http://95.216.215.49:8080</string>
<string name="url_sync_server" translatable="false">http://95.216.215.49:8055</string>
<string-array name="values_theme" translatable="false">
<item>-1</item>
<item>1</item>

View File

@@ -303,4 +303,5 @@
<string name="default_mode">Default mode</string>
<string name="detect_reader_mode">Autodetect reader mode</string>
<string name="detect_reader_mode_summary">Automatically detect if manga is webtoon</string>
<string name="enter_email_text">Enter your email to continue</string>
</resources>

View File

@@ -44,7 +44,8 @@
android:key="sync"
android:persistent="false"
android:summary="@string/sync_title"
android:title="@string/sync" />
android:title="@string/sync"
app:allowDividerAbove="true" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.backup.BackupSettingsFragment"

View File

@@ -1,10 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:persistent="false"
android:summary="Preference stub"
android:title="TODO" />
</PreferenceScreen>
<PreferenceScreen />