diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d3c76ebf..bf3dfbecf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,9 @@ + + + ().theme) registerActivityLifecycleCallbacks(get()) - val widgetUpdater = WidgetUpdater(applicationContext) - widgetUpdater.subscribeToFavourites(get()) - widgetUpdater.subscribeToHistory(get()) } private fun initKoin() { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt index 215d02259..18e206142 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt @@ -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().forEach { + db.invalidationTracker.addObserver(it) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 49bde074a..4ec967ece 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -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().requestFullSync() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index d9de212d6..b471ea43d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -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(AppSettings.KEY_SYNC)?.run { + summary = account?.name ?: getString(R.string.sync_title) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt b/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt index 78688cac0..ba0d9f2a9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt @@ -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()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt new file mode 100644 index 000000000..683c1fa63 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt @@ -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) { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt index f5dbfb9d5..1c816cab2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt @@ -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), diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt similarity index 93% rename from app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticator.kt rename to app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt index 48c0aef8d..d1e4eb05c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt @@ -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) diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt index 6f7ca8161..1262a9e9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt @@ -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? { diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt index 3f3b966cc..12d23183d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt @@ -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) \ No newline at end of file +fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON) + +fun Response.parseJsonOrNull(): JSONObject? { + return if (code == HttpURLConnection.HTTP_NO_CONTENT) { + null + } else { + parseJson() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt b/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt index 3023da8b0..d96fdc43a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt @@ -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 { WidgetUpdater(androidContext()) } + viewModel { ShelfConfigViewModel(get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt b/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt index ee11b02c6..984744650 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt @@ -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) { + 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) diff --git a/app/src/main/res/layout/activity_sync_auth.xml b/app/src/main/res/layout/activity_sync_auth.xml index a0c7fefcd..86ff29e22 100644 --- a/app/src/main/res/layout/activity_sync_auth.xml +++ b/app/src/main/res/layout/activity_sync_auth.xml @@ -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" /> diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 12c41ccbf..54757b66d 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -5,7 +5,7 @@ https://4pda.to/forum/index.php?showtopic=697669 https://hosted.weblate.org/engage/kotatsu org.kotatsu.sync - http://95.216.215.49:8080 + http://95.216.215.49:8055 -1 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a0e4737a4..b1d5f2e89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,4 +303,5 @@ Default mode Autodetect reader mode Automatically detect if manga is webtoon + Enter your email to continue \ No newline at end of file diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml index c7f6ef630..5247bce95 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -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" /> - - - - - \ No newline at end of file + \ No newline at end of file