From a9497f941d5085d8cc549fcf01f370cd48013e61 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 30 Nov 2022 10:29:51 +0100 Subject: [PATCH 01/51] Add Github sponsoring --- .github/FUNDING.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 59271e6..0811824 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,3 @@ -custom: [ 'https://icsx5.bitfire.at/donate/' ] +github: bitfireAT +custom: 'https://icsx5.bitfire.at/donate/' -- GitLab From 508bc647e6289c79d24c1a564a77aeaebf404472 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Thu, 1 Dec 2022 19:04:36 +0100 Subject: [PATCH 02/51] First calendar addition doesn't refresh the list (#75) * Reinitializing `CalendarModel` listeners on resume Signed-off-by: Arnau Mora * CalendarListActivity: Start watching calendars as soon as permissions are granted Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- .../at/bitfire/icsdroid/PermissionUtils.kt | 32 ++++++++++++++++-- .../icsdroid/ui/AddCalendarActivity.kt | 12 +++---- .../icsdroid/ui/CalendarListActivity.kt | 33 +++++++++++-------- 3 files changed, 55 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt index 30a8fa2..7138cd4 100644 --- a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt @@ -5,15 +5,41 @@ package at.bitfire.icsdroid import android.Manifest +import android.content.Context +import android.content.pm.PackageManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import at.bitfire.icsdroid.ui.NotificationUtils -class PermissionUtils(val activity: AppCompatActivity) { +object PermissionUtils { - fun registerCalendarPermissionRequestLauncher() = + val CALENDAR_PERMISSIONS = arrayOf( + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR + ) + + /** + * Checks whether the calling app has all [CALENDAR_PERMISSIONS]. + * + * @param context context to check permissions within + * @return *true* if all calendar permissions are granted; *false* otherwise + */ + fun haveCalendarPermissions(context: Context) = CALENDAR_PERMISSIONS.all { permission -> + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + + /** + * Registers a calendar permission request launcher. + * + * @param activity activity to register permission request launcher + * @param onGranted called when calendar permissions have been granted + * + * @return permission request launcher; has to be called with `launch(PermissionUtils.CALENDAR_PERMISSIONS)` + */ + fun registerCalendarPermissionRequest(activity: AppCompatActivity, onGranted: () -> Unit = {}) = activity.registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> @@ -28,6 +54,8 @@ class PermissionUtils(val activity: AppCompatActivity) { // we have calendar permissions, cancel possible notification val nm = NotificationManagerCompat.from(activity) nm.cancel(NotificationUtils.NOTIFY_PERMISSION) + + onGranted() } } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt index 3978b48..0680169 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt @@ -4,12 +4,9 @@ package at.bitfire.icsdroid.ui -import android.Manifest -import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ActivityCompat import at.bitfire.icsdroid.PermissionUtils import at.bitfire.icsdroid.db.LocalCalendar @@ -28,10 +25,11 @@ class AddCalendarActivity: AppCompatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) - val calendarPermissionRequestLauncher = PermissionUtils(this).registerCalendarPermissionRequestLauncher() - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED || - ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED) - calendarPermissionRequestLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) + if (!PermissionUtils.haveCalendarPermissions(this)) { + PermissionUtils + .registerCalendarPermissionRequest(this) + .launch(PermissionUtils.CALENDAR_PERMISSIONS) + } if (inState == null) { supportFragmentManager diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt index c36b64d..ea2c95a 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt @@ -4,13 +4,11 @@ package at.bitfire.icsdroid.ui -import android.Manifest import android.annotation.SuppressLint import android.app.Application import android.content.ContentUris import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.database.ContentObserver import android.os.Build import android.os.Bundle @@ -22,7 +20,6 @@ import android.view.* import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.core.app.ActivityCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -64,10 +61,13 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis binding.refresh.setOnRefreshListener(this) binding.refresh.setSize(SwipeRefreshLayout.LARGE) - val calendarPermissionsRequestLauncher = PermissionUtils(this).registerCalendarPermissionRequestLauncher() + val calendarPermissionsRequestLauncher = PermissionUtils.registerCalendarPermissionRequest(this) { + // re-initialize model if calendar permissions are granted + model.reinit() + } model.askForPermissions.observe(this) { ask -> if (ask) - calendarPermissionsRequestLauncher.launch(CalendarModel.PERMISSIONS) + calendarPermissionsRequestLauncher.launch(PermissionUtils.CALENDAR_PERMISSIONS) } model.isRefreshing.observe(this) { isRefreshing -> @@ -259,16 +259,17 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis /** - * Data model for this view. Must only be created when the app has calendar permissions! + * Data model for this view. Updates calendar subscriptions in real-time. + * + * Must be initialized with [reinit] after it's created. + * + * Requires calendar permissions. If it doesn't have calendar permissions, it does nothing. + * As soon as calendar permissions are granted, you have to call [reinit] again. */ class CalendarModel( application: Application ): AndroidViewModel(application) { - companion object { - val PERMISSIONS = arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR) - } - private val resolver = application.contentResolver val askForPermissions = MutableLiveData(false) @@ -283,11 +284,17 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis fun reinit() { - val havePermissions = PERMISSIONS.all { ActivityCompat.checkSelfPermission(getApplication(), it) == PackageManager.PERMISSION_GRANTED } + val havePermissions = PermissionUtils.haveCalendarPermissions(getApplication()) askForPermissions.value = !havePermissions - if (havePermissions && observer == null) - startWatchingCalendars() + if (observer == null) { + // we're not watching the calendars yet + if (havePermissions) { + Log.d(Constants.TAG, "Watching calendars") + startWatchingCalendars() + } else + Log.w(Constants.TAG,"Can't watch calendars (permission denied)") + } } override fun onCleared() { -- GitLab From 121f584682c86a6fe6e3ed6954082e0891fc0294 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 11 Dec 2022 17:27:11 +0100 Subject: [PATCH 03/51] Android 13 compatibility (#79) * Raised compile SDK level Signed-off-by: Arnau Mora * Updated AGP to `7.3.1` Signed-off-by: Arnau Mora * Added namespace declaration Signed-off-by: Arnau Mora * Updated desugaring lib to `1.2.0` Signed-off-by: Arnau Mora * Updated test deps Signed-off-by: Arnau Mora * Updated `com.google.android.material:material` Signed-off-by: Arnau Mora * Updated AndroidX libs Signed-off-by: Arnau Mora * Added notifications permission request Signed-off-by: Arnau Mora * Migrated to new permissions request Signed-off-by: Arnau Mora * Added notifications permission Signed-off-by: Arnau Mora * Opt in for back invoked callback Signed-off-by: Arnau Mora * Migrated back handler Signed-off-by: Arnau Mora * Updated Kotlin to `1.7.20` Signed-off-by: Arnau Mora * Increased target sdk version to `33` Signed-off-by: Arnau Mora * Updated AppCompat Signed-off-by: Arnau Mora * Imported locales config Signed-off-by: Arnau Mora * Added compatibility service Signed-off-by: Arnau Mora * Added automatic locales generation Signed-off-by: Arnau Mora * Imported locales list Signed-off-by: Arnau Mora * Added monochrome icon Signed-off-by: Arnau Mora * Don't ask for multiple permissions at once * Remove anonymous object Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- app/build.gradle | 97 +++++++++++++++++-- app/src/main/AndroidManifest.xml | 14 +++ .../at/bitfire/icsdroid/PermissionUtils.kt | 67 ++++++++----- .../java/at/bitfire/icsdroid/SyncAdapter.kt | 3 +- .../icsdroid/ui/AddCalendarActivity.kt | 3 +- .../icsdroid/ui/CalendarListActivity.kt | 40 ++++++-- .../icsdroid/ui/EditCalendarActivity.kt | 14 ++- .../res/drawable/ic_launcher_monochrome.xml | 63 ++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 1 + app/src/main/res/values/strings.xml | 3 +- build.gradle | 4 +- 11 files changed, 260 insertions(+), 49 deletions(-) create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml diff --git a/app/build.gradle b/app/build.gradle index 5e32c3a..98aa230 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,13 +4,15 @@ apply plugin: 'kotlin-kapt' apply plugin: 'com.mikepenz.aboutlibraries.plugin' android { - compileSdkVersion 32 - buildToolsVersion '32.0.0' + compileSdkVersion 33 + buildToolsVersion '33.0.0' + + namespace 'at.bitfire.icsdroid' defaultConfig { applicationId "at.bitfire.icsdroid" minSdkVersion 21 - targetSdkVersion 32 + targetSdkVersion 33 versionCode 66 versionName "2.0.3" @@ -18,6 +20,10 @@ android { setProperty "archivesBaseName", "icsx5-" + getVersionCode() + "-" + getVersionName() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + def locales = getLocales() + buildConfigField "String[]", "TRANSLATION_ARRAY", "new String[]{\""+locales.join("\",\"")+"\"}" + resConfigs locales } compileOptions { @@ -60,6 +66,11 @@ android { signingConfig signingConfigs.bitfire } } + sourceSets { + main { + res.srcDirs += ['build/generated/res/locale'] + } + } lint { disable 'ExtraTranslation', 'MissingTranslation', 'InvalidPackage', 'OnClick' @@ -72,22 +83,88 @@ android { } } +import groovy.xml.MarkupBuilder + +import static groovy.io.FileType.DIRECTORIES + +/** + * Obtains a list of all the available locales + * @since 20220928 + * @return A list with the language codes of the locales available. + */ +def getLocales() { + // Initialize the list English, since it's available by default + def list = ["en"] + // Get all directories inside resources + def dir = new File(projectDir, "src/main/res") + dir.traverse(type: DIRECTORIES, maxDepth: 0) { file -> + // Get only values directories + def fileName = file.name + if (!fileName.startsWith("values-")) return + + // Take only the values directories that contain strings + def stringsFile = new File(file, "strings.xml") + if (!stringsFile.exists()) return + + // Add to the list the locale of the strings file + list.add(fileName.substring(fileName.indexOf('-') + 1)) + } + // Log the available locales + println "Supported locales: " + list.join(", ") + // Return the built list + return list +} + +/** + * Writes the available locales obtained from getLocales() in locale-config.xml + * @since 20221016 + * @return A list with the language codes of the locales available. + */ +task updateLocalesConfig() { + println 'Building locale config...' + ext.outputDir = new File(projectDir, 'build/generated/res/locale/xml') + + doFirst { + mkdir outputDir + + new File(outputDir, "locales_config.xml").withWriter { writer -> + def destXml = new MarkupBuilder(new IndentPrinter(writer, " ", true, true)) + destXml.setDoubleQuotes(true) + def destXmlMkp = destXml.getMkp() + destXmlMkp.xmlDeclaration(version: "1.0", encoding: "utf-8") + destXmlMkp.comment("Generated at ${new Date()}") + destXmlMkp.yield "\r\n" + + def locales = getLocales() + destXml."locale-config"(['xmlns:android':"http://schemas.android.com/apk/res/android"]) { + locales.forEach { locale -> + destXml."locale"("android:name": locale) + } + } + } + } +} + +gradle.projectsEvaluated { + preBuild.dependsOn('updateLocalesConfig') +} + dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.0' implementation project(':cert4android') implementation project(':ical4android') - implementation 'androidx.appcompat:appcompat:1.5.1' + implementation 'androidx.appcompat:appcompat:1.6.0-rc01' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.core:core-ktx:1.8.0' - implementation 'androidx.fragment:fragment-ktx:1.5.2' + implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.fragment:fragment-ktx:1.5.4' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.work:work-runtime-ktx:2.7.1' - implementation 'com.google.android.material:material:1.6.1' + implementation 'com.google.android.material:material:1.7.0' implementation 'com.jaredrummler:colorpicker:1.1.0' implementation "com.mikepenz:aboutlibraries:${versions.aboutLibs}" @@ -102,8 +179,8 @@ dependencies { implementation 'org.apache.commons:commons-lang3:3.8.1' // for tests - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation "androidx.test:rules:1.4.0" + androidTestImplementation 'androidx.test:runner:1.5.0' + androidTestImplementation "androidx.test:rules:1.5.0" androidTestImplementation "androidx.arch.core:core-testing:2.1.0" androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6aa0cf2..5194ed8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt index 7138cd4..48d5354 100644 --- a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt @@ -7,12 +7,12 @@ package at.bitfire.icsdroid import android.Manifest import android.content.Context import android.content.pm.PackageManager +import android.util.Log import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import at.bitfire.icsdroid.ui.NotificationUtils object PermissionUtils { @@ -32,31 +32,54 @@ object PermissionUtils { } /** - * Registers a calendar permission request launcher. + * Registers for the result of the request of some permissions. + * Invoke the returned anonymous function to actually request the permissions. * - * @param activity activity to register permission request launcher - * @param onGranted called when calendar permissions have been granted + * When all requested permissions are granted, [onGranted] is called. + * When not all requested permissions are granted, a toast is shown. + * + * @param activity The activity where to register the request launcher. + * @param permissions The permissions to be requested. + * @param toastMessage The message to show in a toast if at least one permissions was not granted. + * @param onGranted What to call when all permissions were granted. * - * @return permission request launcher; has to be called with `launch(PermissionUtils.CALENDAR_PERMISSIONS)` + * @return The request launcher for launching the request. */ - fun registerCalendarPermissionRequest(activity: AppCompatActivity, onGranted: () -> Unit = {}) = - activity.registerForActivityResult( + fun registerPermissionRequest( + activity: AppCompatActivity, + permissions: Array, + @StringRes toastMessage: Int, + onGranted: () -> Unit = {}, + ): (() -> Unit) { + val request = activity.registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - if (permissions[Manifest.permission.READ_CALENDAR] == false || - permissions[Manifest.permission.WRITE_CALENDAR] == false) { - // calendar permissions missing - Toast.makeText(activity, R.string.calendar_permissions_required, Toast.LENGTH_LONG).show() - activity.finish() - - } else if (permissions[Manifest.permission.READ_CALENDAR] == true && - permissions[Manifest.permission.WRITE_CALENDAR] == true) { - // we have calendar permissions, cancel possible notification - val nm = NotificationManagerCompat.from(activity) - nm.cancel(NotificationUtils.NOTIFY_PERMISSION) - + ) { permissionsResult -> + Log.i(Constants.TAG, "Requested permissions: ${permissions.asList()}, got permissions: $permissionsResult") + if (permissions.all { requestedPermission -> permissionsResult.getOrDefault(requestedPermission, null) == true }) + // all permissions granted onGranted() + else { + // some permissions missing + Toast.makeText(activity, toastMessage, Toast.LENGTH_LONG).show() } } + return { request.launch(permissions) } + } + + /** + * Registers a calendar permission request launcher. + * + * @param activity activity to register permission request launcher + * @param onGranted called when calendar permissions have been granted + * + * @return Call the returning function to launch the request + */ + fun registerCalendarPermissionRequest(activity: AppCompatActivity, onGranted: () -> Unit = {}) = + registerPermissionRequest( + activity, + CALENDAR_PERMISSIONS, + R.string.calendar_permissions_required, + onGranted + ) -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt b/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt index 349a6a4..8cd1381 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt @@ -30,12 +30,13 @@ class SyncAdapter( override fun onSecurityException(account: Account?, extras: Bundle?, authority: String?, syncResult: SyncResult?) { val nm = NotificationUtils.createChannels(context) + val askPermissionsIntent = Intent(context, CalendarListActivity::class.java) val notification = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC) .setSmallIcon(R.drawable.ic_sync_problem_white) .setContentTitle(context.getString(R.string.sync_permission_required)) .setContentText(context.getString(R.string.sync_permission_required_sync_calendar)) .setCategory(NotificationCompat.CATEGORY_ERROR) - .setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, CalendarListActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat)) + .setContentIntent(PendingIntent.getActivity(context, 0, askPermissionsIntent, PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat)) .setAutoCancel(true) .setLocalOnly(true) .build() diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt index 0680169..d0b0501 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt @@ -27,8 +27,7 @@ class AddCalendarActivity: AppCompatActivity() { if (!PermissionUtils.haveCalendarPermissions(this)) { PermissionUtils - .registerCalendarPermissionRequest(this) - .launch(PermissionUtils.CALENDAR_PERMISSIONS) + .registerCalendarPermissionRequest(this)() } if (inState == null) { diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt index ea2c95a..0643c62 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt @@ -4,11 +4,13 @@ package at.bitfire.icsdroid.ui +import android.Manifest import android.annotation.SuppressLint import android.app.Application import android.content.ContentUris import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.database.ContentObserver import android.os.Build import android.os.Bundle @@ -20,6 +22,8 @@ import android.view.* import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -61,19 +65,26 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis binding.refresh.setOnRefreshListener(this) binding.refresh.setSize(SwipeRefreshLayout.LARGE) - val calendarPermissionsRequestLauncher = PermissionUtils.registerCalendarPermissionRequest(this) { - // re-initialize model if calendar permissions are granted - model.reinit() - } + val permissionsRequestLauncher = + PermissionUtils.registerPermissionRequest(this, CalendarModel.REQUIRED_PERMISSIONS, R.string.permissions_required) { + // re-initialize model if calendar permissions are granted + model.reinit() + + // we have calendar permissions, cancel possible sync notification (see SyncAdapter.onSecurityException askPermissionsIntent) + val nm = NotificationManagerCompat.from(this) + nm.cancel(NotificationUtils.NOTIFY_PERMISSION) + } model.askForPermissions.observe(this) { ask -> if (ask) - calendarPermissionsRequestLauncher.launch(PermissionUtils.CALENDAR_PERMISSIONS) + permissionsRequestLauncher() } + // show whether sync is running model.isRefreshing.observe(this) { isRefreshing -> binding.refresh.isRefreshing = isRefreshing } + // calendars val calendarAdapter = CalendarListAdapter(this) calendarAdapter.clickListener = { calendar -> val intent = Intent(this, EditCalendarActivity::class.java) @@ -270,6 +281,14 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis application: Application ): AndroidViewModel(application) { + companion object { + val REQUIRED_PERMISSIONS = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + PermissionUtils.CALENDAR_PERMISSIONS + Manifest.permission.POST_NOTIFICATIONS + else + PermissionUtils.CALENDAR_PERMISSIONS + } + private val resolver = application.contentResolver val askForPermissions = MutableLiveData(false) @@ -284,12 +303,17 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis fun reinit() { - val havePermissions = PermissionUtils.haveCalendarPermissions(getApplication()) - askForPermissions.value = !havePermissions + val haveCalendarPermissions = PermissionUtils.haveCalendarPermissions(getApplication()) + val haveNotificationPermissions = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + else + true + askForPermissions.value = !haveCalendarPermissions || !haveNotificationPermissions if (observer == null) { // we're not watching the calendars yet - if (havePermissions) { + if (haveCalendarPermissions) { Log.d(Constants.TAG, "Watching calendars") startWatchingCalendars() } else diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt index 0dc6013..5f681bf 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt @@ -11,6 +11,7 @@ import android.content.ContentUris import android.content.ContentValues import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle import android.provider.CalendarContract import android.util.Log @@ -18,6 +19,8 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.Toast +import android.window.OnBackInvokedDispatcher +import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity @@ -101,6 +104,13 @@ class EditCalendarActivity: AppCompatActivity() { .show(supportFragmentManager, null) } } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + onBackInvokedDispatcher.registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_DEFAULT, + ) { handleOnBackPressed() } + else + onBackPressedDispatcher.addCallback { handleOnBackPressed() } } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -165,14 +175,12 @@ class EditCalendarActivity: AppCompatActivity() { /* user actions */ - override fun onBackPressed() { + private fun handleOnBackPressed() { if (dirty()) supportFragmentManager.beginTransaction() .add(SaveDismissDialogFragment(), null) .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) .commit() - else - super.onBackPressed() } fun onSave(item: MenuItem?) { diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..2808247 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index b1bc769..d3932e0 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,4 +1,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41a286d..a2042ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,8 +8,9 @@ Next Calendar permissions required - Sync problems Couldn\'t load calendar + Sync problems + Permissions required My subscriptions diff --git a/build.gradle b/build.gradle index 2dac876..0dbdcc9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext.versions = [ aboutLibs: '8.9.4', - kotlin: '1.7.10', + kotlin: '1.7.20', okhttp: '5.0.0-alpha.10' ] @@ -13,7 +13,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.2.2' + classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:${versions.aboutLibs}" } -- GitLab From 19934b187fe6ac440668cdb2c300d7ff1c350221 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Wed, 14 Dec 2022 15:58:24 +0100 Subject: [PATCH 04/51] WIP: Customizable alarms per subscription (#78) * Added alerts section Signed-off-by: Arnau Mora * Added allowed reminders fetching Signed-off-by: Arnau Mora * Added `LifecycleViewHolder` Signed-off-by: Arnau Mora * Added row for custom alerts Signed-off-by: Arnau Mora * Added alerts section displaying Signed-off-by: Arnau Mora * Added allowed reminders passing Signed-off-by: Arnau Mora * Added `ColorPickerActivity.Contract` Signed-off-by: Arnau Mora * Added strings Signed-off-by: Arnau Mora * Added reminders and ignore alerts storage Signed-off-by: Arnau Mora * Moved class declaration Signed-off-by: Arnau Mora * Added storage of custom alarm preferences Signed-off-by: Arnau Mora * Added check for blank reminders Signed-off-by: Arnau Mora * Added alarms removal when loading Signed-off-by: Arnau Mora * Simplified calendar alerts to just one Signed-off-by: Arnau Mora * Updated strings Signed-off-by: Arnau Mora * Simplified UI to have only one default alarm Signed-off-by: Arnau Mora * Added new strings Signed-off-by: Arnau Mora * Removed no longer necessary `CalendarReminder` Signed-off-by: Arnau Mora * Sketched workflow Signed-off-by: Arnau Mora * Implemented default alarm adder Signed-off-by: Arnau Mora * Just a small deprecation Signed-off-by: Arnau Mora * Added ignore cache option Signed-off-by: Arnau Mora * Added ignore cache refresh of saving Signed-off-by: Arnau Mora * Optimized event updating Signed-off-by: Arnau Mora * Make forceResync explicit * Minor changes * No longer used Signed-off-by: Arnau Mora * Moved alarms logic to `ProcessEventsTask` Signed-off-by: Arnau Mora * Cleanup Signed-off-by: Arnau Mora * Use Joda for time interval formatting; minor changes * Removed `firstCheck` variable Signed-off-by: Arnau Mora * Refactored check Signed-off-by: Arnau Mora Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- app/build.gradle | 1 + .../at/bitfire/icsdroid/ProcessEventsTask.kt | 80 +++++++++++++--- .../java/at/bitfire/icsdroid/SyncWorker.kt | 29 +++--- .../at/bitfire/icsdroid/db/LocalCalendar.kt | 83 ++++++++++++----- .../icsdroid/ui/AddCalendarDetailsFragment.kt | 7 ++ .../icsdroid/ui/CalendarListActivity.kt | 2 +- .../icsdroid/ui/ColorPickerActivity.kt | 10 ++ .../icsdroid/ui/EditCalendarActivity.kt | 21 ++++- .../bitfire/icsdroid/ui/TitleColorFragment.kt | 91 +++++++++++++++---- app/src/main/res/layout/title_color.xml | 54 +++++++++-- app/src/main/res/values/strings.xml | 10 ++ 11 files changed, 311 insertions(+), 77 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 98aa230..bd9cf5b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -171,6 +171,7 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}" implementation "com.squareup.okhttp3:okhttp-coroutines:${versions.okhttp}" + implementation "joda-time:joda-time:2.12.1" // latest commons that don't require Java 8 //noinspection GradleDependency diff --git a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt index af989e5..b46f50e 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt @@ -19,14 +19,36 @@ import at.bitfire.icsdroid.db.LocalCalendar import at.bitfire.icsdroid.db.LocalEvent import at.bitfire.icsdroid.ui.EditCalendarActivity import at.bitfire.icsdroid.ui.NotificationUtils +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.Trigger import okhttp3.MediaType import java.io.InputStream import java.io.InputStreamReader import java.net.MalformedURLException - +import java.time.Duration + +/** + * Fetches the .ics for a given Webcal subscription and stores the events + * in the local calendar provider. + * + * By default, caches will be used: + * + * - for fetching a calendar by HTTP (ETag/Last-Modified), + * - for updating the local events (will only be updated when LAST-MODIFIED is newer). + * + * @param context context to work in + * @param calendar represents the subscription to be checked + * @param forceResync enforces that the calendar is fetched and all events are fully processed + * (useful when subscription settings have been changed) + */ class ProcessEventsTask( - val context: Context, - val calendar: LocalCalendar + val context: Context, + val calendar: LocalCalendar, + val forceResync: Boolean ) { suspend fun sync() { @@ -44,6 +66,41 @@ class ProcessEventsTask( Log.i(Constants.TAG, "iCalendar file completely processed") } + /** + * Updates the alarms of the given event according to the [calendar]'s [LocalCalendar.defaultAlarmMinutes] and [LocalCalendar.ignoreEmbeddedAlerts] + * parameters. + * @since 20221208 + * @param event The event to update. + * @return The given [event], with the alarms updated. + */ + private fun updateAlarms(event: Event): Event = event.apply { + if (calendar.ignoreEmbeddedAlerts == true) { + // Remove all alerts + Log.d(Constants.TAG, "Removing all alarms from ${uid}: $this") + alarms.clear() + } + calendar.defaultAlarmMinutes?.let { minutes -> + // Check if already added alarm + val alarm = alarms.find { it.description.value.contains("*added by ICSx5") } + if (alarm != null) return@let + // Add the default alarm to the event + Log.d(Constants.TAG, "Adding the default alarm to ${uid}.") + alarms.add( + // Create the new VAlarm + VAlarm.Factory().createComponent( + // Set all the properties for the alarm + PropertyList().apply { + // Set action to DISPLAY + add(Action.DISPLAY) + // Add the trigger x minutes before + val duration = Duration.ofMinutes(-minutes) + add(Trigger(duration)) + } + ) + ) + } + } + private suspend fun processEvents() { val uri = try { @@ -53,7 +110,7 @@ class ProcessEventsTask( calendar.updateStatusError(e.localizedMessage ?: e.toString()) return } - Log.i(Constants.TAG, "Synchronizing $uri") + Log.i(Constants.TAG, "Synchronizing $uri, forceResync=$forceResync") // dismiss old notifications val notificationManager = NotificationUtils.createChannels(context) @@ -65,7 +122,7 @@ class ProcessEventsTask( InputStreamReader(data, contentType?.charset() ?: Charsets.UTF_8).use { reader -> try { val events = Event.eventsFromReader(reader) - processEvents(events) + processEvents(events, forceResync) Log.i(Constants.TAG, "Calendar sync successful, ETag=$eTag, lastModified=$lastModified") calendar.updateStatusSuccess(eTag, lastModified ?: 0L) @@ -98,9 +155,9 @@ class ProcessEventsTask( downloader.password = password } - if (calendar.eTag != null) + if (calendar.eTag != null && !forceResync) downloader.ifNoneMatch = calendar.eTag - if (calendar.lastModified != 0L) + if (calendar.lastModified != 0L && !forceResync) downloader.ifModifiedSince = calendar.lastModified downloader.fetch() @@ -131,11 +188,12 @@ class ProcessEventsTask( } } - private fun processEvents(events: List) { - Log.i(Constants.TAG, "Processing ${events.size} events") + private fun processEvents(events: List, ignoreLastModified: Boolean) { + Log.i(Constants.TAG, "Processing ${events.size} events (ignoreLastModified=$ignoreLastModified)") val uids = HashSet(events.size) - for (event in events) { + for (ev in events) { + val event = updateAlarms(ev) val uid = event.uid!! Log.d(Constants.TAG, "Found VEVENT: $uid") uids += uid @@ -147,7 +205,7 @@ class ProcessEventsTask( } else { val localEvent = localEvents.first() - var lastModified = event.lastModified + var lastModified = if (ignoreLastModified) null else event.lastModified Log.d(Constants.TAG, "$uid already in local calendar, lastModified = $lastModified") if (lastModified != null) { diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt index 7b047ca..a498efe 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt @@ -26,24 +26,26 @@ class SyncWorker( const val NAME = "SyncWorker" + const val FORCE_RESYNC = "forceResync" + /** * Enqueues a sync job for immediate execution. If the sync is forced, * the "requires network connection" constraint won't be set. * - * @param context required for managing work - * @param force *true* enqueues the sync regardless of the network state; *false* adds a [NetworkType.CONNECTED] constraint + * @param context required for managing work + * @param force *true* enqueues the sync regardless of the network state; *false* adds a [NetworkType.CONNECTED] constraint + * @param forceResync *true* ignores all locally stored data and fetched everything from the server again */ - fun run(context: Context, force: Boolean = false) { + fun run(context: Context, force: Boolean = false, forceResync: Boolean = false) { val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(FORCE_RESYNC to forceResync)) - val policy: ExistingWorkPolicy - if (force) { + val policy: ExistingWorkPolicy = if (force) { Log.i(Constants.TAG, "Manual sync, ignoring network condition") // overwrite existing syncs (which may have unwanted constraints) - policy = ExistingWorkPolicy.REPLACE - + ExistingWorkPolicy.REPLACE } else { // regular sync, requires network request.setConstraints(Constraints.Builder() @@ -51,7 +53,7 @@ class SyncWorker( .build()) // don't overwrite previous syncs (whether regular or manual) - policy = ExistingWorkPolicy.KEEP + ExistingWorkPolicy.KEEP } WorkManager.getInstance(context) @@ -67,10 +69,11 @@ class SyncWorker( @SuppressLint("Recycle") override suspend fun doWork(): Result { + val forceResync = inputData.getBoolean(FORCE_RESYNC, false) applicationContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { providerClient -> try { return withContext(Dispatchers.Default) { - performSync(AppAccount.get(applicationContext), providerClient) + performSync(AppAccount.get(applicationContext), providerClient, forceResync) } } finally { providerClient.closeCompat() @@ -79,12 +82,12 @@ class SyncWorker( return Result.failure() } - private suspend fun performSync(account: Account, provider: ContentProviderClient): Result { - Log.i(Constants.TAG, "Synchronizing ${account.name}") + private suspend fun performSync(account: Account, provider: ContentProviderClient, forceResync: Boolean): Result { + Log.i(Constants.TAG, "Synchronizing ${account.name} (forceResync=$forceResync)") try { LocalCalendar.findAll(account, provider) - .filter { it.isSynced } - .forEach { ProcessEventsTask(applicationContext, it).sync() } + .filter { it.isSynced } + .forEach { ProcessEventsTask(applicationContext, it, forceResync).sync() } } catch (e: CalendarStorageException) { Log.e(Constants.TAG, "Calendar storage exception", e) diff --git a/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt b/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt index 5ec3b2c..3ae256b 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt @@ -11,16 +11,25 @@ import android.content.ContentValues import android.os.RemoteException import android.provider.CalendarContract.Calendars import android.provider.CalendarContract.Events +import android.util.Log import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.AndroidCalendarFactory import at.bitfire.ical4android.CalendarStorageException import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter +import at.bitfire.icsdroid.Constants +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.Trigger +import java.time.Duration class LocalCalendar private constructor( - account: Account, - provider: ContentProviderClient, - id: Long -): AndroidCalendar(account, provider, LocalEvent.Factory, id) { + account: Account, + provider: ContentProviderClient, + id: Long +) : AndroidCalendar(account, provider, LocalEvent.Factory, id) { companion object { @@ -31,20 +40,43 @@ class LocalCalendar private constructor( const val COLUMN_LAST_SYNC = Calendars.CAL_SYNC5 const val COLUMN_ERROR_MESSAGE = Calendars.CAL_SYNC6 + /** + * Stores if the calendar's embedded alerts should be ignored. + * @since 20221202 + */ + const val COLUMN_IGNORE_EMBEDDED = Calendars.CAL_SYNC8 + + /** + * Stores the default alarm to set to all events in the given calendar. + * @since 20221202 + */ + const val COLUMN_DEFAULT_ALARM = Calendars.CAL_SYNC7 + fun findById(account: Account, provider: ContentProviderClient, id: Long) = - findByID(account, provider, Factory, id) + findByID(account, provider, Factory, id) fun findAll(account: Account, provider: ContentProviderClient) = - find(account, provider, Factory, null, null) + find(account, provider, Factory, null, null) } - var url: String? = null // URL of iCalendar file - var eTag: String? = null // iCalendar ETag at last successful sync + /** URL of iCalendar file */ + var url: String? = null + /** iCalendar ETag at last successful sync */ + var eTag: String? = null + + /** iCalendar Last-Modified at last successful sync (or 0 for none) */ + var lastModified = 0L + /** time of last sync (0 if none) */ + var lastSync = 0L + /** error message (HTTP status or exception name) of last sync (or null) */ + var errorMessage: String? = null - var lastModified = 0L // iCalendar Last-Modified at last successful sync (or 0 for none) - var lastSync = 0L // time of last sync (0 if none) - var errorMessage: String? = null // error message (HTTP status or exception name) of last sync (or null) + /** Setting: whether to ignore alarms embedded in the Webcal */ + var ignoreEmbeddedAlerts: Boolean? = null + /** Setting: Shall a default alarm be added to every event in the calendar? If yes, this + * field contains the minutes before the event. If no, it is *null*. */ + var defaultAlarmMinutes: Long? = null override fun populate(info: ContentValues) { @@ -56,6 +88,9 @@ class LocalCalendar private constructor( info.getAsLong(COLUMN_LAST_SYNC)?.let { lastSync = it } errorMessage = info.getAsString(COLUMN_ERROR_MESSAGE) + + info.getAsBoolean(COLUMN_IGNORE_EMBEDDED)?.let { ignoreEmbeddedAlerts = it } + info.getAsLong(COLUMN_DEFAULT_ALARM)?.let { defaultAlarmMinutes = it } } fun updateStatusSuccess(eTag: String?, lastModified: Long) { @@ -63,11 +98,13 @@ class LocalCalendar private constructor( this.lastModified = lastModified lastSync = System.currentTimeMillis() - val values = ContentValues(4) + val values = ContentValues(7) values.put(COLUMN_ETAG, eTag) values.put(COLUMN_LAST_MODIFIED, lastModified) values.put(COLUMN_LAST_SYNC, lastSync) values.putNull(COLUMN_ERROR_MESSAGE) + values.put(COLUMN_DEFAULT_ALARM, defaultAlarmMinutes) + values.put(COLUMN_IGNORE_EMBEDDED, ignoreEmbeddedAlerts) update(values) } @@ -85,11 +122,13 @@ class LocalCalendar private constructor( lastSync = System.currentTimeMillis() errorMessage = message - val values = ContentValues(4) + val values = ContentValues(7) values.putNull(COLUMN_ETAG) values.putNull(COLUMN_LAST_MODIFIED) values.put(COLUMN_LAST_SYNC, lastSync) values.put(COLUMN_ERROR_MESSAGE, message) + values.put(COLUMN_DEFAULT_ALARM, defaultAlarmMinutes) + values.put(COLUMN_IGNORE_EMBEDDED, ignoreEmbeddedAlerts) update(values) } @@ -102,36 +141,38 @@ class LocalCalendar private constructor( } fun queryByUID(uid: String) = - queryEvents("${Events._SYNC_ID}=?", arrayOf(uid)) + queryEvents("${Events._SYNC_ID}=?", arrayOf(uid)) fun retainByUID(uids: MutableSet): Int { var deleted = 0 try { - provider.query(Events.CONTENT_URI.asSyncAdapter(account), - arrayOf(Events._ID, Events._SYNC_ID, Events.ORIGINAL_SYNC_ID), - "${Events.CALENDAR_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS NULL", arrayOf(id.toString()), null)?.use { row -> + provider.query( + Events.CONTENT_URI.asSyncAdapter(account), + arrayOf(Events._ID, Events._SYNC_ID, Events.ORIGINAL_SYNC_ID), + "${Events.CALENDAR_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS NULL", arrayOf(id.toString()), null + )?.use { row -> while (row.moveToNext()) { val eventId = row.getLong(0) val syncId = row.getString(1) if (!uids.contains(syncId)) { provider.delete(ContentUris.withAppendedId(Events.CONTENT_URI, eventId).asSyncAdapter(account), null, null) deleted++ - + uids -= syncId } } } return deleted - } catch(e: RemoteException) { + } catch (e: RemoteException) { throw CalendarStorageException("Couldn't delete local events") } } - object Factory: AndroidCalendarFactory { + object Factory : AndroidCalendarFactory { override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) = - LocalCalendar(account, provider, id) + LocalCalendar(account, provider, id) } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt index 7ca2666..5566166 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt @@ -37,6 +37,11 @@ class AddCalendarDetailsFragment: Fragment() { } titleColorModel.title.observe(this, invalidateOptionsMenu) titleColorModel.color.observe(this, invalidateOptionsMenu) + titleColorModel.ignoreAlerts.observe(this, invalidateOptionsMenu) + titleColorModel.defaultAlarmMinutes.observe(this, invalidateOptionsMenu) + + // Set the default value to null so that the visibility of the summary is updated + titleColorModel.defaultAlarmMinutes.postValue(null) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { @@ -77,6 +82,8 @@ class AddCalendarDetailsFragment: Fragment() { calInfo.put(Calendars.SYNC_EVENTS, 1) calInfo.put(Calendars.VISIBLE, 1) calInfo.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) + calInfo.put(LocalCalendar.COLUMN_IGNORE_EMBEDDED, titleColorModel.ignoreAlerts.value) + calInfo.put(LocalCalendar.COLUMN_DEFAULT_ALARM, titleColorModel.defaultAlarmMinutes.value) val client: ContentProviderClient? = requireActivity().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) return try { diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt index 0643c62..a114b93 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt @@ -60,7 +60,7 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis binding.lifecycleOwner = this binding.model = model - val defaultRefreshColor = resources.getColor(R.color.lightblue) + val defaultRefreshColor = ContextCompat.getColor(this, R.color.lightblue) binding.refresh.setColorSchemeColors(defaultRefreshColor) binding.refresh.setOnRefreshListener(this) binding.refresh.setSize(SwipeRefreshLayout.LARGE) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt index b6eff4d..a2f0e0a 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt @@ -4,8 +4,10 @@ package at.bitfire.icsdroid.ui +import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContract import androidx.appcompat.app.AppCompatActivity import at.bitfire.icsdroid.db.LocalCalendar import com.jaredrummler.android.colorpicker.ColorPickerDialog @@ -17,6 +19,14 @@ class ColorPickerActivity: AppCompatActivity(), ColorPickerDialogListener { const val EXTRA_COLOR = "color" } + class Contract: ActivityResultContract() { + override fun createIntent(context: Context, input: Int?): Intent = Intent(context, ColorPickerActivity::class.java).apply { + putExtra(EXTRA_COLOR, input) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Int = intent?.getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) ?: LocalCalendar.DEFAULT_COLOR + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt index 5f681bf..3739c5c 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt @@ -33,10 +33,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import at.bitfire.ical4android.CalendarStorageException -import at.bitfire.icsdroid.AppAccount -import at.bitfire.icsdroid.Constants -import at.bitfire.icsdroid.HttpUtils -import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.* import at.bitfire.icsdroid.databinding.EditCalendarBinding import at.bitfire.icsdroid.db.CalendarCredentials import at.bitfire.icsdroid.db.LocalCalendar @@ -73,6 +70,8 @@ class EditCalendarActivity: AppCompatActivity() { titleColorModel.title.observe(this, invalidate) titleColorModel.color.observe(this, invalidate) + titleColorModel.ignoreAlerts.observe(this, invalidate) + titleColorModel.defaultAlarmMinutes.observe(this, invalidate) credentialsModel.requiresAuth.observe(this, invalidate) credentialsModel.username.observe(this, invalidate) @@ -159,6 +158,14 @@ class EditCalendarActivity: AppCompatActivity() { titleColorModel.originalColor = it titleColorModel.color.value = it } + calendar.ignoreEmbeddedAlerts.let { + titleColorModel.originalIgnoreAlerts = it + titleColorModel.ignoreAlerts.postValue(it) + } + calendar.defaultAlarmMinutes.let { + titleColorModel.originalDefaultAlarmMinutes = it + titleColorModel.defaultAlarmMinutes.postValue(it) + } model.active.value = calendar.isSynced @@ -187,12 +194,16 @@ class EditCalendarActivity: AppCompatActivity() { var success = false model.calendar.value?.let { calendar -> try { - val values = ContentValues(3) + val values = ContentValues(5) values.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, titleColorModel.title.value) values.put(CalendarContract.Calendars.CALENDAR_COLOR, titleColorModel.color.value) values.put(CalendarContract.Calendars.SYNC_EVENTS, if (model.active.value == true) 1 else 0) + values.put(LocalCalendar.COLUMN_DEFAULT_ALARM, titleColorModel.defaultAlarmMinutes.value) + values.put(LocalCalendar.COLUMN_IGNORE_EMBEDDED, titleColorModel.ignoreAlerts.value) calendar.update(values) + SyncWorker.run(this, forceResync = true) + credentialsModel.let { model -> val credentials = CalendarCredentials(this) if (model.requiresAuth.value == true) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt index 012cff6..c6ab54a 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt @@ -4,45 +4,95 @@ package at.bitfire.icsdroid.ui -import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.CompoundButton +import android.widget.CompoundButton.OnCheckedChangeListener +import android.widget.EditText +import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import at.bitfire.icsdroid.R import at.bitfire.icsdroid.databinding.TitleColorBinding -import at.bitfire.icsdroid.db.LocalCalendar +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.joda.time.Minutes +import org.joda.time.format.PeriodFormat -class TitleColorFragment: Fragment() { +class TitleColorFragment : Fragment() { private val model by activityViewModels() + private lateinit var binding: TitleColorBinding + + private val checkboxCheckedChanged: OnCheckedChangeListener = OnCheckedChangeListener { _, checked -> + if (!checked) { + model.defaultAlarmMinutes.postValue(null) + return@OnCheckedChangeListener + } + + val editText = EditText(requireContext()).apply { + setHint(R.string.default_alarm_dialog_hint) + + addTextChangedListener { txt -> + val text = txt?.toString() + val num = text?.toLongOrNull() + error = if (text == null || text.isBlank() || num == null) + getString(R.string.default_alarm_dialog_error) + else + null + } + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.default_alarm_dialog_title) + .setMessage(R.string.default_alarm_dialog_message) + .setView(editText) + .setPositiveButton(R.string.default_alarm_dialog_set) { dialog, _ -> + if (editText.error == null) { + model.defaultAlarmMinutes.postValue(editText.text?.toString()?.toLongOrNull()) + dialog.dismiss() + } + } + .setOnCancelListener { + binding.defaultAlarmSwitch.isChecked = false + } + .create() + .show() + } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { - val binding = TitleColorBinding.inflate(inflater, container, false) + binding = TitleColorBinding.inflate(inflater, container, false) binding.lifecycleOwner = this binding.model = model - binding.color.setOnClickListener { - val intent = Intent(requireActivity(), ColorPickerActivity::class.java) - model.color.value?.let { - intent.putExtra(ColorPickerActivity.EXTRA_COLOR, it) + model.defaultAlarmMinutes.observe(viewLifecycleOwner) { min: Long? -> + binding.defaultAlarmSwitch.isChecked = min != null + // We add the listener once the switch has an initial value + binding.defaultAlarmSwitch.setOnCheckedChangeListener(checkboxCheckedChanged) + + if (min == null) { + binding.defaultAlarmText.visibility = View.GONE + } else { + val alarmPeriodText = PeriodFormat.wordBased().print(Minutes.minutes(min.toInt())) + binding.defaultAlarmText.text = getString(R.string.add_calendar_alarms_default_description, alarmPeriodText) + binding.defaultAlarmText.visibility = View.VISIBLE } - startActivityForResult(intent, 0) } - return binding.root - } - override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) { - result?.let { - model.color.value = it.getIntExtra(ColorPickerActivity.EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) + val colorPickerContract = registerForActivityResult(ColorPickerActivity.Contract()) { color -> + model.color.postValue(color) + } + binding.color.setOnClickListener { + colorPickerContract.launch(model.color.value) } + + return binding.root } - class TitleColorModel: ViewModel() { + class TitleColorModel : ViewModel() { var url = MutableLiveData() var originalTitle: String? = null @@ -51,9 +101,14 @@ class TitleColorFragment: Fragment() { var originalColor: Int? = null val color = MutableLiveData() - fun dirty() = - originalTitle != title.value || - originalColor != color.value + var originalIgnoreAlerts: Boolean? = null + val ignoreAlerts = MutableLiveData() + + var originalDefaultAlarmMinutes: Long? = null + val defaultAlarmMinutes = MutableLiveData() + + fun dirty(): Boolean = originalTitle != title.value || originalColor != color.value || originalIgnoreAlerts != ignoreAlerts.value || + originalDefaultAlarmMinutes != defaultAlarmMinutes.value } } diff --git a/app/src/main/res/layout/title_color.xml b/app/src/main/res/layout/title_color.xml index 0042ee6..6673aaa 100644 --- a/app/src/main/res/layout/title_color.xml +++ b/app/src/main/res/layout/title_color.xml @@ -2,20 +2,24 @@ + - + + + android:layout_height="match_parent" + android:orientation="vertical"> + android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5" /> + android:text="@{model.url}" + android:textIsSelectable="true" /> @@ -60,11 +64,45 @@ android:layout_gravity="center" android:layout_marginLeft="16dp" app:color="@{model.color}" - tools:ignore="RtlHardcoded"/> + tools:ignore="RtlHardcoded" /> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2042ed..21719ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,16 @@ Pick file User name Validating calendar resource… + Alarms + Ignore alerts embed in the calendar + If enabled, all the incoming alarms from the server will be dismissed. + Add a default alarm for all events + Alarms set to %s before + Add default alarm + This will add an alarm for all events + Minutes before event + Set + Introduce a valid number Share details -- GitLab From 5e57d22bf1eb52f9bbbe1dd09a993d4ab2baf6b5 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 15 Dec 2022 16:07:55 +0100 Subject: [PATCH 05/51] Version bump to 2.1-beta.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index bd9cf5b..f102c4d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,8 +14,8 @@ android { minSdkVersion 21 targetSdkVersion 33 - versionCode 66 - versionName "2.0.3" + versionCode 67 + versionName "2.1-beta.1" setProperty "archivesBaseName", "icsx5-" + getVersionCode() + "-" + getVersionName() -- GitLab From 429c77a699f1012fa709451b5c5e4d8eef0e6c65 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Thu, 15 Dec 2022 17:25:09 +0100 Subject: [PATCH 06/51] Using `Uri` instead of `URI` (#85) Signed-off-by: Arnau Mora Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- .../icsdroid/ui/AddCalendarEnterUrlFragment.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt index fc31e0a..a46d07d 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt @@ -15,7 +15,6 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.Observer import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.HttpUtils -import at.bitfire.icsdroid.HttpUtils.toUri import at.bitfire.icsdroid.R import at.bitfire.icsdroid.databinding.AddCalendarEnterUrlBinding import okhttp3.HttpUrl.Companion.toHttpUrl @@ -86,13 +85,13 @@ class AddCalendarEnterUrlFragment: Fragment() { /* dynamic changes */ - private fun validateUri(): URI? { + private fun validateUri(): Uri? { var errorMsg: String? = null - var uri: URI + var uri: Uri try { try { - uri = URI(titleColorModel.url.value ?: return null) + uri = Uri.parse(titleColorModel.url.value ?: return null) } catch (e: URISyntaxException) { Log.d(Constants.TAG, "Invalid URL", e) errorMsg = e.localizedMessage @@ -102,16 +101,16 @@ class AddCalendarEnterUrlFragment: Fragment() { Log.i(Constants.TAG, uri.toString()) if (uri.scheme.equals("webcal", true)) { - uri = URI("http", uri.authority, uri.path, uri.query, null) + uri = uri.buildUpon().scheme("http").build() titleColorModel.url.value = uri.toString() return null } else if (uri.scheme.equals("webcals", true)) { - uri = URI("https", uri.authority, uri.path, uri.query, null) + uri = uri.buildUpon().scheme("https").build() titleColorModel.url.value = uri.toString() return null } - val supportsAuthenticate = HttpUtils.supportsAuthentication(uri.toUri()) + val supportsAuthenticate = HttpUtils.supportsAuthentication(uri) binding.credentials.visibility = if (supportsAuthenticate) View.VISIBLE else View.GONE when (uri.scheme?.lowercase()) { "content" -> { -- GitLab From 0b1443de38e0e32f147351ae36358c01af710236 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 21 Dec 2022 17:01:53 +0100 Subject: [PATCH 07/51] Code scanning (CodeQL) (#87) * Create codeql.yml * Don't use gradle daemons for testing * Update cert4android and ical4android; test workflow --- .github/workflows/codeql.yml | 72 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 2 +- .github/workflows/test-dev.yml | 17 ++++---- cert4android | 2 +- ical4android | 2 +- 5 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..cd1dd6a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,72 @@ +name: "CodeQL" + +on: + push: + branches: [ "dev", main ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "dev" ] + schedule: + - cron: '34 7 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + submodules: recursive + - uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: 11 + cache: 'gradle' + - uses: gradle/wrapper-validation-action@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + # - name: Autobuild + # uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + - name: Build + run: ./gradlew --no-daemon app:assembleStandardDebug + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a922fb..4b82868 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks - name: Build signed packages - run: ./gradlew app:assembleRelease + run: ./gradlew --no-daemon app:assembleRelease env: ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }} diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 4aa34cc..02ca715 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -16,7 +16,7 @@ jobs: - uses: gradle/wrapper-validation-action@v1 - name: Check - run: ./gradlew app:lintStandardDebug app:testStandardDebugUnitTest + run: ./gradlew --no-daemon app:lintStandardDebug app:testStandardDebugUnitTest - name: Archive results uses: actions/upload-artifact@v2 with: @@ -38,20 +38,17 @@ jobs: - uses: actions/checkout@v2 with: submodules: true - - uses: gradle/wrapper-validation-action@v1 - - - name: Cache gradle dependencies - uses: actions/cache@v2 + - uses: actions/setup-java@v2 with: - key: ${{ runner.os }}-1 - path: | - ~/.gradle/caches - ~/.gradle/wrapper + distribution: 'temurin' + java-version: 11 + cache: 'gradle' + - uses: gradle/wrapper-validation-action@v1 - name: Start emulator run: start-emulator.sh - name: Run connected tests - run: ./gradlew app:connectedStandardDebugAndroidTest + run: ./gradlew --no-daemon app:connectedStandardDebugAndroidTest - name: Archive results if: always() uses: actions/upload-artifact@v2 diff --git a/cert4android b/cert4android index b3e2810..fb66126 160000 --- a/cert4android +++ b/cert4android @@ -1 +1 @@ -Subproject commit b3e28100d7b349c360f3537f5856fc486bf73148 +Subproject commit fb66126278ea63419eb192c7d4620575814d51e7 diff --git a/ical4android b/ical4android index 86e0d7c..f4cb518 160000 --- a/ical4android +++ b/ical4android @@ -1 +1 @@ -Subproject commit 86e0d7ccf54caca48c361b2dd27199ecac5149f4 +Subproject commit f4cb518a08bdab97daab9e4d17887e1d504b87d1 -- GitLab From 5310af5f56eb062d89c0810e1b75b0fcdf63079d Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Tue, 3 Jan 2023 14:49:53 +0100 Subject: [PATCH 08/51] Fixed #97 (#98) Signed-off-by: Arnau Mora Signed-off-by: Arnau Mora --- app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt index 327775e..268c3b6 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt @@ -64,7 +64,7 @@ class InfoActivity: AppCompatActivity() { } private fun launchUri(uri: Uri) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/icsx5app")) + val intent = Intent(Intent.ACTION_VIEW, uri) try { startActivity(intent) } catch (e: ActivityNotFoundException) { -- GitLab From eb5a35ac30a52e5351aa4417c3a85d580aee0a1c Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 22 Jan 2023 20:56:46 +0100 Subject: [PATCH 09/51] Added dependent issues (#101) Signed-off-by: Arnau Mora Signed-off-by: Arnau Mora --- .github/workflows/dependent-issues.yml | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/dependent-issues.yml diff --git a/.github/workflows/dependent-issues.yml b/.github/workflows/dependent-issues.yml new file mode 100644 index 0000000..9e2244d --- /dev/null +++ b/.github/workflows/dependent-issues.yml @@ -0,0 +1,54 @@ +name: Dependent Issues + +on: + issues: + types: + - opened + - edited + - closed + - reopened + pull_request_target: + types: + - opened + - edited + - closed + - reopened + # Makes sure we always add status check for PRs. Useful only if + # this action is required to pass before merging. Otherwise, it + # can be removed. + - synchronize + + # Schedule a daily check. Useful if you reference cross-repository + # issues or pull requests. Otherwise, it can be removed. + schedule: + - cron: '12 9 * * *' + +permissions: write-all + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: z0al/dependent-issues@v1 + env: + # (Required) The token to use to make API calls to GitHub. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # (Optional) The token to use to make API calls to GitHub for remote repos. + GITHUB_READ_TOKEN: ${{ secrets.DEPENDENT_ISSUES_READ_TOKEN }} + + with: + # (Optional) The label to use to mark dependent issues + # label: dependent + + # (Optional) Enable checking for dependencies in issues. + # Enable by setting the value to "on". Default "off" + check_issues: on + + # (Optional) A comma-separated list of keywords. Default + # "depends on, blocked by" + keywords: depends on, blocked by + + # (Optional) A custom comment body. It supports `{{ dependencies }}` token. + comment: > + This PR/issue depends on: + {{ dependencies }} -- GitLab From d173133534d8dcd78417451b3f6865aadb555745 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 29 Jan 2023 19:19:21 +0100 Subject: [PATCH 10/51] Added translations page link (#104) Signed-off-by: Arnau Mora --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 6cbc0f7..c0fbe48 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,19 @@ News and updates: [@icsx5app](https://twitter.com/icsx5app) Help, discussion, ideas, bug reports: [ICSx⁵ forum](https://icsx5.bitfire.at/forums/) + + +Contributions +======= + We're happy about contributions! Just send a pull request for small changes or in case of bigger changes, please let us know in the forum before. +## Translations +ICSx⁵ is available [on Transifex](https://www.transifex.com/bitfireAT/icsx5/). There you can propose +changes to the translations, or create new ones. Feel free to suggest new languages, people will +love it. + License -- GitLab From bed0efe406c3849870f466324c4cbf3126316b7e Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Thu, 2 Feb 2023 21:38:31 +0100 Subject: [PATCH 11/51] Move subscriptions to database - Phase 1 (#105) * Added Room and ksp Signed-off-by: Arnau Mora * Added type converter for `Uri` Signed-off-by: Arnau Mora * Added Room declarations Signed-off-by: Arnau Mora * Deprecated `CalendarCredentials` Signed-off-by: Arnau Mora * Generated Room schema Signed-off-by: Arnau Mora * Removed unused method Signed-off-by: Arnau Mora * Made `username` and `password` non-null Signed-off-by: Arnau Mora * Removed `put` method Signed-off-by: Arnau Mora * Removed account storage Signed-off-by: Arnau Mora * Added foreign key Signed-off-by: Arnau Mora * Made `lastModified` and `lastSync` nullable Signed-off-by: Arnau Mora * Removed migration method Signed-off-by: Arnau Mora * Moved `getProvider` Signed-off-by: Arnau Mora * Removed delete method to be moved somewhere else Signed-off-by: Arnau Mora * Removed `equals` and `hashCode` since `Subscription` is a `data class` Signed-off-by: Arnau Mora * Removed dao alias Signed-off-by: Arnau Mora * Renamed `get` to `getBySubscriptionId` Signed-off-by: Arnau Mora * Renamed `pop` to `remove` Signed-off-by: Arnau Mora * Removed `LIMIT` from query Signed-off-by: Arnau Mora * Removed extensions Signed-off-by: Arnau Mora * Moved Android functions to `DatabaseAndroidInterface` Signed-off-by: Arnau Mora * Updated database schema Signed-off-by: Arnau Mora * Credential: don't accept LiveData --------- Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- app/build.gradle | 9 + .../at.bitfire.icsdroid.db.AppDatabase/1.json | 150 ++++++++++++++++ .../at/bitfire/icsdroid/db/AppDatabase.kt | 53 ++++++ .../icsdroid/db/CalendarCredentials.kt | 7 + .../java/at/bitfire/icsdroid/db/Converters.kt | 17 ++ .../icsdroid/db/DatabaseAndroidInterface.kt | 162 ++++++++++++++++++ .../bitfire/icsdroid/db/dao/CredentialsDao.kt | 50 ++++++ .../icsdroid/db/dao/SubscriptionsDao.kt | 132 ++++++++++++++ .../bitfire/icsdroid/db/entity/Credential.kt | 23 +++ .../icsdroid/db/entity/Subscription.kt | 134 +++++++++++++++ build.gradle | 5 +- settings.gradle | 5 + 12 files changed, 746 insertions(+), 1 deletion(-) create mode 100644 app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json create mode 100644 app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt create mode 100644 app/src/main/java/at/bitfire/icsdroid/db/Converters.kt create mode 100644 app/src/main/java/at/bitfire/icsdroid/db/DatabaseAndroidInterface.kt create mode 100644 app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt create mode 100644 app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt create mode 100644 app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt create mode 100644 app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt diff --git a/app/build.gradle b/app/build.gradle index f102c4d..60ac4d3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'com.mikepenz.aboutlibraries.plugin' +apply plugin: 'com.google.devtools.ksp' android { compileSdkVersion 33 @@ -24,6 +25,10 @@ android { def locales = getLocales() buildConfigField "String[]", "TRANSLATION_ARRAY", "new String[]{\""+locales.join("\",\"")+"\"}" resConfigs locales + + ksp { + arg("room.schemaLocation", "$projectDir/schemas".toString()) + } } compileOptions { @@ -179,6 +184,10 @@ dependencies { //noinspection GradleDependency implementation 'org.apache.commons:commons-lang3:3.8.1' + // Room Database + implementation "androidx.room:room-ktx:${versions.room}" + ksp "androidx.room:room-compiler:${versions.room}" + // for tests androidTestImplementation 'androidx.test:runner:1.5.0' androidTestImplementation "androidx.test:rules:1.5.0" diff --git a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json new file mode 100644 index 0000000..7d45284 --- /dev/null +++ b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json @@ -0,0 +1,150 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "f8ce1d0c55ab5f8ed12f7553d32158ed", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `syncEvents` INTEGER NOT NULL, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `color` INTEGER, `isSynced` INTEGER NOT NULL, `isVisible` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncEvents", + "columnName": "syncEvents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreEmbeddedAlerts", + "columnName": "ignoreEmbeddedAlerts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultAlarmMinutes", + "columnName": "defaultAlarmMinutes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVisible", + "columnName": "isVisible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`subscriptionId`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "subscriptionId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f8ce1d0c55ab5f8ed12f7553d32158ed')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt new file mode 100644 index 0000000..748d391 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt @@ -0,0 +1,53 @@ +package at.bitfire.icsdroid.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import at.bitfire.icsdroid.db.AppDatabase.Companion.getInstance +import at.bitfire.icsdroid.db.dao.CredentialsDao +import at.bitfire.icsdroid.db.dao.SubscriptionsDao +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription + +/** + * The database for storing all the ICSx5 subscriptions and other data. Use [getInstance] for getting access to the database. + */ +@TypeConverters(Converters::class) +@Database(entities = [Subscription::class, Credential::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + + companion object { + @Volatile + private var instance: AppDatabase? = null + + /** + * Gets or instantiates the database singleton. Thread-safe. + * @param context The application's context, required to create the database. + */ + fun getInstance(context: Context): AppDatabase { + // if we have an existing instance, return it + instance?.let { + return it + } + + // multiple threads might access this code at once, so synchronize it + synchronized(AppDatabase) { + // another thread might just have created an instance + instance?.let { + return it + } + + // create a new instance and save it + val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "icsx5").build() + instance = db + return db + } + } + } + + abstract fun subscriptionsDao(): SubscriptionsDao + + abstract fun credentialsDao(): CredentialsDao +} diff --git a/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt b/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt index 05a94ad..85e759e 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt @@ -6,6 +6,13 @@ package at.bitfire.icsdroid.db import android.content.Context +@Deprecated( + "Use Room's Credentials from database.", + replaceWith = ReplaceWith( + "CredentialsDao.getInstance(context)", + "at.bitfire.icsdroid.db.AppDatabase" + ), +) class CalendarCredentials(context: Context) { companion object { diff --git a/app/src/main/java/at/bitfire/icsdroid/db/Converters.kt b/app/src/main/java/at/bitfire/icsdroid/db/Converters.kt new file mode 100644 index 0000000..9a999bd --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/Converters.kt @@ -0,0 +1,17 @@ +package at.bitfire.icsdroid.db + +import android.net.Uri +import androidx.room.TypeConverter + +/** + * Provides converters for complex types in the Room DB. + */ +class Converters { + /** Converts an [Uri] to a [String]. */ + @TypeConverter + fun fromUri(value: Uri?): String? = value?.toString() + + /** Converts a [String] to an [Uri]. */ + @TypeConverter + fun toUri(value: String?): Uri? = value?.let { Uri.parse(it) } +} diff --git a/app/src/main/java/at/bitfire/icsdroid/db/DatabaseAndroidInterface.kt b/app/src/main/java/at/bitfire/icsdroid/db/DatabaseAndroidInterface.kt new file mode 100644 index 0000000..2bff6c8 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/DatabaseAndroidInterface.kt @@ -0,0 +1,162 @@ +package at.bitfire.icsdroid.db + +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.Context +import android.database.SQLException +import android.os.RemoteException +import android.provider.CalendarContract +import android.util.Log +import androidx.annotation.WorkerThread +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.CalendarStorageException +import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter +import at.bitfire.icsdroid.Constants +import at.bitfire.icsdroid.db.entity.Subscription +import java.io.FileNotFoundException + +/** + * Provides some utility functions for interacting between [Subscription]s and [LocalCalendar]s. + * @param context The context that will be making the movements. + */ +class DatabaseAndroidInterface( + private val context: Context, + private val subscription: Subscription, +) { + /** + * Gets the calendar provider for a given context. + * @param context The context that is making the request. + * @return The [ContentProviderClient] that provides an interface with the system's calendar. + */ + private fun getProvider(context: Context) = + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) + + /** + * Provides an [AndroidCalendar] from the current subscription. + * @param context The context that is making the request. + * @return A new calendar that matches the current subscription. + * @throws NullPointerException If a provider could not be obtained from the [context]. + * @throws FileNotFoundException If the calendar is not available in the system's database. + */ + fun getCalendar(context: Context) = AndroidCalendar.findByID( + Subscription.getAccount(context), + getProvider(context)!!, + LocalCalendar.Factory, + subscription.id, + ) + + /** + * Provides iCalendar event color values to Android. + * @throws IllegalArgumentException If a provider could not be obtained from the [context]. + * @throws SQLException If there's any issues while updating the system's database. + * @see AndroidCalendar.insertColors + */ + fun insertColors() = + (getProvider(context) + ?: throw IllegalArgumentException("A content provider client could not be obtained from the given context.")) + .let { provider -> AndroidCalendar.insertColors(provider, + Subscription.getAccount(context) + ) } + + /** + * Removes all events from the system's calendar whose uid is not included in the [uids] list. + * @param uids The uids to keep. + * @return The amount of events removed. + * @throws IllegalArgumentException If a provider could not be obtained from the [context]. + * @throws CalendarStorageException If there's an error while deleting an event. + */ + @WorkerThread + private fun androidRetainByUid(uids: Set): Int { + Log.v(Constants.TAG, "Removing all events whose uid is not in: $uids") + val provider = getProvider(context) + ?: throw IllegalArgumentException("A content provider client could not be obtained from the given context.") + var deleted = 0 + try { + val account = Subscription.getAccount(context) + provider.query( + CalendarContract.Events.CONTENT_URI.asSyncAdapter(account), + arrayOf(CalendarContract.Events._ID, CalendarContract.Events._SYNC_ID, CalendarContract.Events.ORIGINAL_SYNC_ID), + "${CalendarContract.Events.CALENDAR_ID}=? AND ${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL", + arrayOf(subscription.id.toString()), + null + )?.use { row -> + val mutableUids = uids.toMutableSet() + + while (row.moveToNext()) { + val eventId = row.getLong(0) + val syncId = row.getString(1) + if (!mutableUids.contains(syncId)) { + Log.v(Constants.TAG, "Removing event with id $syncId.") + provider.delete( + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) + .asSyncAdapter(account), null, null + ) + deleted++ + + mutableUids -= syncId + } + } + } + return deleted + } catch (e: RemoteException) { + Log.e(Constants.TAG, "Could not delete local events.", e) + throw CalendarStorageException("Couldn't delete local events") + } + } + + /** + * Queries an Android Event from the System's Calendar by its uid. + * @param uid The uid of the event. + * @throws FileNotFoundException If the subscription still not has a Calendar in the system. + * @throws NullPointerException If a provider could not be obtained from the [context]. + */ + fun queryAndroidEventByUid(uid: String) = + // Fetch the calendar instance for this subscription + getCalendar(context) + // Run a query with the UID given + .queryEvents("${CalendarContract.Events._SYNC_ID}=?", arrayOf(uid)) + // If no events are returned, just return null + .takeIf { it.isNotEmpty() } + // Since only one event should have the given uid, and we know the list is not + // empty, return the first element. + ?.first() + + /** + * Creates a calendar in the system that matches the subscription. + * @throws NullPointerException If the [context] given doesn't have a valid provider. + * @throws Exception If the calendar could not be created. + */ + @WorkerThread + fun createAndroidCalendar() = Subscription.getAccount(context).let { account -> + AndroidCalendar.create( + account, + getProvider(context)!!, + contentValuesOf( + CalendarContract.Calendars._ID to subscription.id, + CalendarContract.Calendars.ACCOUNT_NAME to account.name, + CalendarContract.Calendars.ACCOUNT_TYPE to account.type, + CalendarContract.Calendars.NAME to subscription.url.toString(), + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME to subscription.displayName, + CalendarContract.Calendars.CALENDAR_COLOR to subscription.color, + CalendarContract.Calendars.OWNER_ACCOUNT to account.name, + CalendarContract.Calendars.SYNC_EVENTS to if (subscription.syncEvents) 1 else 0, + CalendarContract.Calendars.VISIBLE to if (subscription.isVisible) 1 else 0, + CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL to CalendarContract.Calendars.CAL_ACCESS_READ, + ), + ) + } + + /** + * Deletes the Android calendar associated with this subscription. + * @return The number of rows affected, or null if the [context] given doesn't have a valid + * provider. + * @throws RemoteException If there's an error while making the request. + */ + @WorkerThread + fun deleteAndroidCalendar() = getProvider(context)?.delete( + CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(Subscription.getAccount(context)), + "${CalendarContract.Calendars._ID}=?", + arrayOf(subscription.id.toString()), + ) +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt b/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt new file mode 100644 index 0000000..47cd7f9 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt @@ -0,0 +1,50 @@ +package at.bitfire.icsdroid.db.dao + +import android.database.SQLException +import android.database.sqlite.SQLiteConstraintException +import androidx.annotation.WorkerThread +import androidx.room.Dao +import androidx.room.Query +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription + +/** + * Creates an interface with all the credentials stored in the database. + * @see AppDatabase + * @see Credential + */ +@Dao +interface CredentialsDao { + /** + * Gets all the credentials stored for the given subscription. + * @param subscriptionId The id of the subscription to get the credentials for. + * @return The [Credential] stored for the given [subscriptionId] or null if none. + * @throws SQLException If there's an error while fetching the credential. + */ + @WorkerThread + @Query("SELECT * FROM credentials WHERE subscriptionId=:subscriptionId") + fun getBySubscriptionId(subscriptionId: Long): Credential? + + /** + * Inserts a new credential into the table. + * @param subscriptionId The id ([Subscription.id]) of the parent subscription of the credential. + * @param username The username to use for the credential. + * @param password The password to use for the credential. + * @throws SQLException If there's an error while making the insert. + * @throws SQLiteConstraintException If there's already a credential for the given subscription. + */ + @WorkerThread + @Query("INSERT INTO credentials (subscriptionId, username, password) VALUES (:subscriptionId, :username, :password)") + fun put(subscriptionId: Long, username: String?, password: String?) + + /** + * Removes the credentials stored for the given subscription from the database. + * @param subscriptionId The id ([Subscription.id]) of the subscription that matches the stored + * credentials to be deleted. + * @throws SQLException If there's an error while making the deletion. + */ + @WorkerThread + @Query("DELETE FROM credentials WHERE subscriptionId=:subscriptionId") + fun remove(subscriptionId: Long) +} diff --git a/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt new file mode 100644 index 0000000..e92706b --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt @@ -0,0 +1,132 @@ +package at.bitfire.icsdroid.db.dao + +import android.content.Context +import android.database.SQLException +import androidx.annotation.WorkerThread +import androidx.lifecycle.LiveData +import androidx.room.* +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Subscription + +/** + * Creates an interface with all the subscriptions made in the database. + * @author Arnau Mora + * @see AppDatabase + */ +@Dao +interface SubscriptionsDao { + companion object { + /** + * Alias for [AppDatabase.getInstance] -> [AppDatabase.subscriptionsDao]. + * @param context The context that is requesting access to the dao. + */ + fun getInstance(context: Context) = AppDatabase.getInstance(context).subscriptionsDao() + } + + /** + * Adds one or more new subscriptions to the database. + * + * **This doesn't add the subscription to the system's calendar.** It's preferable to use + * [Subscription.create] adding new subscriptions. + * @author Arnau Mora + * @param subscriptions All the subscriptions to be added. + * @throws SQLException If any error occurs with the request. + */ + @Insert + @WorkerThread + fun add(vararg subscriptions: Subscription) + + /** + * Gets a [LiveData] with all the made subscriptions. Updates automatically when new ones are added. + * @author Arnau Mora + * @throws SQLException If any error occurs with the request. + */ + @Query("SELECT * FROM subscriptions") + fun getAllLive(): LiveData> + + /** + * Gets a list of all the made subscriptions. + * @author Arnau Mora + * @throws SQLException If any error occurs with the request. + */ + @WorkerThread + @Query("SELECT * FROM subscriptions") + fun getAll(): List + + /** + * Gets an specific [Subscription] by its id ([Subscription.id]). + * @author Arnau Mora + * @param id The id of the subscription to fetch. + * @return The [Subscription] indicated, or null if any. + * @throws SQLException If any error occurs with the request. + */ + @WorkerThread + @Query("SELECT * FROM subscriptions WHERE id=:id") + fun getById(id: Long): Subscription? + + /** + * Gets a [LiveData] that gets updated with the error message of the given subscription. + * @author Arnau Mora + * @param id The id of the subscription to get updates from. + * @throws SQLException If any error occurs with the request. + */ + @Query("SELECT errorMessage FROM subscriptions WHERE id=:id") + fun getErrorMessageLive(id: Long): LiveData + + /** + * Removes all the given subscriptions from the database. Doesn't remove the matching calendar + * from the system. Use of [Subscription.delete] is preferred. + * @author Arnau Mora + * @param subscriptions All the subscriptions to be removed. + * @throws SQLException If any error occurs with the update. + */ + @WorkerThread + @Delete + fun delete(vararg subscriptions: Subscription) + + /** + * Updates the given subscriptions in the database. + * @author Arnau Mora + * @param subscriptions All the subscriptions to be updated. + * @throws SQLException If any error occurs with the update. + */ + @WorkerThread + @Update + fun update(vararg subscriptions: Subscription) + + /** + * Updates the status of a subscription that has not been modified. This is updating its [Subscription.lastSync] to the current time. + * @author Arnau Mora + * @param id The id of the subscription to update. + * @param lastSync The synchronization time to set. Can be left as default, and will match the current system time. + * @throws SQLException If any error occurs with the update. + */ + @WorkerThread + @Query("UPDATE subscriptions SET lastSync=:lastSync WHERE id=:id") + fun updateStatusNotModified(id: Long, lastSync: Long = System.currentTimeMillis()) + + /** + * Updates the status of a subscription that has just been modified. This removes its [Subscription.errorMessage], and updates the [Subscription.eTag], + * [Subscription.lastModified] and [Subscription.lastSync]. + * @author Arnau Mora + * @param id The id of the subscription to update. + * @param eTag The new eTag to set. + * @param lastModified The new date to set for [Subscription.lastModified]. + * @param lastSync The last synchronization date to set. Defaults to the current system time, so can be skipped. + * @throws SQLException If any error occurs with the update. + */ + @WorkerThread + @Query("UPDATE subscriptions SET eTag=:eTag, lastModified=:lastModified, lastSync=:lastSync, errorMessage=null WHERE id=:id") + fun updateStatusSuccess(id: Long, eTag: String?, lastModified: Long, lastSync: Long = System.currentTimeMillis()) + + /** + * Updates the error message of the subscription. + * @author Arnau Mora + * @param id The id of the subscription to update. + * @param message The error message to give to the subscription. + * @throws SQLException If any error occurs with the update. + */ + @WorkerThread + @Query("UPDATE subscriptions SET errorMessage=:message WHERE id=:id") + fun updateStatusError(id: Long, message: String?) +} diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt new file mode 100644 index 0000000..9446492 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt @@ -0,0 +1,23 @@ +package at.bitfire.icsdroid.db.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +/** + * Stores the credentials to be used with a specific subscription. + * @param subscriptionId The id of the subscription that matches this credential. + * @param username The username of the credential. + * @param password The password of the credential. + */ +@Entity( + tableName = "credentials", + foreignKeys = [ + ForeignKey(entity = Subscription::class, parentColumns = ["id"], childColumns = ["subscriptionId"], onDelete = ForeignKey.CASCADE), + ], +) +data class Credential( + @PrimaryKey val subscriptionId: Long, + val username: String, + val password: String, +) \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt new file mode 100644 index 0000000..1878235 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt @@ -0,0 +1,134 @@ +package at.bitfire.icsdroid.db.entity + +import android.accounts.Account +import android.content.Context +import android.database.SQLException +import android.net.Uri +import androidx.annotation.ColorInt +import androidx.annotation.WorkerThread +import androidx.room.Entity +import androidx.room.PrimaryKey +import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.db.AppDatabase +import java.net.MalformedURLException + +/** + * Represents the storage of a subscription the user has made. + * @param id The id of the subscription in the database. + * @param url URL of iCalendar file + * @param eTag iCalendar ETag at last successful sync + * @param displayName Display name of the subscription + * @param lastModified iCalendar Last-Modified at last successful sync (or 0 for none) + * @param lastSync time of last sync (0 if none) + * @param errorMessage error message (HTTP status or exception name) of last sync (or null) + * @param ignoreEmbeddedAlerts Setting: whether to ignore alarms embedded in the Webcal + * @param defaultAlarmMinutes Setting: Shall a default alarm be added to every event in the calendar? If yes, this field contains the minutes before the event. + * If no, it is `null`. + * @param color The color that represents the subscription. + */ +@Entity(tableName = "subscriptions") +data class Subscription( + @PrimaryKey val id: Long = 0L, + val url: Uri, + val eTag: String? = null, + + val displayName: String, + + val lastModified: Long? = null, + val lastSync: Long? = null, + val syncEvents: Boolean = false, + val errorMessage: String? = null, + + val ignoreEmbeddedAlerts: Boolean = false, + val defaultAlarmMinutes: Long? = null, + + val color: Int? = null, + + val isSynced: Boolean = true, + val isVisible: Boolean = true, +) { + companion object { + /** + * The default color to use in all subscriptions. + */ + @ColorInt + const val DEFAULT_COLOR = 0xFF2F80C7.toInt() + + /** Gets the account to be used for the subscriptions. */ + fun getAccount(context: Context) = Account( + context.getString(R.string.account_type), + context.getString(R.string.account_name), + ) + } + + /** + * Updates the status of a subscription that has not been modified. This is updating its [Subscription.lastSync] to the current time. + * @param context The context that is making the request. + * @param lastSync The synchronization time to set. Can be left as default, and will match the current system time. + * @throws SQLException If any error occurs with the update. + */ + @WorkerThread + fun updateStatusNotModified( + context: Context, + lastSync: Long = System.currentTimeMillis() + ) = + AppDatabase.getInstance(context) + .subscriptionsDao() + .updateStatusNotModified(id, lastSync) + + /** + * Updates the status of a subscription that has just been modified. This removes its [Subscription.errorMessage], and updates the [Subscription.eTag], + * [Subscription.lastModified] and [Subscription.lastSync]. + * @param context The context that is making the request. + * @param eTag The new eTag to set. + * @param lastModified The new date to set for [Subscription.lastModified]. + * @param lastSync The last synchronization date to set. Defaults to the current system time, so can be skipped. + * @throws SQLException If any error occurs with the update. + */ + @WorkerThread + fun updateStatusSuccess( + context: Context, + eTag: String? = this.eTag, + lastModified: Long? = this.lastModified, + lastSync: Long = System.currentTimeMillis() + ) = AppDatabase.getInstance(context) + .subscriptionsDao() + .updateStatusSuccess(id, eTag, lastModified ?: 0L, lastSync) + + /** + * Updates the error message of the subscription. + * @param context The context that is making the request. + * @param message The error message to give to the subscription. + * @throws SQLException If any error occurs with the update. + */ + @WorkerThread + fun updateStatusError(context: Context, message: String?) = + AppDatabase.getInstance(context) + .subscriptionsDao() + .updateStatusError(id, message) + + /** + * Updates the [Subscription.url] field to the given one. + * @param context The context that is making the request. + * @param url The new url to set. + * @throws SQLException If any error occurs with the update. + */ + @WorkerThread + fun updateUrl(context: Context, url: Uri) = + AppDatabase.getInstance(context) + .subscriptionsDao() + .update( + copy(url = url) + ) + + /** + * Updates the [Subscription.url] field to the given one. + * @param context The context that is making the request. + * @param url The new url to set. + * @throws SQLException If any error occurs with the update. + * @throws MalformedURLException If the given [url] cannot be parsed to [Uri]. + */ + @WorkerThread + fun updateUrl(context: Context, url: String) = updateUrl(context, Uri.parse(url)) + +} diff --git a/build.gradle b/build.gradle index 0dbdcc9..6542c0a 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,9 @@ buildscript { ext.versions = [ aboutLibs: '8.9.4', kotlin: '1.7.20', - okhttp: '5.0.0-alpha.10' + okhttp: '5.0.0-alpha.10', + ksp: '1.0.7', + room: '2.4.3' ] repositories { @@ -16,6 +18,7 @@ buildscript { classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:${versions.aboutLibs}" + classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${versions.kotlin}-${versions.ksp}" } } diff --git a/settings.gradle b/settings.gradle index 15bc481..c1aa803 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,7 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} include ':app', ':cert4android', ':ical4android' -- GitLab From a3e8c31f2170afd38a31e3d8cb65a8477ade46ab Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sat, 11 Feb 2023 12:41:43 +0100 Subject: [PATCH 12/51] Move subscriptions to database - Phase 2 (#107) * Added custom account passing Signed-off-by: Arnau Mora * Context already given in class constructor Signed-off-by: Arnau Mora * Migrated to new database Signed-off-by: Arnau Mora * Added tests Signed-off-by: Arnau Mora * Added `AppDatabase.setInstance` Signed-off-by: Arnau Mora * Added work testing Signed-off-by: Arnau Mora * Removed async calls Signed-off-by: Arnau Mora * Updated `getAccount` Signed-off-by: Arnau Mora * Added testing account updater Signed-off-by: Arnau Mora * Changed order by naming Signed-off-by: Arnau Mora * Removed all account-related things Signed-off-by: Arnau Mora * Added copyright notice Signed-off-by: Arnau Mora * Refactor Signed-off-by: Arnau Mora * Extended KTDoc Signed-off-by: Arnau Mora * Removed `InitCalendarProviderRule` Signed-off-by: Arnau Mora * [WIP] Rewrite migration to DB --------- Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- app/build.gradle | 1 + .../at.bitfire.icsdroid.db.AppDatabase/1.json | 24 +-- .../migration/CalendarToRoomMigrationTest.kt | 149 ++++++++++++++++ .../at/bitfire/icsdroid/ProcessEventsTask.kt | 119 +++++++------ .../java/at/bitfire/icsdroid/SyncWorker.kt | 138 +++++++++++---- .../at/bitfire/icsdroid/db/AppDatabase.kt | 20 ++- .../icsdroid/db/DatabaseAndroidInterface.kt | 162 ------------------ .../at/bitfire/icsdroid/db/LocalCalendar.kt | 75 ++------ .../bitfire/icsdroid/db/dao/CredentialsDao.kt | 24 +-- .../icsdroid/db/dao/SubscriptionsDao.kt | 108 ++---------- .../bitfire/icsdroid/db/entity/Credential.kt | 5 +- .../icsdroid/db/entity/Subscription.kt | 145 ++++++---------- .../bitfire/icsdroid/ui/TitleColorFragment.kt | 1 - 13 files changed, 424 insertions(+), 547 deletions(-) create mode 100644 app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt delete mode 100644 app/src/main/java/at/bitfire/icsdroid/db/DatabaseAndroidInterface.kt diff --git a/app/build.gradle b/app/build.gradle index 60ac4d3..1b9690a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -194,6 +194,7 @@ dependencies { androidTestImplementation "androidx.arch.core:core-testing:2.1.0" androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" + androidTestImplementation "androidx.work:work-testing:2.7.1" testImplementation 'junit:junit:4.13.2' } diff --git a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json index 7d45284..f584575 100644 --- a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json +++ b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "f8ce1d0c55ab5f8ed12f7553d32158ed", + "identityHash": "3d3efde41926a4c941fe31fe482723d6", "entities": [ { "tableName": "subscriptions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `syncEvents` INTEGER NOT NULL, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `color` INTEGER, `isSynced` INTEGER NOT NULL, `isVisible` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `color` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -44,12 +44,6 @@ "affinity": "INTEGER", "notNull": false }, - { - "fieldPath": "syncEvents", - "columnName": "syncEvents", - "affinity": "INTEGER", - "notNull": true - }, { "fieldPath": "errorMessage", "columnName": "errorMessage", @@ -73,18 +67,6 @@ "columnName": "color", "affinity": "INTEGER", "notNull": false - }, - { - "fieldPath": "isSynced", - "columnName": "isSynced", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isVisible", - "columnName": "isVisible", - "affinity": "INTEGER", - "notNull": true } ], "primaryKey": { @@ -144,7 +126,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f8ce1d0c55ab5f8ed12f7553d32158ed')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3d3efde41926a4c941fe31fe482723d6')" ] } } \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt b/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt new file mode 100644 index 0000000..e8402da --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt @@ -0,0 +1,149 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.migration + +import android.Manifest +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.CalendarContract.Calendars +import android.util.Log +import androidx.core.content.contentValuesOf +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.work.Configuration +import androidx.work.ListenableWorker.Result +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.testing.WorkManagerTestInitHelper +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.icsdroid.AppAccount +import at.bitfire.icsdroid.SyncWorker +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.CalendarCredentials +import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.db.dao.CredentialsDao +import at.bitfire.icsdroid.db.dao.SubscriptionsDao +import kotlinx.coroutines.runBlocking +import org.junit.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull + +class CalendarToRoomMigrationTest { + + companion object { + @JvmField + @ClassRule + val calendarPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR, + ) + + val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext + private lateinit var provider: ContentProviderClient + + @BeforeClass + @JvmStatic + fun setUpProvider() { + provider = LocalCalendar.getCalendarProvider(appContext) + } + + @AfterClass + @JvmStatic + fun closeProvider() { + provider.closeCompat() + } + + const val CALENDAR_DISPLAY_NAME = "Some subscription" + const val CALENDAR_URL = "https://example.com/test.ics" + const val CALENDAR_USERNAME = "someUser" + const val CALENDAR_PASSWORD = "somePassword" + + } + + /** Provides an in-memory interface to the app's database */ + private lateinit var db: AppDatabase + private lateinit var credentialsDao: CredentialsDao + private lateinit var subscriptionsDao: SubscriptionsDao + + // Initialize the test WorkManager for scheduling workers + @Before + fun prepareWorkManager() { + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(appContext, config) + } + + // Initialize the Room database + @Before + fun prepareDatabase() { + assertNotNull(appContext) + + db = Room.inMemoryDatabaseBuilder(appContext, AppDatabase::class.java).build() + credentialsDao = db.credentialsDao() + subscriptionsDao = db.subscriptionsDao() + + AppDatabase.setInstance(db) + } + + + private fun createCalendar(): LocalCalendar { + val account = AppAccount.get(appContext) + val uri = AndroidCalendar.create( + account, + provider, + contentValuesOf( + Calendars.CALENDAR_DISPLAY_NAME to CALENDAR_DISPLAY_NAME, + Calendars.NAME to CALENDAR_URL + ) + ) + + val calendar = AndroidCalendar.findByID( + account, + provider, + LocalCalendar.Factory, + ContentUris.parseId(uri) + ) + + // associate credentials, too + CalendarCredentials(appContext).put(calendar, CALENDAR_USERNAME, CALENDAR_PASSWORD) + + return calendar + } + + @Test + fun testSubscriptionCreated() { + val worker = TestListenableWorkerBuilder( + context = appContext + ).build() + + val calendar = createCalendar() + try { + runBlocking { + val result = worker.doWork() + assertEquals(result, Result.success()) + + val subscription = subscriptionsDao.getAll().first() + // check that the calendar has been added to the subscriptions list + assertEquals(calendar.id, subscription.id) + assertEquals(CALENDAR_DISPLAY_NAME, subscription.displayName) + assertEquals(Uri.parse(CALENDAR_URL), subscription.url) + + // check credentials, too + val credentials = credentialsDao.getBySubscriptionId(subscription.id) + assertEquals(CALENDAR_USERNAME, credentials?.username) + assertEquals(CALENDAR_PASSWORD, credentials?.password) + } + } finally { + calendar.delete() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt index b46f50e..59ac9f9 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt @@ -12,23 +12,21 @@ import android.net.Uri import android.provider.CalendarContract import android.util.Log import androidx.core.app.NotificationCompat -import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.Event -import at.bitfire.icsdroid.db.CalendarCredentials +import at.bitfire.icsdroid.db.AppDatabase import at.bitfire.icsdroid.db.LocalCalendar import at.bitfire.icsdroid.db.LocalEvent +import at.bitfire.icsdroid.db.entity.Subscription import at.bitfire.icsdroid.ui.EditCalendarActivity import at.bitfire.icsdroid.ui.NotificationUtils import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.property.Action -import net.fortuna.ical4j.model.property.Description import net.fortuna.ical4j.model.property.Trigger import okhttp3.MediaType import java.io.InputStream import java.io.InputStreamReader -import java.net.MalformedURLException import java.time.Duration /** @@ -41,45 +39,47 @@ import java.time.Duration * - for updating the local events (will only be updated when LAST-MODIFIED is newer). * * @param context context to work in - * @param calendar represents the subscription to be checked + * @param subscription represents the subscription to be checked * @param forceResync enforces that the calendar is fetched and all events are fully processed * (useful when subscription settings have been changed) */ class ProcessEventsTask( val context: Context, + val subscription: Subscription, val calendar: LocalCalendar, val forceResync: Boolean ) { + private val db = AppDatabase.getInstance(context) + private val subscriptionsDao = db.subscriptionsDao() + suspend fun sync() { Thread.currentThread().contextClassLoader = context.classLoader try { - // provide iCalendar event color values to Android - AndroidCalendar.insertColors(calendar.provider, calendar.account) - processEvents() - } catch(e: Exception) { + } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't sync calendar", e) - calendar.updateStatusError(e.localizedMessage ?: e.toString()) + subscriptionsDao.updateStatusError(subscription.id, e.localizedMessage ?: e.toString()) } Log.i(Constants.TAG, "iCalendar file completely processed") } /** - * Updates the alarms of the given event according to the [calendar]'s [LocalCalendar.defaultAlarmMinutes] and [LocalCalendar.ignoreEmbeddedAlerts] + * Updates the alarms of the given event according to the [subscription]'s + * [Subscription.defaultAlarmMinutes] and [Subscription.ignoreEmbeddedAlerts] * parameters. * @since 20221208 * @param event The event to update. * @return The given [event], with the alarms updated. */ private fun updateAlarms(event: Event): Event = event.apply { - if (calendar.ignoreEmbeddedAlerts == true) { + if (subscription.ignoreEmbeddedAlerts) { // Remove all alerts Log.d(Constants.TAG, "Removing all alarms from ${uid}: $this") alarms.clear() } - calendar.defaultAlarmMinutes?.let { minutes -> + subscription.defaultAlarmMinutes?.let { minutes -> // Check if already added alarm val alarm = alarms.find { it.description.value.contains("*added by ICSx5") } if (alarm != null) return@let @@ -102,30 +102,29 @@ class ProcessEventsTask( } private suspend fun processEvents() { - val uri = - try { - Uri.parse(calendar.url) - } catch(e: MalformedURLException) { - Log.e(Constants.TAG, "Invalid calendar URL", e) - calendar.updateStatusError(e.localizedMessage ?: e.toString()) - return - } + val uri = subscription.url Log.i(Constants.TAG, "Synchronizing $uri, forceResync=$forceResync") // dismiss old notifications val notificationManager = NotificationUtils.createChannels(context) - notificationManager.cancel(calendar.id.toString(), 0) + notificationManager.cancel(subscription.id.toString(), 0) var exception: Throwable? = null - val downloader = object: CalendarFetcher(context, uri) { - override fun onSuccess(data: InputStream, contentType: MediaType?, eTag: String?, lastModified: Long?, displayName: String?) { + val downloader = object : CalendarFetcher(context, uri) { + override fun onSuccess( + data: InputStream, + contentType: MediaType?, + eTag: String?, + lastModified: Long?, + displayName: String? + ) { InputStreamReader(data, contentType?.charset() ?: Charsets.UTF_8).use { reader -> try { val events = Event.eventsFromReader(reader) processEvents(events, forceResync) Log.i(Constants.TAG, "Calendar sync successful, ETag=$eTag, lastModified=$lastModified") - calendar.updateStatusSuccess(eTag, lastModified ?: 0L) + subscriptionsDao.updateStatusSuccess(subscription.id, eTag, lastModified) } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't process events", e) exception = e @@ -135,30 +134,35 @@ class ProcessEventsTask( override fun onNotModified() { Log.i(Constants.TAG, "Calendar has not been modified since last sync") - calendar.updateStatusNotModified() + subscriptionsDao.updateStatusNotModified(subscription.id) } override fun onNewPermanentUrl(target: Uri) { super.onNewPermanentUrl(target) Log.i(Constants.TAG, "Got permanent redirect, saving new URL: $target") - calendar.updateUrl(target.toString()) + subscriptionsDao.updateUrl(subscription.id, target) } override fun onError(error: Exception) { Log.w(Constants.TAG, "Sync error", error) exception = error } - } - CalendarCredentials(context).get(calendar).let { (username, password) -> - downloader.username = username - downloader.password = password } - if (calendar.eTag != null && !forceResync) - downloader.ifNoneMatch = calendar.eTag - if (calendar.lastModified != 0L && !forceResync) - downloader.ifModifiedSince = calendar.lastModified + // Get the credentials for the given subscription from the database + AppDatabase.getInstance(context) + .credentialsDao() + .getBySubscriptionId(subscription.id) + ?.let { (_, username, password) -> + downloader.username = username + downloader.password = password + } + + if (subscription.eTag != null && !forceResync) + downloader.ifNoneMatch = subscription.eTag + if (subscription.lastModified != 0L && !forceResync) + downloader.ifModifiedSince = subscription.lastModified downloader.fetch() @@ -166,30 +170,41 @@ class ProcessEventsTask( val message = ex.localizedMessage ?: ex.message ?: ex.toString() val errorIntent = Intent(context, EditCalendarActivity::class.java) - errorIntent.data = ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendar.id) + errorIntent.data = + ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, subscription.id) errorIntent.putExtra(EditCalendarActivity.ERROR_MESSAGE, message) errorIntent.putExtra(EditCalendarActivity.THROWABLE, ex) val notification = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC) - .setSmallIcon(R.drawable.ic_sync_problem_white) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .setGroup(context.getString(R.string.app_name)) - .setContentTitle(context.getString(R.string.sync_error_title)) - .setContentText(message) - .setSubText(calendar.displayName) - .setContentIntent(PendingIntent.getActivity(context, 0, errorIntent, PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat)) - .setAutoCancel(true) - .setWhen(System.currentTimeMillis()) - .setOnlyAlertOnce(true) - calendar.color?.let { notification.color = it } - notificationManager.notify(calendar.id.toString(), 0, notification.build()) - - calendar.updateStatusError(message) + .setSmallIcon(R.drawable.ic_sync_problem_white) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setGroup(context.getString(R.string.app_name)) + .setContentTitle(context.getString(R.string.sync_error_title)) + .setContentText(message) + .setSubText(subscription.displayName) + .setContentIntent( + PendingIntent.getActivity( + context, + 0, + errorIntent, + PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat + ) + ) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()) + .setOnlyAlertOnce(true) + subscription.color?.let { notification.color = it } + notificationManager.notify(subscription.id.toString(), 0, notification.build()) + + subscriptionsDao.updateStatusError(subscription.id, message) } } private fun processEvents(events: List, ignoreLastModified: Boolean) { - Log.i(Constants.TAG, "Processing ${events.size} events (ignoreLastModified=$ignoreLastModified)") + Log.i( + Constants.TAG, + "Processing ${events.size} events (ignoreLastModified=$ignoreLastModified)" + ) val uids = HashSet(events.size) for (ev in events) { @@ -202,9 +217,9 @@ class ProcessEventsTask( if (localEvents.isEmpty()) { Log.d(Constants.TAG, "$uid not in local calendar, adding") LocalEvent(calendar, event).add() - } else { val localEvent = localEvents.first() + var lastModified = if (ignoreLastModified) null else event.lastModified Log.d(Constants.TAG, "$uid already in local calendar, lastModified = $lastModified") diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt index a498efe..410605c 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt @@ -4,30 +4,34 @@ package at.bitfire.icsdroid -import android.accounts.Account -import android.annotation.SuppressLint import android.content.ContentProviderClient import android.content.Context -import android.provider.CalendarContract import android.util.Log import androidx.work.* -import at.bitfire.ical4android.CalendarStorageException +import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.icsdroid.Constants.TAG +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.CalendarCredentials import at.bitfire.icsdroid.db.LocalCalendar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription class SyncWorker( - context: Context, - workerParams: WorkerParameters + context: Context, + workerParams: WorkerParameters ): CoroutineWorker(context, workerParams) { companion object { + /** The name of the worker. Tags the unique work. */ const val NAME = "SyncWorker" - const val FORCE_RESYNC = "forceResync" - + /** + * An input data for the Worker that tells whether the synchronization should be performed + * without taking into account the current network condition. + */ + private const val FORCE_RESYNC = "forceResync" /** * Enqueues a sync job for immediate execution. If the sync is forced, @@ -42,7 +46,7 @@ class SyncWorker( .setInputData(workDataOf(FORCE_RESYNC to forceResync)) val policy: ExistingWorkPolicy = if (force) { - Log.i(Constants.TAG, "Manual sync, ignoring network condition") + Log.i(TAG, "Manual sync, ignoring network condition") // overwrite existing syncs (which may have unwanted constraints) ExistingWorkPolicy.REPLACE @@ -62,40 +66,106 @@ class SyncWorker( } fun liveStatus(context: Context) = - WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(NAME) + WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(NAME) } + private val database = AppDatabase.getInstance(applicationContext) + private val subscriptionsDao = database.subscriptionsDao() + private val credentialsDao = database.credentialsDao() + + val account = AppAccount.get(applicationContext) + lateinit var provider: ContentProviderClient + + private var forceReSync: Boolean = false - @SuppressLint("Recycle") override suspend fun doWork(): Result { - val forceResync = inputData.getBoolean(FORCE_RESYNC, false) - applicationContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { providerClient -> - try { - return withContext(Dispatchers.Default) { - performSync(AppAccount.get(applicationContext), providerClient, forceResync) - } - } finally { - providerClient.closeCompat() - } - } - return Result.failure() - } + forceReSync = inputData.getBoolean(FORCE_RESYNC, false) + Log.i(TAG, "Synchronizing (forceReSync=$forceReSync)") - private suspend fun performSync(account: Account, provider: ContentProviderClient, forceResync: Boolean): Result { - Log.i(Constants.TAG, "Synchronizing ${account.name} (forceResync=$forceResync)") + provider = LocalCalendar.getCalendarProvider(applicationContext) try { - LocalCalendar.findAll(account, provider) - .filter { it.isSynced } - .forEach { ProcessEventsTask(applicationContext, it, forceResync).sync() } + // migrate old calendar-based subscriptions to database + migrateLegacyCalendars() + + // update local calendars according to the subscriptions + updateLocalCalendars() - } catch (e: CalendarStorageException) { - Log.e(Constants.TAG, "Calendar storage exception", e) + // provide iCalendar event color values to Android + val account = AppAccount.get(applicationContext) + AndroidCalendar.insertColors(provider, account) + + // sync local calendars + for (subscription in subscriptionsDao.getAll()) { + val calendar = LocalCalendar.findById(account, provider, subscription.id) + ProcessEventsTask(applicationContext, subscription, calendar, forceReSync).sync() + } } catch (e: InterruptedException) { - Log.e(Constants.TAG, "Thread interrupted", e) + Log.e(TAG, "Thread interrupted", e) + return Result.retry() + } finally { + provider.closeCompat() } return Result.success() } -} + /** + * Migrates all the legacy calendar-based subscriptions to the database. Performs these steps: + * + * 1. Searches for all the calendars created + * 2. Checks that those calendars have a matching [Subscription] in the database. + * 3. If there's no matching [Subscription], create it. + */ + private fun migrateLegacyCalendars() { + val legacyCredentials = CalendarCredentials(applicationContext) + + // if there's a provider available, get all the calendars available in the system + for (calendar in LocalCalendar.findAll(account, provider)) { + val match = subscriptionsDao.getById(calendar.id) + if (match == null) { + // still no subscription for this calendar ID, create one (= migration) + val newSubscription = Subscription.fromLegacyCalendar(calendar) + subscriptionsDao.add(newSubscription) + Log.i(TAG, "The calendar #${calendar.id} didn't have a matching subscription. Just created it.") + + // migrate credentials, too (if available) + val (legacyUsername, legacyPassword) = legacyCredentials.get(calendar) + if (legacyUsername != null && legacyPassword != null) + credentialsDao.create(Credential( + newSubscription.id, legacyUsername, legacyPassword + )) + } + } + } + + /** + * Updates the local calendars according to the available [Subscription]s. A local calendar is + * + * - created if there's a [Subscription] without calendar, + * - updated (e.g. display name) if there's a [Subscription] for this calendar, + * - deleted if there's no [Subscription] for this calendar. + */ + private fun updateLocalCalendars() { + val subscriptions = subscriptionsDao.getAll() + val calendars = LocalCalendar.findAll(account, provider).associateBy { it.id }.toMutableMap() + + for (subscription in subscriptions) { + val calendar = calendars.remove(subscription.id) + if (calendar != null) { + Log.d(Constants.TAG, "Updating local calendar #${calendar.id} from subscription") + calendar.update(subscription.toCalendarProperties()) + } else { + Log.d(Constants.TAG, "Creating local calendar from subscription #${subscription.id}") + AndroidCalendar.create(account, provider, subscription.toCalendarProperties()) + } + } + + // remove remaining calendars + for (calendar in calendars.values) { + Log.d(Constants.TAG, "Removing local calendar #${calendar.id} without subscription") + calendar.delete() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt index 748d391..ee20674 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt @@ -1,6 +1,11 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.icsdroid.db import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase @@ -22,6 +27,15 @@ abstract class AppDatabase : RoomDatabase() { @Volatile private var instance: AppDatabase? = null + /** + * This function is only intended to be used by tests, use [getInstance], it initializes + * the instance automatically. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun setInstance(instance: AppDatabase?) { + this.instance = instance + } + /** * Gets or instantiates the database singleton. Thread-safe. * @param context The application's context, required to create the database. @@ -40,7 +54,9 @@ abstract class AppDatabase : RoomDatabase() { } // create a new instance and save it - val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "icsx5").build() + val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "icsx5") + .fallbackToDestructiveMigration() + .build() instance = db return db } @@ -48,6 +64,6 @@ abstract class AppDatabase : RoomDatabase() { } abstract fun subscriptionsDao(): SubscriptionsDao - abstract fun credentialsDao(): CredentialsDao + } diff --git a/app/src/main/java/at/bitfire/icsdroid/db/DatabaseAndroidInterface.kt b/app/src/main/java/at/bitfire/icsdroid/db/DatabaseAndroidInterface.kt deleted file mode 100644 index 2bff6c8..0000000 --- a/app/src/main/java/at/bitfire/icsdroid/db/DatabaseAndroidInterface.kt +++ /dev/null @@ -1,162 +0,0 @@ -package at.bitfire.icsdroid.db - -import android.content.ContentProviderClient -import android.content.ContentUris -import android.content.Context -import android.database.SQLException -import android.os.RemoteException -import android.provider.CalendarContract -import android.util.Log -import androidx.annotation.WorkerThread -import androidx.core.content.contentValuesOf -import at.bitfire.ical4android.AndroidCalendar -import at.bitfire.ical4android.CalendarStorageException -import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter -import at.bitfire.icsdroid.Constants -import at.bitfire.icsdroid.db.entity.Subscription -import java.io.FileNotFoundException - -/** - * Provides some utility functions for interacting between [Subscription]s and [LocalCalendar]s. - * @param context The context that will be making the movements. - */ -class DatabaseAndroidInterface( - private val context: Context, - private val subscription: Subscription, -) { - /** - * Gets the calendar provider for a given context. - * @param context The context that is making the request. - * @return The [ContentProviderClient] that provides an interface with the system's calendar. - */ - private fun getProvider(context: Context) = - context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) - - /** - * Provides an [AndroidCalendar] from the current subscription. - * @param context The context that is making the request. - * @return A new calendar that matches the current subscription. - * @throws NullPointerException If a provider could not be obtained from the [context]. - * @throws FileNotFoundException If the calendar is not available in the system's database. - */ - fun getCalendar(context: Context) = AndroidCalendar.findByID( - Subscription.getAccount(context), - getProvider(context)!!, - LocalCalendar.Factory, - subscription.id, - ) - - /** - * Provides iCalendar event color values to Android. - * @throws IllegalArgumentException If a provider could not be obtained from the [context]. - * @throws SQLException If there's any issues while updating the system's database. - * @see AndroidCalendar.insertColors - */ - fun insertColors() = - (getProvider(context) - ?: throw IllegalArgumentException("A content provider client could not be obtained from the given context.")) - .let { provider -> AndroidCalendar.insertColors(provider, - Subscription.getAccount(context) - ) } - - /** - * Removes all events from the system's calendar whose uid is not included in the [uids] list. - * @param uids The uids to keep. - * @return The amount of events removed. - * @throws IllegalArgumentException If a provider could not be obtained from the [context]. - * @throws CalendarStorageException If there's an error while deleting an event. - */ - @WorkerThread - private fun androidRetainByUid(uids: Set): Int { - Log.v(Constants.TAG, "Removing all events whose uid is not in: $uids") - val provider = getProvider(context) - ?: throw IllegalArgumentException("A content provider client could not be obtained from the given context.") - var deleted = 0 - try { - val account = Subscription.getAccount(context) - provider.query( - CalendarContract.Events.CONTENT_URI.asSyncAdapter(account), - arrayOf(CalendarContract.Events._ID, CalendarContract.Events._SYNC_ID, CalendarContract.Events.ORIGINAL_SYNC_ID), - "${CalendarContract.Events.CALENDAR_ID}=? AND ${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL", - arrayOf(subscription.id.toString()), - null - )?.use { row -> - val mutableUids = uids.toMutableSet() - - while (row.moveToNext()) { - val eventId = row.getLong(0) - val syncId = row.getString(1) - if (!mutableUids.contains(syncId)) { - Log.v(Constants.TAG, "Removing event with id $syncId.") - provider.delete( - ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) - .asSyncAdapter(account), null, null - ) - deleted++ - - mutableUids -= syncId - } - } - } - return deleted - } catch (e: RemoteException) { - Log.e(Constants.TAG, "Could not delete local events.", e) - throw CalendarStorageException("Couldn't delete local events") - } - } - - /** - * Queries an Android Event from the System's Calendar by its uid. - * @param uid The uid of the event. - * @throws FileNotFoundException If the subscription still not has a Calendar in the system. - * @throws NullPointerException If a provider could not be obtained from the [context]. - */ - fun queryAndroidEventByUid(uid: String) = - // Fetch the calendar instance for this subscription - getCalendar(context) - // Run a query with the UID given - .queryEvents("${CalendarContract.Events._SYNC_ID}=?", arrayOf(uid)) - // If no events are returned, just return null - .takeIf { it.isNotEmpty() } - // Since only one event should have the given uid, and we know the list is not - // empty, return the first element. - ?.first() - - /** - * Creates a calendar in the system that matches the subscription. - * @throws NullPointerException If the [context] given doesn't have a valid provider. - * @throws Exception If the calendar could not be created. - */ - @WorkerThread - fun createAndroidCalendar() = Subscription.getAccount(context).let { account -> - AndroidCalendar.create( - account, - getProvider(context)!!, - contentValuesOf( - CalendarContract.Calendars._ID to subscription.id, - CalendarContract.Calendars.ACCOUNT_NAME to account.name, - CalendarContract.Calendars.ACCOUNT_TYPE to account.type, - CalendarContract.Calendars.NAME to subscription.url.toString(), - CalendarContract.Calendars.CALENDAR_DISPLAY_NAME to subscription.displayName, - CalendarContract.Calendars.CALENDAR_COLOR to subscription.color, - CalendarContract.Calendars.OWNER_ACCOUNT to account.name, - CalendarContract.Calendars.SYNC_EVENTS to if (subscription.syncEvents) 1 else 0, - CalendarContract.Calendars.VISIBLE to if (subscription.isVisible) 1 else 0, - CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL to CalendarContract.Calendars.CAL_ACCESS_READ, - ), - ) - } - - /** - * Deletes the Android calendar associated with this subscription. - * @return The number of rows affected, or null if the [context] given doesn't have a valid - * provider. - * @throws RemoteException If there's an error while making the request. - */ - @WorkerThread - fun deleteAndroidCalendar() = getProvider(context)?.delete( - CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(Subscription.getAccount(context)), - "${CalendarContract.Calendars._ID}=?", - arrayOf(subscription.id.toString()), - ) -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt b/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt index 3ae256b..d898733 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt @@ -8,22 +8,15 @@ import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentUris import android.content.ContentValues +import android.content.Context import android.os.RemoteException +import android.provider.CalendarContract import android.provider.CalendarContract.Calendars import android.provider.CalendarContract.Events -import android.util.Log import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.AndroidCalendarFactory import at.bitfire.ical4android.CalendarStorageException import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter -import at.bitfire.icsdroid.Constants -import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.PropertyList -import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.property.Action -import net.fortuna.ical4j.model.property.Description -import net.fortuna.ical4j.model.property.Trigger -import java.time.Duration class LocalCalendar private constructor( account: Account, @@ -42,16 +35,28 @@ class LocalCalendar private constructor( /** * Stores if the calendar's embedded alerts should be ignored. - * @since 20221202 */ const val COLUMN_IGNORE_EMBEDDED = Calendars.CAL_SYNC8 /** * Stores the default alarm to set to all events in the given calendar. - * @since 20221202 */ const val COLUMN_DEFAULT_ALARM = Calendars.CAL_SYNC7 + /** + * Gets the calendar provider for a given context. + * The caller (you) is responsible for closing the client! + * + * @throws CalendarStorageException if the calendar provider is not available + * @throws SecurityException if permissions for accessing the calendar are not granted + */ + fun getCalendarProvider(context: Context): ContentProviderClient = + context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) ?: + throw CalendarStorageException("Calendar provider not available") + + + // CRUD methods + fun findById(account: Account, provider: ContentProviderClient, id: Long) = findByID(account, provider, Factory, id) @@ -93,52 +98,6 @@ class LocalCalendar private constructor( info.getAsLong(COLUMN_DEFAULT_ALARM)?.let { defaultAlarmMinutes = it } } - fun updateStatusSuccess(eTag: String?, lastModified: Long) { - this.eTag = eTag - this.lastModified = lastModified - lastSync = System.currentTimeMillis() - - val values = ContentValues(7) - values.put(COLUMN_ETAG, eTag) - values.put(COLUMN_LAST_MODIFIED, lastModified) - values.put(COLUMN_LAST_SYNC, lastSync) - values.putNull(COLUMN_ERROR_MESSAGE) - values.put(COLUMN_DEFAULT_ALARM, defaultAlarmMinutes) - values.put(COLUMN_IGNORE_EMBEDDED, ignoreEmbeddedAlerts) - update(values) - } - - fun updateStatusNotModified() { - lastSync = System.currentTimeMillis() - - val values = ContentValues(1) - values.put(COLUMN_LAST_SYNC, lastSync) - update(values) - } - - fun updateStatusError(message: String) { - eTag = null - lastModified = 0 - lastSync = System.currentTimeMillis() - errorMessage = message - - val values = ContentValues(7) - values.putNull(COLUMN_ETAG) - values.putNull(COLUMN_LAST_MODIFIED) - values.put(COLUMN_LAST_SYNC, lastSync) - values.put(COLUMN_ERROR_MESSAGE, message) - values.put(COLUMN_DEFAULT_ALARM, defaultAlarmMinutes) - values.put(COLUMN_IGNORE_EMBEDDED, ignoreEmbeddedAlerts) - update(values) - } - - fun updateUrl(url: String) { - this.url = url - - val values = ContentValues(1) - values.put(Calendars.NAME, url) - update(values) - } fun queryByUID(uid: String) = queryEvents("${Events._SYNC_ID}=?", arrayOf(uid)) @@ -176,4 +135,4 @@ class LocalCalendar private constructor( } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt b/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt index 47cd7f9..f33aa35 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt @@ -1,19 +1,12 @@ package at.bitfire.icsdroid.db.dao import android.database.SQLException -import android.database.sqlite.SQLiteConstraintException -import androidx.annotation.WorkerThread import androidx.room.Dao +import androidx.room.Insert import androidx.room.Query -import at.bitfire.icsdroid.db.AppDatabase import at.bitfire.icsdroid.db.entity.Credential import at.bitfire.icsdroid.db.entity.Subscription -/** - * Creates an interface with all the credentials stored in the database. - * @see AppDatabase - * @see Credential - */ @Dao interface CredentialsDao { /** @@ -22,21 +15,14 @@ interface CredentialsDao { * @return The [Credential] stored for the given [subscriptionId] or null if none. * @throws SQLException If there's an error while fetching the credential. */ - @WorkerThread @Query("SELECT * FROM credentials WHERE subscriptionId=:subscriptionId") fun getBySubscriptionId(subscriptionId: Long): Credential? /** * Inserts a new credential into the table. - * @param subscriptionId The id ([Subscription.id]) of the parent subscription of the credential. - * @param username The username to use for the credential. - * @param password The password to use for the credential. - * @throws SQLException If there's an error while making the insert. - * @throws SQLiteConstraintException If there's already a credential for the given subscription. */ - @WorkerThread - @Query("INSERT INTO credentials (subscriptionId, username, password) VALUES (:subscriptionId, :username, :password)") - fun put(subscriptionId: Long, username: String?, password: String?) + @Insert + fun create(credential: Credential) /** * Removes the credentials stored for the given subscription from the database. @@ -44,7 +30,7 @@ interface CredentialsDao { * credentials to be deleted. * @throws SQLException If there's an error while making the deletion. */ - @WorkerThread @Query("DELETE FROM credentials WHERE subscriptionId=:subscriptionId") fun remove(subscriptionId: Long) -} + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt index e92706b..88752c5 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt @@ -1,132 +1,44 @@ package at.bitfire.icsdroid.db.dao -import android.content.Context -import android.database.SQLException -import androidx.annotation.WorkerThread +import android.net.Uri import androidx.lifecycle.LiveData import androidx.room.* -import at.bitfire.icsdroid.db.AppDatabase import at.bitfire.icsdroid.db.entity.Subscription -/** - * Creates an interface with all the subscriptions made in the database. - * @author Arnau Mora - * @see AppDatabase - */ @Dao interface SubscriptionsDao { - companion object { - /** - * Alias for [AppDatabase.getInstance] -> [AppDatabase.subscriptionsDao]. - * @param context The context that is requesting access to the dao. - */ - fun getInstance(context: Context) = AppDatabase.getInstance(context).subscriptionsDao() - } - /** - * Adds one or more new subscriptions to the database. - * - * **This doesn't add the subscription to the system's calendar.** It's preferable to use - * [Subscription.create] adding new subscriptions. - * @author Arnau Mora - * @param subscriptions All the subscriptions to be added. - * @throws SQLException If any error occurs with the request. - */ @Insert - @WorkerThread fun add(vararg subscriptions: Subscription) - /** - * Gets a [LiveData] with all the made subscriptions. Updates automatically when new ones are added. - * @author Arnau Mora - * @throws SQLException If any error occurs with the request. - */ + @Delete + fun delete(vararg subscriptions: Subscription) + @Query("SELECT * FROM subscriptions") fun getAllLive(): LiveData> - /** - * Gets a list of all the made subscriptions. - * @author Arnau Mora - * @throws SQLException If any error occurs with the request. - */ - @WorkerThread @Query("SELECT * FROM subscriptions") fun getAll(): List - /** - * Gets an specific [Subscription] by its id ([Subscription.id]). - * @author Arnau Mora - * @param id The id of the subscription to fetch. - * @return The [Subscription] indicated, or null if any. - * @throws SQLException If any error occurs with the request. - */ - @WorkerThread @Query("SELECT * FROM subscriptions WHERE id=:id") fun getById(id: Long): Subscription? - /** - * Gets a [LiveData] that gets updated with the error message of the given subscription. - * @author Arnau Mora - * @param id The id of the subscription to get updates from. - * @throws SQLException If any error occurs with the request. - */ @Query("SELECT errorMessage FROM subscriptions WHERE id=:id") fun getErrorMessageLive(id: Long): LiveData - /** - * Removes all the given subscriptions from the database. Doesn't remove the matching calendar - * from the system. Use of [Subscription.delete] is preferred. - * @author Arnau Mora - * @param subscriptions All the subscriptions to be removed. - * @throws SQLException If any error occurs with the update. - */ - @WorkerThread - @Delete - fun delete(vararg subscriptions: Subscription) - - /** - * Updates the given subscriptions in the database. - * @author Arnau Mora - * @param subscriptions All the subscriptions to be updated. - * @throws SQLException If any error occurs with the update. - */ - @WorkerThread @Update fun update(vararg subscriptions: Subscription) - /** - * Updates the status of a subscription that has not been modified. This is updating its [Subscription.lastSync] to the current time. - * @author Arnau Mora - * @param id The id of the subscription to update. - * @param lastSync The synchronization time to set. Can be left as default, and will match the current system time. - * @throws SQLException If any error occurs with the update. - */ - @WorkerThread @Query("UPDATE subscriptions SET lastSync=:lastSync WHERE id=:id") fun updateStatusNotModified(id: Long, lastSync: Long = System.currentTimeMillis()) - /** - * Updates the status of a subscription that has just been modified. This removes its [Subscription.errorMessage], and updates the [Subscription.eTag], - * [Subscription.lastModified] and [Subscription.lastSync]. - * @author Arnau Mora - * @param id The id of the subscription to update. - * @param eTag The new eTag to set. - * @param lastModified The new date to set for [Subscription.lastModified]. - * @param lastSync The last synchronization date to set. Defaults to the current system time, so can be skipped. - * @throws SQLException If any error occurs with the update. - */ - @WorkerThread @Query("UPDATE subscriptions SET eTag=:eTag, lastModified=:lastModified, lastSync=:lastSync, errorMessage=null WHERE id=:id") - fun updateStatusSuccess(id: Long, eTag: String?, lastModified: Long, lastSync: Long = System.currentTimeMillis()) + fun updateStatusSuccess(id: Long, eTag: String?, lastModified: Long?, lastSync: Long = System.currentTimeMillis()) - /** - * Updates the error message of the subscription. - * @author Arnau Mora - * @param id The id of the subscription to update. - * @param message The error message to give to the subscription. - * @throws SQLException If any error occurs with the update. - */ - @WorkerThread @Query("UPDATE subscriptions SET errorMessage=:message WHERE id=:id") fun updateStatusError(id: Long, message: String?) -} + + @Query("UPDATE subscriptions SET url=:url WHERE id=:id") + fun updateUrl(id: Long, url: Uri) + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt index 9446492..43267fa 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt @@ -6,9 +6,6 @@ import androidx.room.PrimaryKey /** * Stores the credentials to be used with a specific subscription. - * @param subscriptionId The id of the subscription that matches this credential. - * @param username The username of the credential. - * @param password The password of the credential. */ @Entity( tableName = "credentials", @@ -19,5 +16,5 @@ import androidx.room.PrimaryKey data class Credential( @PrimaryKey val subscriptionId: Long, val username: String, - val password: String, + val password: String ) \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt index 1878235..eeff805 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt @@ -1,51 +1,46 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + package at.bitfire.icsdroid.db.entity -import android.accounts.Account -import android.content.Context -import android.database.SQLException import android.net.Uri +import android.provider.CalendarContract.Calendars import androidx.annotation.ColorInt -import androidx.annotation.WorkerThread +import androidx.core.content.contentValuesOf import androidx.room.Entity import androidx.room.PrimaryKey -import at.bitfire.icsdroid.R -import at.bitfire.icsdroid.db.AppDatabase -import java.net.MalformedURLException +import at.bitfire.icsdroid.db.LocalCalendar /** * Represents the storage of a subscription the user has made. - * @param id The id of the subscription in the database. - * @param url URL of iCalendar file - * @param eTag iCalendar ETag at last successful sync - * @param displayName Display name of the subscription - * @param lastModified iCalendar Last-Modified at last successful sync (or 0 for none) - * @param lastSync time of last sync (0 if none) - * @param errorMessage error message (HTTP status or exception name) of last sync (or null) - * @param ignoreEmbeddedAlerts Setting: whether to ignore alarms embedded in the Webcal - * @param defaultAlarmMinutes Setting: Shall a default alarm be added to every event in the calendar? If yes, this field contains the minutes before the event. - * If no, it is `null`. - * @param color The color that represents the subscription. */ @Entity(tableName = "subscriptions") data class Subscription( + /** The id of the subscription in the database. */ @PrimaryKey val id: Long = 0L, + /** URL of iCalendar file */ val url: Uri, + /** ETag at last successful sync */ val eTag: String? = null, + /** display name of the subscription */ val displayName: String, + /** when the remote resource was last modified, according to its source (timestamp) */ val lastModified: Long? = null, + /** timestamp of last sync */ val lastSync: Long? = null, - val syncEvents: Boolean = false, + /** error message (HTTP status or exception name) of last sync (or null for _no error_) */ val errorMessage: String? = null, + /** setting: whether to ignore alarms embedded in the Webcal */ val ignoreEmbeddedAlerts: Boolean = false, + /** setting: Shall a default alarm be added to every event in the calendar? If yes, this field contains the minutes before the event. If no, it is `null`. */ val defaultAlarmMinutes: Long? = null, - val color: Int? = null, - - val isSynced: Boolean = true, - val isVisible: Boolean = true, + /** The color that represents the subscription. */ + val color: Int? = null ) { companion object { /** @@ -54,81 +49,39 @@ data class Subscription( @ColorInt const val DEFAULT_COLOR = 0xFF2F80C7.toInt() - /** Gets the account to be used for the subscriptions. */ - fun getAccount(context: Context) = Account( - context.getString(R.string.account_type), - context.getString(R.string.account_name), - ) - } - - /** - * Updates the status of a subscription that has not been modified. This is updating its [Subscription.lastSync] to the current time. - * @param context The context that is making the request. - * @param lastSync The synchronization time to set. Can be left as default, and will match the current system time. - * @throws SQLException If any error occurs with the update. - */ - @WorkerThread - fun updateStatusNotModified( - context: Context, - lastSync: Long = System.currentTimeMillis() - ) = - AppDatabase.getInstance(context) - .subscriptionsDao() - .updateStatusNotModified(id, lastSync) - - /** - * Updates the status of a subscription that has just been modified. This removes its [Subscription.errorMessage], and updates the [Subscription.eTag], - * [Subscription.lastModified] and [Subscription.lastSync]. - * @param context The context that is making the request. - * @param eTag The new eTag to set. - * @param lastModified The new date to set for [Subscription.lastModified]. - * @param lastSync The last synchronization date to set. Defaults to the current system time, so can be skipped. - * @throws SQLException If any error occurs with the update. - */ - @WorkerThread - fun updateStatusSuccess( - context: Context, - eTag: String? = this.eTag, - lastModified: Long? = this.lastModified, - lastSync: Long = System.currentTimeMillis() - ) = AppDatabase.getInstance(context) - .subscriptionsDao() - .updateStatusSuccess(id, eTag, lastModified ?: 0L, lastSync) - - /** - * Updates the error message of the subscription. - * @param context The context that is making the request. - * @param message The error message to give to the subscription. - * @throws SQLException If any error occurs with the update. - */ - @WorkerThread - fun updateStatusError(context: Context, message: String?) = - AppDatabase.getInstance(context) - .subscriptionsDao() - .updateStatusError(id, message) - - /** - * Updates the [Subscription.url] field to the given one. - * @param context The context that is making the request. - * @param url The new url to set. - * @throws SQLException If any error occurs with the update. - */ - @WorkerThread - fun updateUrl(context: Context, url: Uri) = - AppDatabase.getInstance(context) - .subscriptionsDao() - .update( - copy(url = url) + /** + * Converts a [LocalCalendar] to a [Subscription] data object. + * Must only be used for migrating legacy calendars. + * + * @param calendar The legacy calendar to create the subscription from. + * @return A new [Subscription] that has the contents of [calendar]. + */ + fun fromLegacyCalendar(calendar: LocalCalendar) = + Subscription( + id = calendar.id, + url = Uri.parse(calendar.url ?: "https://invalid-url"), + eTag = calendar.eTag, + displayName = calendar.displayName ?: calendar.id.toString(), + lastModified = calendar.lastModified, + lastSync = calendar.lastSync, + errorMessage = calendar.errorMessage, + ignoreEmbeddedAlerts = calendar.ignoreEmbeddedAlerts ?: false, + defaultAlarmMinutes = calendar.defaultAlarmMinutes, + color = calendar.color ) + } + /** - * Updates the [Subscription.url] field to the given one. - * @param context The context that is making the request. - * @param url The new url to set. - * @throws SQLException If any error occurs with the update. - * @throws MalformedURLException If the given [url] cannot be parsed to [Uri]. + * Converts this subscription's properties to [android.content.ContentValues] that can be + * passed to the calendar provider in order to create/update the local calendar. */ - @WorkerThread - fun updateUrl(context: Context, url: String) = updateUrl(context, Uri.parse(url)) + fun toCalendarProperties() = contentValuesOf( + Calendars._ID to id, + Calendars.CALENDAR_DISPLAY_NAME to displayName, + Calendars.CALENDAR_COLOR to color, + Calendars.CALENDAR_ACCESS_LEVEL to Calendars.CAL_ACCESS_READ, + Calendars.SYNC_EVENTS to 1 + ) -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt index c6ab54a..a18032d 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt @@ -8,7 +8,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.CompoundButton import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.EditText import androidx.core.widget.addTextChangedListener -- GitLab From 65005a8ed8d6f0415c29cb8d67e8073065e716de Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Fri, 17 Feb 2023 17:25:29 +0100 Subject: [PATCH 13/51] Subscriptions in database: UI (#111) * Removed permissions requesting Signed-off-by: Arnau Mora * Migrated loading to database Signed-off-by: Arnau Mora * Minor changes * `add` returns ids Signed-off-by: Arnau Mora * Migrated creation to database Signed-off-by: Arnau Mora * Added update method Signed-off-by: Arnau Mora * Changed ViewModel Signed-off-by: Arnau Mora * Removed active switch Signed-off-by: Arnau Mora * Migrated edit to database Signed-off-by: Arnau Mora * Rework EditCalendarActivity - use explicit EXTRA_SUBSCRIPTION_ID - use ViewModelProvider.Factory instead of custom loadSubscription() - create one-two-one relationship between Subscription and Credential * Added permission extra Signed-off-by: Arnau Mora * Moved permission request Signed-off-by: Arnau Mora * Added permission required Snackbar Signed-off-by: Arnau Mora * Fixed permission request Signed-off-by: Arnau Mora * Added `showCalendarPermissionNotification` Signed-off-by: Arnau Mora * Added notification permission requests Signed-off-by: Arnau Mora * Added calendar permission notification Signed-off-by: Arnau Mora * Changed threading (#112) Signed-off-by: Arnau Mora * Change model/view/result pattern to states (LiveData) * Don't ask to enable sync framework auto-sync because we don't use the sync framework anymore * Remove unneeded string --------- Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- .../at/bitfire/icsdroid/PermissionUtils.kt | 35 ++ .../at/bitfire/icsdroid/ProcessEventsTask.kt | 9 +- .../java/at/bitfire/icsdroid/SyncAdapter.kt | 23 +- .../java/at/bitfire/icsdroid/SyncWorker.kt | 4 + .../bitfire/icsdroid/db/dao/CredentialsDao.kt | 30 +- .../icsdroid/db/dao/SubscriptionsDao.kt | 16 +- .../bitfire/icsdroid/db/entity/Credential.kt | 2 +- .../icsdroid/ui/AddCalendarActivity.kt | 5 - .../icsdroid/ui/AddCalendarDetailsFragment.kt | 139 +++++--- .../icsdroid/ui/CalendarListActivity.kt | 233 +++++------- .../icsdroid/ui/EditCalendarActivity.kt | 331 +++++++++--------- .../bitfire/icsdroid/ui/NotificationUtils.kt | 23 ++ .../res/layout/calendar_list_activity.xml | 6 +- app/src/main/res/layout/edit_calendar.xml | 9 +- app/src/main/res/values/strings.xml | 6 +- build.gradle | 2 +- 16 files changed, 434 insertions(+), 439 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt index 48d5354..d3d87ee 100644 --- a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt @@ -7,6 +7,7 @@ package at.bitfire.icsdroid import android.Manifest import android.content.Context import android.content.pm.PackageManager +import android.os.Build import android.util.Log import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -31,6 +32,19 @@ object PermissionUtils { ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } + /** + * Checks whether the calling app has permission to request notifications. If the device's SDK + * level is lower than Tiramisu, always returns `true`. + * + * @param context context to check permissions within + * @return *true* if notification permissions are granted; *false* otherwise + */ + fun haveNotificationPermission(context: Context) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + else + true + /** * Registers for the result of the request of some permissions. * Invoke the returned anonymous function to actually request the permissions. @@ -82,4 +96,25 @@ object PermissionUtils { onGranted ) + /** + * Registers a notification permission request launcher. + * + * @param activity activity to register permission request launcher + * @param onGranted called when calendar permissions have been granted + * + * @return Call the returning function to launch the request + */ + fun registerNotificationPermissionRequest(activity: AppCompatActivity, onGranted: () -> Unit = {}) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + registerPermissionRequest( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + R.string.notification_permissions_required, + onGranted + ) + else { + // If SDK level is not greater or equal than Tiramisu, do nothing + {} + } + } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt index 59ac9f9..cfd61da 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt @@ -5,11 +5,9 @@ package at.bitfire.icsdroid import android.app.PendingIntent -import android.content.ContentUris import android.content.Context import android.content.Intent import android.net.Uri -import android.provider.CalendarContract import android.util.Log import androidx.core.app.NotificationCompat import at.bitfire.ical4android.Event @@ -170,10 +168,9 @@ class ProcessEventsTask( val message = ex.localizedMessage ?: ex.message ?: ex.toString() val errorIntent = Intent(context, EditCalendarActivity::class.java) - errorIntent.data = - ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, subscription.id) - errorIntent.putExtra(EditCalendarActivity.ERROR_MESSAGE, message) - errorIntent.putExtra(EditCalendarActivity.THROWABLE, ex) + errorIntent.putExtra(EditCalendarActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) + errorIntent.putExtra(EditCalendarActivity.EXTRA_ERROR_MESSAGE, message) + errorIntent.putExtra(EditCalendarActivity.EXTRA_THROWABLE, ex) val notification = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC) .setSmallIcon(R.drawable.ic_sync_problem_white) diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt b/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt index 8cd1381..c089ecc 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncAdapter.kt @@ -5,16 +5,13 @@ package at.bitfire.icsdroid import android.accounts.Account -import android.app.PendingIntent import android.content.* import android.os.Bundle -import androidx.core.app.NotificationCompat import androidx.work.WorkManager -import at.bitfire.icsdroid.ui.CalendarListActivity import at.bitfire.icsdroid.ui.NotificationUtils class SyncAdapter( - context: Context + context: Context ): AbstractThreadedSyncAdapter(context, false) { override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { @@ -28,19 +25,11 @@ class SyncAdapter( wm.cancelUniqueWork(SyncWorker.NAME) } + /** + * Called by the sync framework when we don't have calendar permissions. + */ override fun onSecurityException(account: Account?, extras: Bundle?, authority: String?, syncResult: SyncResult?) { - val nm = NotificationUtils.createChannels(context) - val askPermissionsIntent = Intent(context, CalendarListActivity::class.java) - val notification = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC) - .setSmallIcon(R.drawable.ic_sync_problem_white) - .setContentTitle(context.getString(R.string.sync_permission_required)) - .setContentText(context.getString(R.string.sync_permission_required_sync_calendar)) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .setContentIntent(PendingIntent.getActivity(context, 0, askPermissionsIntent, PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat)) - .setAutoCancel(true) - .setLocalOnly(true) - .build() - nm.notify(NotificationUtils.NOTIFY_PERMISSION, notification) + NotificationUtils.showCalendarPermissionNotification(context) } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt index 410605c..94fb2f2 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt @@ -16,6 +16,7 @@ import at.bitfire.icsdroid.db.CalendarCredentials import at.bitfire.icsdroid.db.LocalCalendar import at.bitfire.icsdroid.db.entity.Credential import at.bitfire.icsdroid.db.entity.Subscription +import at.bitfire.icsdroid.ui.NotificationUtils class SyncWorker( context: Context, @@ -100,6 +101,9 @@ class SyncWorker( val calendar = LocalCalendar.findById(account, provider, subscription.id) ProcessEventsTask(applicationContext, subscription, calendar, forceReSync).sync() } + } catch (e: SecurityException) { + NotificationUtils.showCalendarPermissionNotification(applicationContext) + return Result.failure() } catch (e: InterruptedException) { Log.e(TAG, "Thread interrupted", e) return Result.retry() diff --git a/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt b/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt index f33aa35..5fb233b 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/dao/CredentialsDao.kt @@ -1,36 +1,24 @@ package at.bitfire.icsdroid.db.dao -import android.database.SQLException -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query +import androidx.room.* import at.bitfire.icsdroid.db.entity.Credential -import at.bitfire.icsdroid.db.entity.Subscription @Dao interface CredentialsDao { - /** - * Gets all the credentials stored for the given subscription. - * @param subscriptionId The id of the subscription to get the credentials for. - * @return The [Credential] stored for the given [subscriptionId] or null if none. - * @throws SQLException If there's an error while fetching the credential. - */ + @Query("SELECT * FROM credentials WHERE subscriptionId=:subscriptionId") fun getBySubscriptionId(subscriptionId: Long): Credential? - /** - * Inserts a new credential into the table. - */ @Insert fun create(credential: Credential) - /** - * Removes the credentials stored for the given subscription from the database. - * @param subscriptionId The id ([Subscription.id]) of the subscription that matches the stored - * credentials to be deleted. - * @throws SQLException If there's an error while making the deletion. - */ + @Upsert + fun upsert(credential: Credential) + @Query("DELETE FROM credentials WHERE subscriptionId=:subscriptionId") - fun remove(subscriptionId: Long) + fun removeBySubscriptionId(subscriptionId: Long) + + @Update + fun update(credential: Credential) } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt index 88752c5..18ffd2b 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt @@ -3,13 +3,14 @@ package at.bitfire.icsdroid.db.dao import android.net.Uri import androidx.lifecycle.LiveData import androidx.room.* +import at.bitfire.icsdroid.db.entity.Credential import at.bitfire.icsdroid.db.entity.Subscription @Dao interface SubscriptionsDao { @Insert - fun add(vararg subscriptions: Subscription) + fun add(vararg subscriptions: Subscription): List @Delete fun delete(vararg subscriptions: Subscription) @@ -23,6 +24,9 @@ interface SubscriptionsDao { @Query("SELECT * FROM subscriptions WHERE id=:id") fun getById(id: Long): Subscription? + @Query("SELECT * FROM subscriptions WHERE id=:id") + fun getWithCredentialsByIdLive(id: Long): LiveData + @Query("SELECT errorMessage FROM subscriptions WHERE id=:id") fun getErrorMessageLive(id: Long): LiveData @@ -41,4 +45,14 @@ interface SubscriptionsDao { @Query("UPDATE subscriptions SET url=:url WHERE id=:id") fun updateUrl(id: Long, url: Uri) + + data class SubscriptionWithCredential( + @Embedded val subscription: Subscription, + @Relation( + parentColumn = "id", + entityColumn = "subscriptionId" + ) + val credential: Credential? + ) + } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt index 43267fa..db59d4d 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Credential.kt @@ -11,7 +11,7 @@ import androidx.room.PrimaryKey tableName = "credentials", foreignKeys = [ ForeignKey(entity = Subscription::class, parentColumns = ["id"], childColumns = ["subscriptionId"], onDelete = ForeignKey.CASCADE), - ], + ] ) data class Credential( @PrimaryKey val subscriptionId: Long, diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt index d0b0501..bffcaef 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt @@ -25,11 +25,6 @@ class AddCalendarActivity: AppCompatActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(true) - if (!PermissionUtils.haveCalendarPermissions(this)) { - PermissionUtils - .registerCalendarPermissionRequest(this)() - } - if (inState == null) { supportFragmentManager .beginTransaction() diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt index 5566166..a089475 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt @@ -4,30 +4,32 @@ package at.bitfire.icsdroid.ui -import android.content.ContentProviderClient -import android.content.ContentUris -import android.content.ContentValues +import android.app.Application +import android.net.Uri import android.os.Bundle -import android.provider.CalendarContract -import android.provider.CalendarContract.Calendars import android.util.Log import android.view.* import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer -import at.bitfire.ical4android.AndroidCalendar -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat -import at.bitfire.icsdroid.AppAccount +import androidx.lifecycle.viewModelScope import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.R -import at.bitfire.icsdroid.db.CalendarCredentials -import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class AddCalendarDetailsFragment: Fragment() { private val titleColorModel by activityViewModels() private val credentialsModel by activityViewModels() + private val model by activityViewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -48,6 +50,23 @@ class AddCalendarDetailsFragment: Fragment() { val v = inflater.inflate(R.layout.add_calendar_details, container, false) setHasOptionsMenu(true) + // Handle status changes + model.success.observe(viewLifecycleOwner) { success -> + if (success) { + // success, show notification and close activity + Toast.makeText( + requireActivity(), + requireActivity().getString(R.string.add_calendar_created), + Toast.LENGTH_LONG + ).show() + + requireActivity().finish() + } + } + model.errorMessage.observe(viewLifecycleOwner) { message -> + Toast.makeText(requireActivity(), message, Toast.LENGTH_LONG).show() + } + return v } @@ -61,49 +80,67 @@ class AddCalendarDetailsFragment: Fragment() { } override fun onOptionsItemSelected(item: MenuItem) = - if (item.itemId == R.id.create_calendar) { - if (createCalendar()) - requireActivity().finish() - true - } else - false - - - private fun createCalendar(): Boolean { - val account = AppAccount.get(requireActivity()) - - val calInfo = ContentValues(9) - calInfo.put(Calendars.ACCOUNT_NAME, account.name) - calInfo.put(Calendars.ACCOUNT_TYPE, account.type) - calInfo.put(Calendars.NAME, titleColorModel.url.value) - calInfo.put(Calendars.CALENDAR_DISPLAY_NAME, titleColorModel.title.value) - calInfo.put(Calendars.CALENDAR_COLOR, titleColorModel.color.value) - calInfo.put(Calendars.OWNER_ACCOUNT, account.name) - calInfo.put(Calendars.SYNC_EVENTS, 1) - calInfo.put(Calendars.VISIBLE, 1) - calInfo.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) - calInfo.put(LocalCalendar.COLUMN_IGNORE_EMBEDDED, titleColorModel.ignoreAlerts.value) - calInfo.put(LocalCalendar.COLUMN_DEFAULT_ALARM, titleColorModel.defaultAlarmMinutes.value) - - val client: ContentProviderClient? = requireActivity().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) - return try { - client?.let { - val uri = AndroidCalendar.create(account, it, calInfo) - val calendar = LocalCalendar.findById(account, client, ContentUris.parseId(uri)) - - if (credentialsModel.requiresAuth.value == true) - CalendarCredentials(requireActivity()).put(calendar, credentialsModel.username.value, credentialsModel.password.value) - } - Toast.makeText(activity, getString(R.string.add_calendar_created), Toast.LENGTH_LONG).show() - requireActivity().invalidateOptionsMenu() + if (item.itemId == R.id.create_calendar) { + model.create(titleColorModel, credentialsModel) true - } catch(e: Exception) { - Log.e(Constants.TAG, "Couldn't create calendar", e) - Toast.makeText(context, e.localizedMessage, Toast.LENGTH_LONG).show() + } else false - } finally { - client?.closeCompat() + + + class SubscriptionModel(application: Application) : AndroidViewModel(application) { + + private val database = AppDatabase.getInstance(getApplication()) + private val subscriptionsDao = database.subscriptionsDao() + private val credentialsDao = database.credentialsDao() + + val success = MutableLiveData(false) + val errorMessage = MutableLiveData() + + /** + * Creates a new subscription taking the data from the given models. + */ + fun create( + titleColorModel: TitleColorFragment.TitleColorModel, + credentialsModel: CredentialsFragment.CredentialsModel, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + val subscription = Subscription( + displayName = titleColorModel.title.value!!, + url = Uri.parse(titleColorModel.url.value), + color = titleColorModel.color.value, + ignoreEmbeddedAlerts = titleColorModel.ignoreAlerts.value ?: false, + defaultAlarmMinutes = titleColorModel.defaultAlarmMinutes.value + ) + + /** A list of all the ids of the inserted rows, should only contain one value */ + val ids = withContext(Dispatchers.IO) { subscriptionsDao.add(subscription) } + + /** The id of the newly inserted subscription */ + val id = ids.first() + + // Create the credential in the IO thread + if (credentialsModel.requiresAuth.value == true) { + // If the subscription requires credentials, create them + val username = credentialsModel.username.value + val password = credentialsModel.password.value + if (username != null && password != null) { + val credential = Credential( + subscriptionId = id, + username = username, + password = password + ) + credentialsDao.create(credential) + } + } + + success.postValue(true) + } catch (e: Exception) { + Log.e(Constants.TAG, "Couldn't create calendar", e) + errorMessage.postValue(e.localizedMessage) + } + } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt index a114b93..9a2b359 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt @@ -4,58 +4,71 @@ package at.bitfire.icsdroid.ui -import android.Manifest import android.annotation.SuppressLint import android.app.Application -import android.content.ContentUris import android.content.Context import android.content.Intent -import android.content.pm.PackageManager -import android.database.ContentObserver import android.os.Build import android.os.Bundle import android.os.PowerManager -import android.provider.CalendarContract import android.provider.Settings import android.util.Log import android.view.* import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.work.WorkInfo -import at.bitfire.ical4android.CalendarStorageException import at.bitfire.icsdroid.* import at.bitfire.icsdroid.databinding.CalendarListActivityBinding import at.bitfire.icsdroid.databinding.CalendarListItemBinding -import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Subscription import com.google.android.material.snackbar.Snackbar import java.text.DateFormat import java.util.* class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshListener { - private val model by viewModels() + companion object { + /** + * Set this extra to request calendar permission when the activity starts. + */ + const val EXTRA_REQUEST_CALENDAR_PERMISSION = "permission" + } + + private val model by viewModels() private lateinit var binding: CalendarListActivityBinding - private var snackBar: Snackbar? = null + /** Stores the calendar permission request for asking for calendar permissions during runtime */ + private lateinit var requestCalendarPermissions: () -> Unit + /** Stores the post notification permission request for asking for permissions during runtime */ + private lateinit var requestNotificationPermission: () -> Unit + + private var snackBar: Snackbar? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setTitle(R.string.title_activity_calendar_list) + // Register the calendar permission request + requestCalendarPermissions = PermissionUtils.registerCalendarPermissionRequest(this) { + SyncWorker.run(this) + } + + // Register the notifications permission request + requestNotificationPermission = PermissionUtils.registerNotificationPermissionRequest(this) + binding = DataBindingUtil.setContentView(this, R.layout.calendar_list_activity) binding.lifecycleOwner = this binding.model = model @@ -65,47 +78,37 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis binding.refresh.setOnRefreshListener(this) binding.refresh.setSize(SwipeRefreshLayout.LARGE) - val permissionsRequestLauncher = - PermissionUtils.registerPermissionRequest(this, CalendarModel.REQUIRED_PERMISSIONS, R.string.permissions_required) { - // re-initialize model if calendar permissions are granted - model.reinit() - - // we have calendar permissions, cancel possible sync notification (see SyncAdapter.onSecurityException askPermissionsIntent) - val nm = NotificationManagerCompat.from(this) - nm.cancel(NotificationUtils.NOTIFY_PERMISSION) - } - model.askForPermissions.observe(this) { ask -> - if (ask) - permissionsRequestLauncher() - } - // show whether sync is running model.isRefreshing.observe(this) { isRefreshing -> binding.refresh.isRefreshing = isRefreshing } // calendars - val calendarAdapter = CalendarListAdapter(this) - calendarAdapter.clickListener = { calendar -> + val subscriptionAdapter = SubscriptionListAdapter(this) + subscriptionAdapter.clickListener = { calendar -> val intent = Intent(this, EditCalendarActivity::class.java) - intent.data = ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendar.id) + intent.putExtra(EditCalendarActivity.EXTRA_SUBSCRIPTION_ID, calendar.id) startActivity(intent) } - binding.calendarList.adapter = calendarAdapter + binding.calendarList.adapter = subscriptionAdapter binding.fab.setOnClickListener { onAddCalendar() } - model.calendars.observe(this) { calendars -> - calendarAdapter.submitList(calendars) + // If EXTRA_PERMISSION is true, request the calendar permissions + val requestPermissions = intent.getBooleanExtra(EXTRA_REQUEST_CALENDAR_PERMISSION, false) + if (requestPermissions && !PermissionUtils.haveCalendarPermissions(this)) + requestCalendarPermissions() + + model.subscriptions.observe(this) { subscriptions -> + subscriptionAdapter.submitList(subscriptions) val colors = mutableSetOf() colors += defaultRefreshColor - colors.addAll(calendars.mapNotNull { it.color }) + colors.addAll(subscriptions.mapNotNull { it.color }) binding.refresh.setColorSchemeColors(*colors.toIntArray()) } - model.reinit() // startup fragments if (savedInstanceState == null) @@ -144,11 +147,18 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis snackBar = null when { - // periodic sync not enabled - AppAccount.syncInterval(this) == AppAccount.SYNC_INTERVAL_MANUALLY -> { - snackBar = Snackbar.make(binding.coordinator, R.string.calendar_list_sync_interval_manually, Snackbar.LENGTH_INDEFINITE).also { - it.show() - } + // notification permissions are granted + !PermissionUtils.haveNotificationPermission(this) -> { + snackBar = Snackbar.make(binding.coordinator, R.string.notification_permissions_required, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.permissions_grant) { requestNotificationPermission() } + .also { it.show() } + } + + // calendar permissions are granted + !PermissionUtils.haveCalendarPermissions(this) -> { + snackBar = Snackbar.make(binding.coordinator, R.string.calendar_permissions_required, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.permissions_grant) { requestCalendarPermissions() } + .also { it.show() } } // periodic sync enabled AND Android >= 6 AND not whitelisted from battery saving AND sync interval < 1 day @@ -198,29 +208,26 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis } - class CalendarListAdapter( - val context: Context - ): ListAdapter(object: DiffUtil.ItemCallback() { + class SubscriptionListAdapter( + val context: Context + ): ListAdapter(object: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: LocalCalendar, newItem: LocalCalendar) = - oldItem.id == newItem.id + override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription) = + oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: LocalCalendar, newItem: LocalCalendar) = - // compare all displayed fields - oldItem.url == newItem.url && - oldItem.displayName == newItem.displayName && - oldItem.isSynced == newItem.isSynced && - oldItem.lastSync == newItem.lastSync && - oldItem.color == newItem.color && - oldItem.errorMessage == newItem.errorMessage + override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription) = + // compare all displayed fields + oldItem.url == newItem.url && + oldItem.displayName == newItem.displayName && + oldItem.lastSync == newItem.lastSync && + oldItem.color == newItem.color && + oldItem.errorMessage == newItem.errorMessage }) { class ViewHolder(val binding: CalendarListItemBinding): RecyclerView.ViewHolder(binding.root) - - var clickListener: ((LocalCalendar) -> Unit)? = null - + var clickListener: ((Subscription) -> Unit)? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { Log.i(Constants.TAG, "Creating view holder") @@ -229,35 +236,29 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val calendar = currentList[position] + val subscription = currentList[position] holder.binding.root.setOnClickListener { clickListener?.let { listener -> - listener(calendar) + listener(subscription) } } holder.binding.apply { - url.text = calendar.url - title.text = calendar.displayName - - syncStatus.text = - if (!calendar.isSynced) - context.getString(R.string.calendar_list_sync_disabled) - else { - if (calendar.lastSync == 0L) - context.getString(R.string.calendar_list_not_synced_yet) - else - DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT) - .format(Date(calendar.lastSync)) - } - - calendar.color?.let { + url.text = subscription.url.toString() + title.text = subscription.displayName + + syncStatus.text = subscription.lastSync?.let { lastSync -> + DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT) + .format(Date(lastSync)) + } ?: context.getString(R.string.calendar_list_not_synced_yet) + + subscription.color?.let { color.setColor(it) } } - val errorMessage = calendar.errorMessage + val errorMessage = subscription.errorMessage if (errorMessage == null) holder.binding.errorMessage.visibility = View.GONE else { @@ -268,95 +269,17 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis } - - /** - * Data model for this view. Updates calendar subscriptions in real-time. - * - * Must be initialized with [reinit] after it's created. - * - * Requires calendar permissions. If it doesn't have calendar permissions, it does nothing. - * As soon as calendar permissions are granted, you have to call [reinit] again. - */ - class CalendarModel( - application: Application - ): AndroidViewModel(application) { - - companion object { - val REQUIRED_PERMISSIONS = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - PermissionUtils.CALENDAR_PERMISSIONS + Manifest.permission.POST_NOTIFICATIONS - else - PermissionUtils.CALENDAR_PERMISSIONS - } - - private val resolver = application.contentResolver - - val askForPermissions = MutableLiveData(false) + class SubscriptionsModel(application: Application): AndroidViewModel(application) { /** whether there are running sync workers */ val isRefreshing = Transformations.map(SyncWorker.liveStatus(application)) { workInfos -> workInfos.any { it.state == WorkInfo.State.RUNNING } } - val calendars = MutableLiveData>() - private var observer: ContentObserver? = null - - - fun reinit() { - val haveCalendarPermissions = PermissionUtils.haveCalendarPermissions(getApplication()) - val haveNotificationPermissions = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - else - true - askForPermissions.value = !haveCalendarPermissions || !haveNotificationPermissions - - if (observer == null) { - // we're not watching the calendars yet - if (haveCalendarPermissions) { - Log.d(Constants.TAG, "Watching calendars") - startWatchingCalendars() - } else - Log.w(Constants.TAG,"Can't watch calendars (permission denied)") - } - } - - override fun onCleared() { - stopWatchingCalendars() - } - - - private fun startWatchingCalendars() { - val newObserver = object: ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - loadCalendars() - } - } - resolver.registerContentObserver(CalendarContract.Calendars.CONTENT_URI, false, newObserver) - observer = newObserver - - loadCalendars() - } - - private fun stopWatchingCalendars() { - observer?.let { - resolver.unregisterContentObserver(it) - observer = null - } - } - - private fun loadCalendars() { - val provider = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY) - if (provider != null) - try { - val result = LocalCalendar.findAll(AppAccount.get(getApplication()), provider) - calendars.postValue(result) - } catch(e: CalendarStorageException) { - Log.e(Constants.TAG, "Couldn't load calendar list", e) - } finally { - provider.release() - } - } + /** LiveData watching the subscriptions */ + val subscriptions = AppDatabase.getInstance(application) + .subscriptionsDao() + .getAllLive() } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt index 3739c5c..bcdd052 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt @@ -4,17 +4,9 @@ package at.bitfire.icsdroid.ui -import android.Manifest -import android.annotation.SuppressLint import android.app.Application -import android.content.ContentUris -import android.content.ContentValues -import android.content.pm.PackageManager -import android.net.Uri import android.os.Build import android.os.Bundle -import android.provider.CalendarContract -import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View @@ -24,7 +16,6 @@ import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ActivityCompat import androidx.core.app.ShareCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.DialogFragment @@ -32,78 +23,86 @@ import androidx.fragment.app.FragmentTransaction import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer -import at.bitfire.ical4android.CalendarStorageException -import at.bitfire.icsdroid.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import at.bitfire.icsdroid.HttpUtils +import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.SyncWorker import at.bitfire.icsdroid.databinding.EditCalendarBinding -import at.bitfire.icsdroid.db.CalendarCredentials -import at.bitfire.icsdroid.db.LocalCalendar -import java.io.FileNotFoundException +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.dao.SubscriptionsDao +import at.bitfire.icsdroid.db.entity.Credential +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class EditCalendarActivity: AppCompatActivity() { companion object { - const val ERROR_MESSAGE = "errorMessage" - const val THROWABLE = "errorThrowable" + const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" + const val EXTRA_ERROR_MESSAGE = "errorMessage" + const val EXTRA_THROWABLE = "errorThrowable" } - private val model by viewModels() private val titleColorModel by viewModels() private val credentialsModel by viewModels() + private val model by viewModels { + object: ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val subscriptionId = intent.getLongExtra(EXTRA_SUBSCRIPTION_ID, -1) + return SubscriptionModel(application, subscriptionId) as T + } + } + } + lateinit var binding: EditCalendarBinding override fun onCreate(inState: Bundle?) { super.onCreate(inState) + model.subscriptionWithCredential.observe(this) { data -> + if (data != null) + onSubscriptionLoaded(data) + } + val invalidate = Observer { invalidateOptionsMenu() } - - model.calendar.observe(this) { calendar -> - if (!model.loaded) { - onCalendarLoaded(calendar) - model.loaded = true - } + arrayOf( + titleColorModel.title, + titleColorModel.color, + titleColorModel.ignoreAlerts, + titleColorModel.defaultAlarmMinutes, + credentialsModel.requiresAuth, + credentialsModel.username, + credentialsModel.password + ).forEach { element -> + element.observe(this, invalidate) } - model.active.observe(this, invalidate) - - titleColorModel.title.observe(this, invalidate) - titleColorModel.color.observe(this, invalidate) - titleColorModel.ignoreAlerts.observe(this, invalidate) - titleColorModel.defaultAlarmMinutes.observe(this, invalidate) - - credentialsModel.requiresAuth.observe(this, invalidate) - credentialsModel.username.observe(this, invalidate) - credentialsModel.password.observe(this, invalidate) binding = DataBindingUtil.setContentView(this, R.layout.edit_calendar) binding.lifecycleOwner = this binding.model = model - if (inState == null) { - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED && - ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) { - // permissions OK, load calendar from provider - val uri = intent.data ?: throw IllegalArgumentException("Intent data empty (must be calendar URI)") - val calendarId = ContentUris.parseId(uri) - try { - model.loadCalendar(calendarId) - } catch (e: FileNotFoundException) { - Toast.makeText(this, R.string.could_not_load_calendar, Toast.LENGTH_LONG).show() - finish() - } - } else { - Toast.makeText(this, R.string.calendar_permissions_required, Toast.LENGTH_LONG).show() + // handle status changes + model.successMessage.observe(this) { message -> + if (message != null) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() finish() } + } - intent.getStringExtra(ERROR_MESSAGE)?.let { error -> - AlertFragment.create(error, intent.getSerializableExtra(THROWABLE) as? Throwable) - .show(supportFragmentManager, null) + // show error message from calling intent, if available + if (inState == null) + intent.getStringExtra(EXTRA_ERROR_MESSAGE)?.let { error -> + AlertFragment.create(error, intent.getSerializableExtra(EXTRA_THROWABLE) as? Throwable) + .show(supportFragmentManager, null) } - } + // FIXME crashes on back button. To reproduce: open subscription list / edit subscription / if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) onBackInvokedDispatcher.registerOnBackInvokedCallback( OnBackInvokedDispatcher.PRIORITY_DEFAULT, @@ -128,9 +127,9 @@ class EditCalendarActivity: AppCompatActivity() { .setVisible(dirty) // if local file, hide authentication fragment - val uri = Uri.parse(model.calendar.value?.url) + val uri = model.subscriptionWithCredential.value?.subscription?.url binding.credentials.visibility = - if (HttpUtils.supportsAuthentication(uri)) + if (uri != null && HttpUtils.supportsAuthentication(uri)) View.VISIBLE else View.GONE @@ -148,35 +147,42 @@ class EditCalendarActivity: AppCompatActivity() { return true } - private fun onCalendarLoaded(calendar: LocalCalendar) { - titleColorModel.url.value = calendar.url - calendar.displayName.let { + private fun onSubscriptionLoaded(subscriptionWithCredential: SubscriptionsDao.SubscriptionWithCredential) { + val subscription = subscriptionWithCredential.subscription + + titleColorModel.url.value = subscription.url.toString() + subscription.displayName.let { titleColorModel.originalTitle = it titleColorModel.title.value = it } - calendar.color.let { + subscription.color.let { titleColorModel.originalColor = it titleColorModel.color.value = it } - calendar.ignoreEmbeddedAlerts.let { + subscription.ignoreEmbeddedAlerts.let { titleColorModel.originalIgnoreAlerts = it titleColorModel.ignoreAlerts.postValue(it) } - calendar.defaultAlarmMinutes.let { + subscription.defaultAlarmMinutes.let { titleColorModel.originalDefaultAlarmMinutes = it titleColorModel.defaultAlarmMinutes.postValue(it) } - model.active.value = calendar.isSynced - - val (username, password) = CalendarCredentials(this).get(calendar) - val requiresAuth = username != null && password != null + val credential = subscriptionWithCredential.credential + val requiresAuth = credential != null credentialsModel.originalRequiresAuth = requiresAuth credentialsModel.requiresAuth.value = requiresAuth - credentialsModel.originalUsername = username - credentialsModel.username.value = username - credentialsModel.originalPassword = password - credentialsModel.password.value = password + + if (credential != null) { + credential.username.let { username -> + credentialsModel.originalUsername = username + credentialsModel.username.value = username + } + credential.password.let { password -> + credentialsModel.originalPassword = password + credentialsModel.password.value = password + } + } } @@ -185,61 +191,24 @@ class EditCalendarActivity: AppCompatActivity() { private fun handleOnBackPressed() { if (dirty()) supportFragmentManager.beginTransaction() - .add(SaveDismissDialogFragment(), null) - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - .commit() + .add(SaveDismissDialogFragment(), null) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() } fun onSave(item: MenuItem?) { - var success = false - model.calendar.value?.let { calendar -> - try { - val values = ContentValues(5) - values.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, titleColorModel.title.value) - values.put(CalendarContract.Calendars.CALENDAR_COLOR, titleColorModel.color.value) - values.put(CalendarContract.Calendars.SYNC_EVENTS, if (model.active.value == true) 1 else 0) - values.put(LocalCalendar.COLUMN_DEFAULT_ALARM, titleColorModel.defaultAlarmMinutes.value) - values.put(LocalCalendar.COLUMN_IGNORE_EMBEDDED, titleColorModel.ignoreAlerts.value) - calendar.update(values) - - SyncWorker.run(this, forceResync = true) - - credentialsModel.let { model -> - val credentials = CalendarCredentials(this) - if (model.requiresAuth.value == true) - credentials.put(calendar, model.username.value, model.password.value) - else - credentials.put(calendar, null, null) - } - success = true - } catch(e: CalendarStorageException) { - Log.e(Constants.TAG, "Couldn't update calendar", e) - } - } - Toast.makeText(this, getString(if (success) R.string.edit_calendar_saved else R.string.edit_calendar_failed), Toast.LENGTH_SHORT).show() - finish() + model.updateSubscription(titleColorModel, credentialsModel) } fun onAskDelete(item: MenuItem) { supportFragmentManager.beginTransaction() - .add(DeleteDialogFragment(), null) - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - .commit() + .add(DeleteDialogFragment(), null) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() } private fun onDelete() { - var success = false - model.calendar.value?.let { - try { - it.delete() - CalendarCredentials(this).put(it, null, null) - success = true - } catch(e: CalendarStorageException) { - Log.e(Constants.TAG, "Couldn't delete calendar") - } - } - Toast.makeText(this, getString(if (success) R.string.edit_calendar_deleted else R.string.edit_calendar_failed), Toast.LENGTH_SHORT).show() - finish() + model.removeSubscription() } fun onCancel(item: MenuItem?) { @@ -247,90 +216,120 @@ class EditCalendarActivity: AppCompatActivity() { } fun onShare(item: MenuItem) { - model.calendar.value?.let { + model.subscriptionWithCredential.value?.let { (subscription, _) -> ShareCompat.IntentBuilder.from(this) - .setSubject(it.displayName) - .setText(it.url) + .setSubject(subscription.displayName) + .setText(subscription.url.toString()) .setType("text/plain") .setChooserTitle(R.string.edit_calendar_send_url) .startChooser() } } - private fun dirty(): Boolean { - val calendar = model.calendar.value ?: return false - return calendar.isSynced != model.active.value || - titleColorModel.dirty() || - credentialsModel.dirty() - } + private fun dirty(): Boolean = titleColorModel.dirty() || credentialsModel.dirty() /* view model and data source */ - class CalendarModel( - application: Application + class SubscriptionModel( + application: Application, + private val subscriptionId: Long ): AndroidViewModel(application) { - var loaded = false + private val db = AppDatabase.getInstance(application) + private val credentialsDao = db.credentialsDao() + private val subscriptionsDao = db.subscriptionsDao() + + val successMessage = MutableLiveData() + val errorMessage = MutableLiveData() - var calendar = MutableLiveData() - val active = MutableLiveData() + val subscriptionWithCredential = db.subscriptionsDao().getWithCredentialsByIdLive(subscriptionId) /** - * Loads the requested calendar from the Calendar Provider. - * - * @param id calendar ID - * - * @throws FileNotFoundException when the calendar doesn't exist (anymore) + * Updates the loaded subscription from the data provided by the view models. */ - fun loadCalendar(id: Long) { - @SuppressLint("Recycle") - val provider = getApplication().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) ?: return - try { - calendar.value = LocalCalendar.findById(AppAccount.get(getApplication()), provider, id) - } finally { - provider.release() + fun updateSubscription( + titleColorModel: TitleColorFragment.TitleColorModel, + credentialsModel: CredentialsFragment.CredentialsModel + ) { + viewModelScope.launch(Dispatchers.IO) { + subscriptionWithCredential.value?.let { subscriptionWithCredentials -> + val subscription = subscriptionWithCredentials.subscription + + val newSubscription = subscription.copy( + displayName = titleColorModel.title.value ?: subscription.displayName, + color = titleColorModel.color.value, + defaultAlarmMinutes = titleColorModel.defaultAlarmMinutes.value, + ignoreEmbeddedAlerts = titleColorModel.ignoreAlerts.value ?: false + ) + subscriptionsDao.update(newSubscription) + + if (credentialsModel.requiresAuth.value == true) { + val username = credentialsModel.username.value + val password = credentialsModel.password.value + if (username != null && password != null) + credentialsDao.upsert(Credential(subscriptionId, username, password)) + } else + credentialsDao.removeBySubscriptionId(subscriptionId) + + // notify UI about success + successMessage.postValue(getApplication().getString(R.string.edit_calendar_saved)) + + // sync the subscription to reflect the changes in the calendar provider + SyncWorker.run(getApplication(), forceResync = true) + } } } - } + /** + * Removes the loaded subscription. + */ + fun removeSubscription() { + viewModelScope.launch(Dispatchers.IO) { + subscriptionWithCredential.value?.let { subscriptionWithCredentials -> + subscriptionsDao.delete(subscriptionWithCredentials.subscription) + // notify UI about success + successMessage.postValue(getApplication().getString(R.string.edit_calendar_deleted)) + } + } + } - /* "Save or dismiss" dialog */ + } - class SaveDismissDialogFragment: DialogFragment() { + + /** "Really delete?" dialog */ + class DeleteDialogFragment: DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?) = - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.edit_calendar_unsaved_changes) - .setPositiveButton(R.string.edit_calendar_save) { dialog, _ -> - dialog.dismiss() - (activity as? EditCalendarActivity)?.onSave(null) - } - .setNegativeButton(R.string.edit_calendar_dismiss) { dialog, _ -> - dialog.dismiss() - (activity as? EditCalendarActivity)?.onCancel(null) - } - .create() + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.edit_calendar_really_delete) + .setPositiveButton(R.string.edit_calendar_delete) { dialog, _ -> + dialog.dismiss() + (activity as EditCalendarActivity?)?.onDelete() + } + .setNegativeButton(R.string.edit_calendar_cancel) { dialog, _ -> + dialog.dismiss() + } + .create() } - - /* "Really delete?" dialog */ - - class DeleteDialogFragment: DialogFragment() { + /** "Save or dismiss" dialog */ + class SaveDismissDialogFragment: DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?) = - AlertDialog.Builder(requireActivity()) - .setMessage(R.string.edit_calendar_really_delete) - .setPositiveButton(R.string.edit_calendar_delete) { dialog, _ -> - dialog.dismiss() - (activity as EditCalendarActivity?)?.onDelete() - } - .setNegativeButton(R.string.edit_calendar_cancel) { dialog, _ -> - dialog.dismiss() - } - .create() + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.edit_calendar_unsaved_changes) + .setPositiveButton(R.string.edit_calendar_save) { dialog, _ -> + dialog.dismiss() + (activity as? EditCalendarActivity)?.onSave(null) + } + .setNegativeButton(R.string.edit_calendar_dismiss) { dialog, _ -> + dialog.dismiss() + (activity as? EditCalendarActivity)?.onCancel(null) + } + .create() } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt b/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt index bf925c1..2eb34ad 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt @@ -8,7 +8,9 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.os.Build +import androidx.core.app.NotificationCompat import at.bitfire.icsdroid.R object NotificationUtils { @@ -36,4 +38,25 @@ object NotificationUtils { return nm } + /** + * Shows a notification informing the user that the calendar permission is required but has not + * been granted. + */ + fun showCalendarPermissionNotification(context: Context) { + val nm = createChannels(context) + val askPermissionsIntent = Intent(context, CalendarListActivity::class.java).apply { + putExtra(CalendarListActivity.EXTRA_REQUEST_CALENDAR_PERMISSION, true) + } + val notification = NotificationCompat.Builder(context, CHANNEL_SYNC) + .setSmallIcon(R.drawable.ic_sync_problem_white) + .setContentTitle(context.getString(R.string.sync_permission_required)) + .setContentText(context.getString(R.string.sync_permission_required_sync_calendar)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setContentIntent(PendingIntent.getActivity(context, 0, askPermissionsIntent, PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat)) + .setAutoCancel(true) + .setLocalOnly(true) + .build() + nm.notify(NOTIFY_PERMISSION, notification) + } + } \ No newline at end of file diff --git a/app/src/main/res/layout/calendar_list_activity.xml b/app/src/main/res/layout/calendar_list_activity.xml index 60f1163..c3e33c8 100644 --- a/app/src/main/res/layout/calendar_list_activity.xml +++ b/app/src/main/res/layout/calendar_list_activity.xml @@ -5,7 +5,7 @@ tools:context=".ui.CalendarListActivity"> - + @@ -25,7 +25,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - android:visibility="@{model.calendars.empty ? View.GONE : View.VISIBLE }" /> + android:visibility="@{model.subscriptions.empty ? View.GONE : View.VISIBLE }" /> @@ -37,7 +37,7 @@ style="@style/TextAppearance.MaterialComponents.Body1" android:layout_margin="16dp" android:text="@string/calendar_list_empty_info" - android:visibility="@{model.calendars.empty ? View.VISIBLE : View.GONE }" /> + android:visibility="@{model.subscriptions.empty ? View.VISIBLE : View.GONE }" /> - + - - Next Calendar permissions required + Notification permissions required Couldn\'t load calendar Sync problems Permissions required + Grant My subscriptions @@ -18,10 +20,6 @@ About ICSx⁵ not synchronized yet Set sync. interval - Sync. disabled - Automatic sync disabled - System-wide automatic sync is disabled - Activate Battery: Whitelist ICSx⁵ for short sync intervals Settings diff --git a/build.gradle b/build.gradle index 6542c0a..7428e0a 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ buildscript { kotlin: '1.7.20', okhttp: '5.0.0-alpha.10', ksp: '1.0.7', - room: '2.4.3' + room: '2.5.0' ] repositories { -- GitLab From f23a7368fde0266e560dbb0da6ebabe5945424bd Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 20 Feb 2023 11:08:29 +0100 Subject: [PATCH 14/51] Fixed back handler (#114) * Fixed back handler Signed-off-by: Arnau Mora * Amend back pressing --------- Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- .../icsdroid/ui/EditCalendarActivity.kt | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt index bcdd052..a13df8f 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt @@ -5,13 +5,11 @@ package at.bitfire.icsdroid.ui import android.app.Application -import android.os.Build import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.Toast -import android.window.OnBackInvokedDispatcher import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog @@ -102,13 +100,17 @@ class EditCalendarActivity: AppCompatActivity() { .show(supportFragmentManager, null) } - // FIXME crashes on back button. To reproduce: open subscription list / edit subscription / - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - ) { handleOnBackPressed() } - else - onBackPressedDispatcher.addCallback { handleOnBackPressed() } + onBackPressedDispatcher.addCallback { + if (dirty()) { + // If the form is dirty, warn the user about losing changes + supportFragmentManager.beginTransaction() + .add(SaveDismissDialogFragment(), null) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .commit() + } else + // Otherwise, simply finish the activity + finish() + } } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -188,14 +190,6 @@ class EditCalendarActivity: AppCompatActivity() { /* user actions */ - private fun handleOnBackPressed() { - if (dirty()) - supportFragmentManager.beginTransaction() - .add(SaveDismissDialogFragment(), null) - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - .commit() - } - fun onSave(item: MenuItem?) { model.updateSubscription(titleColorModel, credentialsModel) } -- GitLab From eb4cf63288ac06271e916e67a3ba4b0c6d27fec5 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Tue, 21 Feb 2023 10:48:58 +0100 Subject: [PATCH 15/51] Fixed deprecations and warnings (#115) * Fixed deprecations and warnings Signed-off-by: Arnau Mora * Removed own compat methods Signed-off-by: Arnau Mora --------- Signed-off-by: Arnau Mora --- app/src/main/java/at/bitfire/icsdroid/AppAccount.kt | 2 +- .../main/java/at/bitfire/icsdroid/PeriodicSyncWorker.kt | 2 +- app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt | 4 ++-- app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt | 9 +++++---- .../bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt | 2 +- .../main/java/at/bitfire/icsdroid/ui/AlertFragment.kt | 2 +- .../java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt | 3 +-- .../java/at/bitfire/icsdroid/ui/NotificationUtils.kt | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/AppAccount.kt b/app/src/main/java/at/bitfire/icsdroid/AppAccount.kt index 051d1a4..5f1a5d5 100644 --- a/app/src/main/java/at/bitfire/icsdroid/AppAccount.kt +++ b/app/src/main/java/at/bitfire/icsdroid/AppAccount.kt @@ -14,7 +14,7 @@ import android.util.Log object AppAccount { private const val DEFAULT_SYNC_INTERVAL = 24*3600L // 1 day - const val SYNC_INTERVAL_MANUALLY = -1L + private const val SYNC_INTERVAL_MANUALLY = -1L private const val PREF_ACCOUNT = "account" private const val KEY_SYNC_INTERVAL = "syncInterval" diff --git a/app/src/main/java/at/bitfire/icsdroid/PeriodicSyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/PeriodicSyncWorker.kt index 0b2145f..6a6d1c4 100644 --- a/app/src/main/java/at/bitfire/icsdroid/PeriodicSyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/PeriodicSyncWorker.kt @@ -15,7 +15,7 @@ class PeriodicSyncWorker( ): Worker(context, workerParams) { companion object { - const val NAME = "PeriodicSync" + private const val NAME = "PeriodicSync" fun setInterval(context: Context, seconds: Long?) { val wm = WorkManager.getInstance(context) diff --git a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt index d3d87ee..b9b1a9d 100644 --- a/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/PermissionUtils.kt @@ -17,7 +17,7 @@ import androidx.core.content.ContextCompat object PermissionUtils { - val CALENDAR_PERMISSIONS = arrayOf( + private val CALENDAR_PERMISSIONS = arrayOf( Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR ) @@ -59,7 +59,7 @@ object PermissionUtils { * * @return The request launcher for launching the request. */ - fun registerPermissionRequest( + private fun registerPermissionRequest( activity: AppCompatActivity, permissions: Array, @StringRes toastMessage: Int, diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt index 94fb2f2..4155d9c 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt @@ -75,7 +75,7 @@ class SyncWorker( private val subscriptionsDao = database.subscriptionsDao() private val credentialsDao = database.credentialsDao() - val account = AppAccount.get(applicationContext) + private val account = AppAccount.get(applicationContext) lateinit var provider: ContentProviderClient private var forceReSync: Boolean = false @@ -121,6 +121,7 @@ class SyncWorker( * 2. Checks that those calendars have a matching [Subscription] in the database. * 3. If there's no matching [Subscription], create it. */ + @Suppress("DEPRECATION") private fun migrateLegacyCalendars() { val legacyCredentials = CalendarCredentials(applicationContext) @@ -157,17 +158,17 @@ class SyncWorker( for (subscription in subscriptions) { val calendar = calendars.remove(subscription.id) if (calendar != null) { - Log.d(Constants.TAG, "Updating local calendar #${calendar.id} from subscription") + Log.d(TAG, "Updating local calendar #${calendar.id} from subscription") calendar.update(subscription.toCalendarProperties()) } else { - Log.d(Constants.TAG, "Creating local calendar from subscription #${subscription.id}") + Log.d(TAG, "Creating local calendar from subscription #${subscription.id}") AndroidCalendar.create(account, provider, subscription.toCalendarProperties()) } } // remove remaining calendars for (calendar in calendars.values) { - Log.d(Constants.TAG, "Removing local calendar #${calendar.id} without subscription") + Log.d(TAG, "Removing local calendar #${calendar.id} without subscription") calendar.delete() } } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt index a46d07d..ab42cab 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt @@ -27,7 +27,7 @@ class AddCalendarEnterUrlFragment: Fragment() { private val credentialsModel by activityViewModels() private lateinit var binding: AddCalendarEnterUrlBinding - val pickFile = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + private val pickFile = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> if (uri != null) { // keep the picked file accessible after the first sync and reboots requireActivity().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt index 755eea7..eda02f2 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt @@ -46,7 +46,7 @@ class AlertFragment: DialogFragment() { ex.printStackTrace(PrintWriter(details)) } - val share = ShareCompat.IntentBuilder.from(requireActivity()) + val share = ShareCompat.IntentBuilder(requireActivity()) .setType("text/plain") .setText(details.toString()) .createChooserIntent() diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt index a13df8f..a2d4471 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt @@ -211,7 +211,7 @@ class EditCalendarActivity: AppCompatActivity() { fun onShare(item: MenuItem) { model.subscriptionWithCredential.value?.let { (subscription, _) -> - ShareCompat.IntentBuilder.from(this) + ShareCompat.IntentBuilder(this) .setSubject(subscription.displayName) .setText(subscription.url.toString()) .setType("text/plain") @@ -235,7 +235,6 @@ class EditCalendarActivity: AppCompatActivity() { private val subscriptionsDao = db.subscriptionsDao() val successMessage = MutableLiveData() - val errorMessage = MutableLiveData() val subscriptionWithCredential = db.subscriptionsDao().getWithCredentialsByIdLive(subscriptionId) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt b/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt index 2eb34ad..510f726 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt @@ -52,7 +52,7 @@ object NotificationUtils { .setContentTitle(context.getString(R.string.sync_permission_required)) .setContentText(context.getString(R.string.sync_permission_required_sync_calendar)) .setCategory(NotificationCompat.CATEGORY_ERROR) - .setContentIntent(PendingIntent.getActivity(context, 0, askPermissionsIntent, PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat)) + .setContentIntent(PendingIntent.getActivity(context, 0, askPermissionsIntent, PendingIntent.FLAG_UPDATE_CURRENT + flagImmutableCompat)) .setAutoCancel(true) .setLocalOnly(true) .build() -- GitLab From 4eec7d634420718da08c1cdb05259a6f77d3c3b4 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 21 Feb 2023 11:40:52 +0100 Subject: [PATCH 16/51] Update dependencies --- app/build.gradle | 14 +++++++------- build.gradle | 4 ++-- cert4android | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- ical4android | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 1b9690a..c709cbe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -156,20 +156,20 @@ gradle.projectsEvaluated { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.0' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2' implementation project(':cert4android') implementation project(':ical4android') - implementation 'androidx.appcompat:appcompat:1.6.0-rc01' + implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.core:core-ktx:1.9.0' - implementation 'androidx.fragment:fragment-ktx:1.5.4' + implementation 'androidx.fragment:fragment-ktx:1.5.5' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'androidx.work:work-runtime-ktx:2.7.1' - implementation 'com.google.android.material:material:1.7.0' + implementation 'androidx.work:work-runtime-ktx:2.8.0' + implementation 'com.google.android.material:material:1.8.0' implementation 'com.jaredrummler:colorpicker:1.1.0' implementation "com.mikepenz:aboutlibraries:${versions.aboutLibs}" @@ -189,12 +189,12 @@ dependencies { ksp "androidx.room:room-compiler:${versions.room}" // for tests - androidTestImplementation 'androidx.test:runner:1.5.0' + androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation "androidx.test:rules:1.5.0" androidTestImplementation "androidx.arch.core:core-testing:2.1.0" androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" - androidTestImplementation "androidx.work:work-testing:2.7.1" + androidTestImplementation "androidx.work:work-testing:2.8.1" testImplementation 'junit:junit:4.13.2' } diff --git a/build.gradle b/build.gradle index 7428e0a..6122a92 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext.versions = [ aboutLibs: '8.9.4', kotlin: '1.7.20', - okhttp: '5.0.0-alpha.10', + okhttp: '5.0.0-alpha.11', ksp: '1.0.7', room: '2.5.0' ] @@ -15,7 +15,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.3.1' + classpath 'com.android.tools.build:gradle:7.4.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:${versions.aboutLibs}" classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${versions.kotlin}-${versions.ksp}" diff --git a/cert4android b/cert4android index fb66126..3428543 160000 --- a/cert4android +++ b/cert4android @@ -1 +1 @@ -Subproject commit fb66126278ea63419eb192c7d4620575814d51e7 +Subproject commit 342854322b04a3a51815c016d30d8e54534956a4 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 92f06b5..f42e62f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/ical4android b/ical4android index f4cb518..43ef146 160000 --- a/ical4android +++ b/ical4android @@ -1 +1 @@ -Subproject commit f4cb518a08bdab97daab9e4d17887e1d504b87d1 +Subproject commit 43ef1460bf457dadfae9bc432b3041415cd88329 -- GitLab From f290664612b23b23d4b265ef2af3f3bb7c133501 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 21 Feb 2023 12:35:59 +0100 Subject: [PATCH 17/51] Fix androidx.work:work-testing dependency --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index c709cbe..7d0f0b4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -194,7 +194,7 @@ dependencies { androidTestImplementation "androidx.arch.core:core-testing:2.1.0" androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" - androidTestImplementation "androidx.work:work-testing:2.8.1" + androidTestImplementation "androidx.work:work-testing:2.8.0" testImplementation 'junit:junit:4.13.2' } -- GitLab From 404bb9b808d117602e52ae716f8e6d1765a12e03 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Tue, 21 Feb 2023 23:27:09 +0100 Subject: [PATCH 18/51] Enabled automatic id generation (#117) * Update dependencies * Enabled automatic id generation Signed-off-by: Arnau Mora * Updated schema Signed-off-by: Arnau Mora --------- Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- .../at.bitfire.icsdroid.db.AppDatabase/1.json | 14 +++++++------- .../at/bitfire/icsdroid/db/entity/Subscription.kt | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json index f584575..be60d10 100644 --- a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json +++ b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "3d3efde41926a4c941fe31fe482723d6", + "identityHash": "aa152cc4e5846c386d67f531d02ab2fe", "entities": [ { "tableName": "subscriptions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `color` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `color` INTEGER)", "fields": [ { "fieldPath": "id", @@ -70,10 +70,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "id" - ], - "autoGenerate": false + ] }, "indices": [], "foreignKeys": [] @@ -102,10 +102,10 @@ } ], "primaryKey": { + "autoGenerate": false, "columnNames": [ "subscriptionId" - ], - "autoGenerate": false + ] }, "indices": [], "foreignKeys": [ @@ -126,7 +126,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3d3efde41926a4c941fe31fe482723d6')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aa152cc4e5846c386d67f531d02ab2fe')" ] } } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt index eeff805..3f0982f 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt @@ -18,7 +18,7 @@ import at.bitfire.icsdroid.db.LocalCalendar @Entity(tableName = "subscriptions") data class Subscription( /** The id of the subscription in the database. */ - @PrimaryKey val id: Long = 0L, + @PrimaryKey(autoGenerate = true) val id: Long = 0L, /** URL of iCalendar file */ val url: Uri, /** ETag at last successful sync */ -- GitLab From 3c82c60acf1b0a9ff42933f3b8db27bebb1166b9 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 21 Feb 2023 23:29:19 +0100 Subject: [PATCH 19/51] Automatically generate release notes --- .github/release.yml | 17 +++++++++++++++++ .github/workflows/release.yml | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 .github/release.yml diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..02026d3 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,17 @@ +changelog: + exclude: + labels: + - ignore-for-release + categories: + - title: New features + labels: + - enhancement + - title: Bug fixes + labels: + - bug + - title: Refactoring + labels: + - refactoring + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b82868..55d6460 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,10 +32,11 @@ jobs: ANDROID_KEY_PASSWORD: ${{ secrets.android_key_password }} - name: Create Github release (from standard flavor) - uses: softprops/action-gh-release@v0.1.14 + uses: softprops/action-gh-release@v1 with: prerelease: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} files: app/build/outputs/apk/standard/release/*.apk + generate_release_notes: true fail_on_unmatched_files: true - name: Upload to Google Play (gplay flavor) @@ -47,4 +48,3 @@ jobs: mappingFile: app/build/outputs/mapping/gplayRelease/mapping.txt track: internal status: draft - -- GitLab From b9238b2040f97946c6eeca8de55ebcf087bb5330 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 21 Feb 2023 23:30:05 +0100 Subject: [PATCH 20/51] Version bump to 2.1-beta.2 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7d0f0b4..14897e7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { minSdkVersion 21 targetSdkVersion 33 - versionCode 67 - versionName "2.1-beta.1" + versionCode 68 + versionName "2.1-beta.2" setProperty "archivesBaseName", "icsx5-" + getVersionCode() + "-" + getVersionName() -- GitLab From d70471a0dcc57e213c96c47552a4e9f625c9ad55 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Thu, 23 Feb 2023 10:57:41 +0100 Subject: [PATCH 21/51] Enabled migration on database creation (#121) Signed-off-by: Arnau Mora --- .../java/at/bitfire/icsdroid/SyncWorker.kt | 22 ++++++++++++++++--- .../at/bitfire/icsdroid/db/AppDatabase.kt | 10 ++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt index 4155d9c..081d6e6 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt @@ -34,6 +34,12 @@ class SyncWorker( */ private const val FORCE_RESYNC = "forceResync" + /** + * An input data for the Worker that tells if only migration should be performed, without + * fetching data. + */ + private const val ONLY_MIGRATE = "onlyMigration" + /** * Enqueues a sync job for immediate execution. If the sync is forced, * the "requires network connection" constraint won't be set. @@ -41,10 +47,16 @@ class SyncWorker( * @param context required for managing work * @param force *true* enqueues the sync regardless of the network state; *false* adds a [NetworkType.CONNECTED] constraint * @param forceResync *true* ignores all locally stored data and fetched everything from the server again + * @param onlyMigrate *true* only runs synchronization, without fetching data. */ - fun run(context: Context, force: Boolean = false, forceResync: Boolean = false) { + fun run(context: Context, force: Boolean = false, forceResync: Boolean = false, onlyMigrate: Boolean = false) { val request = OneTimeWorkRequestBuilder() - .setInputData(workDataOf(FORCE_RESYNC to forceResync)) + .setInputData( + workDataOf( + FORCE_RESYNC to forceResync, + ONLY_MIGRATE to onlyMigrate, + ) + ) val policy: ExistingWorkPolicy = if (force) { Log.i(TAG, "Manual sync, ignoring network condition") @@ -82,13 +94,17 @@ class SyncWorker( override suspend fun doWork(): Result { forceReSync = inputData.getBoolean(FORCE_RESYNC, false) - Log.i(TAG, "Synchronizing (forceReSync=$forceReSync)") + val onlyMigrate = inputData.getBoolean(ONLY_MIGRATE, false) + Log.i(TAG, "Synchronizing (forceReSync=$forceReSync,onlyMigrate=$onlyMigrate)") provider = LocalCalendar.getCalendarProvider(applicationContext) try { // migrate old calendar-based subscriptions to database migrateLegacyCalendars() + // Do not synchronize if onlyMigrate is true + if (onlyMigrate) return Result.success() + // update local calendars according to the subscriptions updateLocalCalendars() diff --git a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt index ee20674..107e862 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt @@ -10,6 +10,8 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.sqlite.db.SupportSQLiteDatabase +import at.bitfire.icsdroid.SyncWorker import at.bitfire.icsdroid.db.AppDatabase.Companion.getInstance import at.bitfire.icsdroid.db.dao.CredentialsDao import at.bitfire.icsdroid.db.dao.SubscriptionsDao @@ -56,6 +58,12 @@ abstract class AppDatabase : RoomDatabase() { // create a new instance and save it val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "icsx5") .fallbackToDestructiveMigration() + .addCallback(object : Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + super.onCreate(db) + SyncWorker.run(context, onlyMigrate = true) + } + }) .build() instance = db return db @@ -65,5 +73,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun subscriptionsDao(): SubscriptionsDao abstract fun credentialsDao(): CredentialsDao - + } -- GitLab From 4cbe8b3e79c0740f757eeebc0e672dbdca7c97df Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Thu, 23 Feb 2023 23:34:30 +0100 Subject: [PATCH 22/51] Enabled AutoBackup (#119) * Enabled AutoBackup Signed-off-by: Arnau Mora * Remove FIXME --------- Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- app/src/main/AndroidManifest.xml | 5 ++++- app/src/main/res/xml/backup_rules.xml | 6 ++++++ app/src/main/res/xml/data_extraction_rules.xml | 9 +++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5194ed8..ad457b2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,7 +33,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..f162389 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file -- GitLab From 9b8f5eb30401965e101299b655f3ab0737c503d7 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 24 Feb 2023 16:42:21 +0100 Subject: [PATCH 23/51] Version bump to 2.1-beta.3 --- app/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 14897e7..a3cee3a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { minSdkVersion 21 targetSdkVersion 33 - versionCode 68 - versionName "2.1-beta.2" + versionCode 69 + versionName "2.1-beta.3" setProperty "archivesBaseName", "icsx5-" + getVersionCode() + "-" + getVersionName() @@ -197,4 +197,4 @@ dependencies { androidTestImplementation "androidx.work:work-testing:2.8.0" testImplementation 'junit:junit:4.13.2' -} +} \ No newline at end of file -- GitLab From 19a64c78b25131b769ad27dc283749ede00cb6bb Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sun, 26 Feb 2023 23:38:35 +0100 Subject: [PATCH 24/51] Fetch translations from Transifex (closes #118) --- .tx/config | 14 ++- app/src/main/res/values-ca/strings.xml | 101 ++++++++++++++++++ app/src/main/res/values-cs/strings.xml | 2 - app/src/main/res/values-da/strings.xml | 89 +++++++++++++++ app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values-es/strings.xml | 4 - app/src/main/res/values-fr/strings.xml | 7 +- app/src/main/res/values-gl/strings.xml | 84 +++++++++++++++ app/src/main/res/values-hu/strings.xml | 6 +- app/src/main/res/values-ja/strings.xml | 97 +++++++++++++++++ app/src/main/res/values-nl/strings.xml | 17 ++- app/src/main/res/values-pt-rBR/strings.xml | 4 - app/src/main/res/values-pt-rPT/strings.xml | 83 ++++++++++++++ app/src/main/res/values-pt/strings.xml | 4 - app/src/main/res/values-ru-rUA/strings.xml | 83 ++++++++++++++ app/src/main/res/values-ru/strings.xml | 19 +++- app/src/main/res/values-si/strings.xml | 33 ++++++ .../{values-uk => values-uk-rUA}/strings.xml | 5 +- app/src/main/res/values-zh/strings.xml | 19 +++- scripts/fetch-translations.sh | 8 ++ 20 files changed, 630 insertions(+), 55 deletions(-) create mode 100644 app/src/main/res/values-ca/strings.xml create mode 100644 app/src/main/res/values-da/strings.xml create mode 100644 app/src/main/res/values-gl/strings.xml create mode 100644 app/src/main/res/values-ja/strings.xml create mode 100644 app/src/main/res/values-pt-rPT/strings.xml create mode 100644 app/src/main/res/values-ru-rUA/strings.xml create mode 100644 app/src/main/res/values-si/strings.xml rename app/src/main/res/{values-uk => values-uk-rUA}/strings.xml (92%) create mode 100755 scripts/fetch-translations.sh diff --git a/.tx/config b/.tx/config index 3f09462..1e4673c 100644 --- a/.tx/config +++ b/.tx/config @@ -1,12 +1,10 @@ [main] host = https://www.transifex.com +lang_map = pt_BR: pt-rBR, pt_PT: pt-rPT, ru_UA: ru-rUA, uk_UA: uk-rUA -[icsx5.icsx5] -file_filter = app/src/main/res/values-/strings.xml +[o:bitfireAT:p:icsx5:r:icsx5] +file_filter = app/src/main/res/values-/strings.xml +source_file = app/src/main/res/values/strings.xml +source_lang = en +type = ANDROID minimum_perc = 0 -source_file = app/src/main/res/values/strings.xml -source_lang = en -trans.pt_PT = app/src/main/res/values-pt/strings.xml -trans.pt_BR = app/src/main/res/values-pt-rBR/strings.xml -type = ANDROID - diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..fad2f22 --- /dev/null +++ b/app/src/main/res/values-ca/strings.xml @@ -0,0 +1,101 @@ + + + Subscripcions a calendaris + + Següent + Cal permís d\'accés al calendari + No s\'ha pogut carregar el calendari + Problemes de sincronització + Permisos necessaris + + Les meues subscripcions + Per a subscriure\'t a un fil Webcal, prem el botó +, o obre una URL Webcal. + Sobre ICSx⁵ + encara no sincronitzat + Període de sincronització + Bateria: afegeix ICSx⁵ a la llista blanca per a intervals curts + Configuració + + Força el tema obscur + + Subscriure\'s a un calendari + Cal una URI vàlida + Altres persones poden interceptar les teues credencials, utilitza HTTPS per a una autentificació segura. + Subscriure\'s ara + Subscrit correctament + Contrassenya + Cal autenticar-se + Títol i Color + Nom del calendari + Introdueix una direcció Webcal: + https://example.com/webcal.ics + També pots seleccionar un fitxer de l\'emmagatzematge local. + Tria un fitxer + Nom d\'usuari + Validant el recurs de calendari… + Alarmes + Ignora les alertes incloses al calendari + Si està habilitat, totes les alarmes que vinguen del servidor seran eliminades. + Afegeix una alarma per defecte per als esdeveniments + Alarmes establertes per a %s abans + Afegir alarma per defecte + Açò afegirà una alarma per a tots els esdeveniments + Minuts abans de l\'esdeveniment + Establir + Introdueix un nombre vàlid + + Compartir detalls + + Edita la subscripció + Cancel·lar + Desubscriure\'s + S\'ha desubscrit correctament + Amaga + Operació fallida + Segur que et vols desubscriure d\'aquest calendari? + Desa + Canvis desats + Envia la URL + Sincronitza aquest calendari + Hi han canvis sense desar. + + Error de sincronització + Desa + Estableix el període de sincronització: + + Només manualment + Cada 15 minuts + Cada hora + Cada 2 hores + Cada 4 hores + Una vegada al dia + Setmanalment + + Permisos necessaris + Cal tindre permís per a sincronitzar el calendari + No s\'ha pogut obrir el fitxer de l\'emmagatzematge + + Informació de l\'app + Versió %1$s-%2$s + https://www.gnu.org/licenses/. + ]]> + Donar + Subscriu-te a fils Webcal + Notícies i Novetats + Lloc web + Informació de codi obert + Ens alegra que faces servir ICSx⁵, que és un programari de codi obert (GPLv3). Ja que ha sigut i segueix sent un treball dur mantindre i seguir desenvolupant ICSx⁵, per favor, considera una donació. + Mostra la pàgina de donació + Pot ser més tard + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 880f84a..95a0cd3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -5,8 +5,6 @@ O aplikaci ICSx⁵ Nastavit interval synchronizace - Synchronizace vypnuta - Automatická synchronizace vypnuta Nastavení diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..ea5f32e --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,89 @@ + + + Kalender abonnementer + + Næste + Kalenderrettigher er nødvendige + Synkroniseringsfejl + + Mine abonnementer + For at abonnere en Webcal feed, brug + knappen eller åben en Webcal URL. + Om ICSx⁵ + ikke synkroniseret endnu + Sæt interval til synkronisering + Batteri: Whitelist ICSx⁵ for korte sync intervaller + Indstillinger + + Anvend mørk tema + + Abonner kalender + Korrekt URI nødvendig + Tredjeparts personer kan nemt læse dine adgangsindstillinger. Brug HTTPS for sikker autentificering. + Abonner nu + Succesfuld abonneret + Kodeord + Kræver autentificering + Titel & Farve + Kalendernavn + Angiv Webcal adresse + https://example.com/webcal.ics + Eller, vælg fil fra lokal lagerplads. + Vælg fil + Brugernavn + Validerer kalenderkilden ... + + Del detaljer + + Rediger abonnement + Annuller + Afbestil + Afmelding succesfuld + Ryd + Operationen fejlede + Vil du virkelig stoppe abonnementet af denne kalender? + Gem + Ændringer blev gemt + Send URL + Synkroniser denne kalender + Der er ikke gemte ændringer + + Fejl ved synkronisering + Gem + Sæt interval for synkronisering + + Kun manuelt + Hvert 15. minut + Hver time + Hver 2. time + Hver 4 timer + Engang om dagen + Engang om ugen + + Kræver tilladelse + Har brug for tilladelse til at synkronisere kalender + + App information + Version %1$s-%2$s + https://www.gnu.org/licenses/ +]]> + Donér + Abonner Webcal feeds + Nyheder & opdateringer + Hjemmeside + Open-Source information + Vi er glæde, at du bruger ICSx⁵, som er open-source software (GPLv3). Fordi det var og stadig er meget arbejde at udvikle og vedligeholde ICSx⁵, venligst overvej en donation. + Vis donationsside + Senere måske + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index df050ae..9bd857a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -4,18 +4,14 @@ Weiter Kalender-Berechtigungen benötigt - Sync-Probleme Kalender konnte nicht geladen werden + Sync-Probleme Abonnierte Kalender Noch kein Webcal-Abo! Mit + oder durch Öffnen eines Webcal-fähigen URLs hinzufügen. Über ICSx⁵ noch nicht übertragen Aktualisierungsintervall festlegen - Automatische Aktualisierung deaktiviert - Automatische Synchronisierung deaktiviert - Automatische Synchronisierung systemweit deaktiviert - Aktivieren Für kurze Intervalle: Akku-Optimierung für ICSx⁵ deaktivieren Einstellungen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 10d93b4..9c8d4c5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -11,10 +11,6 @@ Acerca de ICSx⁵ aún no se ha sincronizado Marcar intervalo de sincronización - Sincronización desactivada - Sincronización automática desactivada - La sincronización automática está desactivada en todo el sistema - Activar Batería: Meter ICSx⁵ en lista blanca para intervalos de sinc. cortos Configuración diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e68779f..8e5a09e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -4,6 +4,7 @@ Suivant Permissions du calendrier requises + Impossible de charger le calendrier Problèmes de synchro. Mes abonnements @@ -11,10 +12,6 @@ À propos d\'ICSx⁵ pas encore synchronisé Intervalle de synchro. - Synchro. désactivée - Synchro. automatique désactivée - La synchro. globale automatique est désactivée - Activer Batterie : Ajoutez ICSx⁵ aux exceptions pour des intervalles de synchronisation courts Paramètres @@ -30,6 +27,7 @@ Intitulé & couleur Nom du calendrier Entrez une adresse Webcal : + https://example.com/webcal.ics Ou sinon, sélectionnez un fichier du stockage local. Choisir un fichier Nom d\'utilisateur @@ -64,6 +62,7 @@ Autorisation requise Autorisation nécessaire pour la synchro. de votre calendrier + Impossible d’ouvrir le fichier à partir de l’espace de stockage Infos sur l\'application Version %1$s-%2$s diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..0cd82c5 --- /dev/null +++ b/app/src/main/res/values-gl/strings.xml @@ -0,0 +1,84 @@ + + + Subscricións a calendarios + + Seguinte + Precísase permiso para o calendario + Problemas de Sincr + + Subscricións + Para subscribirte a unha fonte Webcal, utiliza o botón + ou abre un URL Webcal. + Acerca de ICSx⁵ + aínda non sincronizaches + Establece intervalo de sincr. + Batería: Permitelle a ICSx⁵ intervalos curtos de sincr + Axustes + + Forzar decorado escuro + + Subscribir a calendario + Require URI válido + Terceiras partes poden interceptar facilmente as túas credenciais. Usa HTTPS para autenticación segura. + Subscríbete agora + Subscrición correcta + Contrasinal + Require autenticación + Título e Cor + Nome do calendario + Nome de usuaria + Validando fonte do calendario... + + Comparte detalles + + Editar subscrición + Cancelar + Retirar subscrición + Retirada correctamente + Desbotar + Fallou a operación + Tes a certeza de que queres retirar a subscrición a este calendario? + Gardar + Cambios gardados + Enviar URL + Sincronizar este calendario + Hai cambios sen gardar. + + Erro de sincronización + Gardar + Establece intervalo de sincr.: + + Só manual + Cada 15 minutos + Cada hora + Cada 2 horas + Cada 4 horas + Unha vez ao día + Unha vez á semana + + Permiso requerido + Precísase permiso para sincronizar o calendario + + Info da app + Versión %1$s - %2$s + https://www.gnu.org/licenses/. + ]]> + Doar + Subscríbete a fontes Webcal + Novas e actualizacións + Sitio web + Información Código-Aberto + Alégranos que utilices ICSx⁵, que é software de código aberto (GPLv3). Manter e desenvolver ICSx⁵ require moito traballo, e como queda moito por facer, considera facer unha doazón. + Ir á páxina de doazóns + Máis tarde + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 9a448fb..b227b6f 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -4,18 +4,14 @@ Következő Naptár engedélyre van szükség - Szinkronizációs problémák A naptár betöltése nem sikerült + Szinkronizációs problémák Feliratkozások Webcal folyamra a + gomb használatával vagy a Webcal URL megnyitásával iratkozhat fel. Az ICSx⁵ névjegye még nincs szinkronizálva Szinkronizáció sűrűségének megadása - Szinkronizáció tiltva - Az automatikus szinkronizáció tiltva - Az automatikus szinkronizálás rendszerszinten tiltva - Aktiválás Akkumulátor: a gyakori szinkronizációhoz helyezze az ICSx⁵ alkalmazást engedélylistára Beállítások diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..5d1970c --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,97 @@ + + + カレンダーの購読 + + 次へ + カレンダーへのアクセスを許可してください + 通知を許可してください + カレンダーを読み込めませんでした + 同期に問題が発生しました + 権限を許可してください + 許可 + + 購読中のカレンダー + + ボタンから登録するか Webcal URL を開いて Webcal フィードを購読できます + ICSx⁵ について + まだ同期されていません + 同期間隔を設定 + バッテリー: 一定間隔で同期を実行するため ICSx⁵ をホワイトリストに追加してください + 設定 + + ダークテーマを使用 + + カレンダーを購読 + 有効な URI が必要です + 第三者が簡単にあなたの認証情報にアクセスできます。HTTPS を使用して認証情報を保護してください + 購読する + 購読が完了しました + パスワード + 認証情報が必要 + 件名と色 + カレンダーの名前 + Webcal アドレスを入力: + https://example.com/webcal.ics + ローカルストレージからファイルを選択することもできます + ファイルを選択 + ユーザー名 + カレンダー情報を確認しています... + アラーム + カレンダーに埋め込まれた通知を無視 + 有効にすると、そのサーバーから送信されるすべてのアラームが無視されます + すべてのイベントにデフォルトのアラームを追加 + アラームは %s前に設定されます + デフォルトのアラームを追加 + 設定すると、すべてのイベントにアラームを追加します + イベントの...分前 + 設定 + 有効な数字を入力してください + + 詳細情報を共有 + + 購読を編集 + キャンセル + 購読解除 + 購読を解除しました + 無視 + 操作に失敗しました + 本当にカレンダーの購読を解除しますか? + 保存 + 変更が保存されました + URL を送信 + このカレンダーを同期 + 保存されていない変更があります + + 同期エラー + 保存 + 同期間隔を設定: + + 手動のみ + 15 分ごと + 1 時間ごと + 2 時間ごと + 4 時間ごと + 毎日 1 回 + 毎週 1 回 + + 権限が必要です + カレンダーを同期するには権限を許可してください + ストレージのファイルを開けません + + アプリ情報 + バージョン %1$s-%2$s + https://www.gnu.org/licenses/ を参照してください。 + ]]> + 寄付 + Webcal フィードを購読 + ニュース & アップデート + ウェブサイト + オープンソース情報 + オープンソースソフトウェア (GPLv3) として ICSx⁵ をお届けできることをとても嬉しく思っています。ICSx⁵ の開発と維持を支える寄付をご検討ください。 + 寄付ページを表示 + 後で + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 17fc8d6..9fe09fc 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -4,18 +4,15 @@ Volgende Kalenderrechten nodig - Sync-problemen Kan kalender niet laden + Sync-problemen + Rechten vereist Mijn abonnementen De + knop gebruiken of een Webcal-URL openen om te abonneren op een Webcal-feed. Over ICSx⁵ nog niet gesynchroniseerd Sync-interval instellen - Sync uitgeschakeld - Automatische sync uitgeschakeld - Systeem-brede automatische sync is uitgezet - Activeren Onbeperkt batterijgebruik voor korte sync-intervallen Instellingen @@ -36,6 +33,16 @@ Bestand kiezen Gebruikersnaam Kalenderbron valideren... + Herinneringen + Negeer ingebouwde kalender-herinneringen + Bij inschakelen worden alle inkomende herinneringen van de server genegeerd. + Voeg een standaard herinnering toe voor elke gebeurtenis + Herinnering instellen op %s voor + Standaard herinnering toevoegen + Dit voegt een herinnering voor alle gebeurtenissen toe + Minuten voor gebeurtenis + Instellen + Voer een geldig getal in Details delen diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a027249..9807b0b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -11,10 +11,6 @@ Sobre o ICSx⁵ ainda não sincronizado Definir intervalo de sincronização - Sincronização desativada - Sincronização automática desativada - A sincronização automática pelo sistema está desativada - Ativar Bateria: Sincronizar ICSx⁵ em intervalos curtos Configurações diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..ce6131d --- /dev/null +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,83 @@ + + + Calendários subscritos + + Seguinte + Necessárias permissões de calendário + Problemas de sincronização + + Os meus calendários + Para subscrever uma feed Webcal, utilize o botão + ou abra um endereço/URL Webcal. + Acerca ICSx⁵ + ainda não sincronizado + Definir intervalo de sincronização + Bateria: Autorizar ICSx⁵ para permitir intervalos de sincronização curtos + Definições + + + Subscrever calendário + Endereço/URI válido necessário + As suas credenciais podem ser facilmente intercetadas por terceiros. Utilize HTTPS para uma autenticação segura. + Subscrever agora + Subscrito com sucesso + Palavra-passe + Requer autenticação + Título e cor + Nome de calendário + Nome de utilizador + A validar calendário... + + + Editar subscrição + Cancelar + Remover subscrição + Removida subscrição com sucesso + Ignorar + Operação sem sucesso + Deseja remover a subscrição deste calendário? + Salvar + Modificações guardadas + Enviar endereço + Sincronizar este calendário + Existem modificações não guardadas + + Erro de sincronização + Salvar + Intervalo de sincronização: + + Apenas manualmente + A cada 15 minutos + A cada hora + A cada 2 horas + A cada 4 horas + Diariamente + Semanalmente + + Permissão necessária + Necessária permissão para sincronizar o calendário + + Informação da aplicação + Versão %1$s-%2$s + https://www.gnu.org/licenses/. + ]]> + Doar + Subscrever feed Webcal + Notícias e updates + Sítio web + Informação Open-Source + Agradecemos o voto de confiança ao usar ICSx⁵, um software open-source (GPLv3). Porque desenvolver e manter ICSx⁵ foi e continua a ser bastante trabalhoso, por favor considere um donativo. + Mostrar página de donativos + Talvez mais tarde + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index ddd07a4..ce6131d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -11,10 +11,6 @@ Acerca ICSx⁵ ainda não sincronizado Definir intervalo de sincronização - Sincronização desativada - Sincronização automática desativada - Sincronização automática global encontra-se desativada - Ativar Bateria: Autorizar ICSx⁵ para permitir intervalos de sincronização curtos Definições diff --git a/app/src/main/res/values-ru-rUA/strings.xml b/app/src/main/res/values-ru-rUA/strings.xml new file mode 100644 index 0000000..19b4577 --- /dev/null +++ b/app/src/main/res/values-ru-rUA/strings.xml @@ -0,0 +1,83 @@ + + + Подписки на календари + + Далее + Разрешить доступ к календарю + Проблемы с синхронизацией + + Мои подписки + Для подписки на Webcal ленту, используйте кнопку + или откройте Webcal URL-ссылку. + Об ICSx⁵ + ещё не синхронизировано + Задать интервал синхронизации + Батарея: добавьте ICSx⁵ в исключения для частой синхронизации + Настройки + + + Подписаться на календарь + Требуется действительный URI + Ваши данные легко могут быть перехвачены третьими лицами. Используйте HTTPS  для безопасной аутентификации. + Подписаться сейчас + Подписка выполнена успешно + Пароль + Требуется аутентификация + Заглавие & Цвет + Название календаря + Имя пользователя + Проверка источника календаря... + + + Изменить подписку + Отмена + Отписаться + Подписка отменена + Убрать + Не удалось выполнить + Уверены, что хотите отменить подписку на этот календарь? + Сохранить + Изменения сохранены + Отправить URL-ссылку + Синхронизировать этот календарь + Имеются несохранённые изменения. + + Ошибка синхронизации + Сохранить + Задать интервал синхронизации: + + Только вручную + Каждые 15 минут + Каждый час + Каждые 2 часа + Каждые 4 часа + Раз в день + Раз в неделю + + Необходим доступ + Необходимо разрешение для синхронизации вашего календаря + + Информация о приложении + Версия %1$s-%2$s + https://www.gnu.org/licenses/. + ]]> + Пожертвовать + Подписаться на Webcal календари + Новости & обновления + Веб страница + Сведения об открытом коде + Мы рады тому, что вы используете приложение ICSx⁵, которое представляет собой программное обеспечение с открытым кодом (GPLv3). По причине того, что было потрачено, и ещё необходимо, много усилий для разработки и обслуживания ICSx⁵, пожалуйста, рассмотрите возможность пожертвования. + Показать страницу для пожертвования + Возможно, позже + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 253137c..87d9869 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -4,18 +4,17 @@ Далее Необходимы разрешения для работы с календарем - Проблемы с синхронизацией + Требуются разрешения на отправку уведомлений Не удалось загрузить календарь + Проблемы с синхронизацией + Требуются разрешения + Предоставить Мои подписки Для подписки на ленту Webcal используйте кнопку + или откройте веб-адрес Webcal. О ICSx⁵ еще не синхронизировано Задать интервал синхронизации - Синхронизация отключена - Автоматическая синхронизация отключена - Автоматическая синхронизация отключена на уровне системы - Активировать Батарея: внесите ICSx⁵ в белый список для использования небольших интервалов синхронизации Настройки @@ -36,6 +35,16 @@ Выбрать файл Имя пользователя Проверка доступности календаря... + Оповещения + Игнорировать оповещения, встроенные в календарь + Если включено, все входящие оповещения с сервера будут отклонены. + Добавить оповещение по умолчанию для всех событий + Оповещения, установленные до %s + Добавить оповещение по умолчанию + Это добавит оповещение для всех событий + За несколько минут до события + Установить + Введите действительное число Поделиться информацией diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml new file mode 100644 index 0000000..bedb8c7 --- /dev/null +++ b/app/src/main/res/values-si/strings.xml @@ -0,0 +1,33 @@ + + + + ඊලඟ + දින දසුන් අවසරය අවශ්‍යයයි + සමමුහූර්ත ගැටලු + + ICSx⁵ පිළිබඳව + සැකසුම් + + + මුරපදය + දින දසුනේ නම + පරිශීලක නාමය + + විස්තර බෙදාගන්න + + අවලංගු + සුරකින්න + වෙනස්කම් සුරැකිණි + ඒ.ස.නි. යවන්න + + සුරකින්න + අවසරය අවශ්‍යයයි + + යෙදුමේ තොරතුරු + අනුවාදය %1$s-%2$s + පරිත්‍යාග + පුවත් යාවත්කාල + වියමන අඩවිය + පරිත්‍යාග පිටුව පෙන්වන්න + සමහරවිට පසුව + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml similarity index 92% rename from app/src/main/res/values-uk/strings.xml rename to app/src/main/res/values-uk-rUA/strings.xml index 3c97d57..a62d6ed 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -10,10 +10,6 @@ Про ICSx⁵ ще не синхронізовано Встановити інтервал синхронізації - Синхронізацію вимкнено - Автоматичну синхронізацію вимкнено - На пристрої вимкнено усю автоматичну синхронізацію - Активувати Батарея: вимкніть обмеження для ICSx⁵ для частішої синхронізації Налаштування @@ -60,6 +56,7 @@ Потрібен дозвіл для синхронізації вашого календаря Інформація про додаток + Пожертва Новини & оновлення Веб-сторінка Інформація про відкритий код diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 20c2370..f5a6487 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -4,18 +4,17 @@ 继续 请授予访问日历的权限 - 同步问题 + 需要通知权限 无法加载日历 + 同步问题 + 需要权限 + 授予 我的订阅 如需订阅Webcal源,请点击 + 按钮或打开Webcal的URL地址 关于ICSx⁵ 尚未同步 设置同步间隔时间 - 未启用同步 - 自动同步已关闭 - 系统层面的自动同步已关闭 - 启用 电池:请将ICSx⁵列入允许频繁同步的白名单 设置 @@ -36,6 +35,16 @@ 选择文件 用户名 正在验证日历… + 闹铃 + 忽略嵌入在日历中的警报 + 一旦启用,所有之后来自该服务器的闹铃都将被无视。 + 为所有事件添加默认闹铃 + 闹铃设为事件发生前 %s + 添加默认闹铃 + 这会为所有事件添加一个闹铃 + 事件开始多少分钟 + 设置 + 输入的数字无效 分享详情 diff --git a/scripts/fetch-translations.sh b/scripts/fetch-translations.sh new file mode 100755 index 0000000..9b9162b --- /dev/null +++ b/scripts/fetch-translations.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +# Important! Use the new client: https://github.com/transifex/cli/ +# The old one [https://github.com/transifex/transifex-client/] which is still packaged with Ubuntu 22.10 doesn't work anymore since Nov 2022 + +MYDIR=`dirname $0`/.. +cd $MYDIR +tx pull --use-git-timestamps -a --minimum-perc 10 -- GitLab From 5f9f2ffadd7cc0d34bcecef28910298516596cb3 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sun, 26 Feb 2023 23:39:32 +0100 Subject: [PATCH 25/51] Version bump to 2.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a3cee3a..14901c7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { minSdkVersion 21 targetSdkVersion 33 - versionCode 69 - versionName "2.1-beta.3" + versionCode 70 + versionName "2.1" setProperty "archivesBaseName", "icsx5-" + getVersionCode() + "-" + getVersionName() -- GitLab From 326c3c0da8d6650de16a20cbe855dda16fd97881 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sun, 26 Feb 2023 23:46:42 +0100 Subject: [PATCH 26/51] Make sample URL non-translatable --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8db037..b85d356 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,7 +36,7 @@ Title & Color Calendar name Enter a Webcal address: - https://example.com/webcal.ics + https://example.com/webcal.ics Alternatively, select a file from local storage. Pick file User name -- GitLab From 34ab68d0cf82a8107e7f2906510ed62dd1c99da5 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 27 Feb 2023 13:13:19 +0100 Subject: [PATCH 27/51] Added Refresh option to overflow menu (#131) * Added Refresh option to overflow menu Signed-off-by: Arnau Mora * Changed wording Signed-off-by: Arnau Mora --------- Signed-off-by: Arnau Mora --- .../main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt | 4 ++++ app/src/main/res/menu/activity_calendar_list.xml | 4 ++++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 9 insertions(+) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt index 9a2b359..0544ba0 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt @@ -187,6 +187,10 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis SyncWorker.run(this, true) } + fun onRefreshRequested(item: MenuItem) { + onRefresh() + } + fun onShowInfo(item: MenuItem) { startActivity(Intent(this, InfoActivity::class.java)) } diff --git a/app/src/main/res/menu/activity_calendar_list.xml b/app/src/main/res/menu/activity_calendar_list.xml index 38d67d5..6d60e29 100644 --- a/app/src/main/res/menu/activity_calendar_list.xml +++ b/app/src/main/res/menu/activity_calendar_list.xml @@ -7,6 +7,10 @@ android:onClick="onSetSyncInterval" app:showAsAction="never"/> + + About ICSx⁵ not synchronized yet Set sync. interval + Synchronize now Battery: Whitelist ICSx⁵ for short sync intervals Settings -- GitLab From 606960ee4e7fd128ba75dd021e88b12b2830218b Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Tue, 28 Feb 2023 20:04:23 +0100 Subject: [PATCH 28/51] Fix migrations (#133) * Added calendar id to subscription Signed-off-by: Arnau Mora * Dao: use specific update methods * Fixed calendar id removal Signed-off-by: Arnau Mora * Fixed calendar matching Signed-off-by: Arnau Mora * Added url to calendar properties Signed-off-by: Arnau Mora * Fixed calendar id find Signed-off-by: Arnau Mora * Fixed test Signed-off-by: Arnau Mora * Fixed subscription id fetching Signed-off-by: Arnau Mora * Move LocalCalendar, LocalEvent to separate package * Introduce MANAGED_BY_DB column to control migration * Fix migration test * Use NULL/NOT NULL for COLUMN_MANAGED_BY_DB * Handle v2.1 migrations (don't double-migrate) * Test migration from 2.0.3 and 2.1 * More logging during migration * Improve migration tests Also closes #135 --------- Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- .../at.bitfire.icsdroid.db.AppDatabase/2.json | 138 ++++++++++++++++++ .../migration/CalendarToRoomMigrationTest.kt | 75 ++++++++-- .../at/bitfire/icsdroid/ProcessEventsTask.kt | 4 +- .../java/at/bitfire/icsdroid/SyncWorker.kt | 76 +++++++--- .../{db => calendar}/LocalCalendar.kt | 49 ++++++- .../icsdroid/{db => calendar}/LocalEvent.kt | 2 +- .../at/bitfire/icsdroid/db/AppDatabase.kt | 13 +- .../icsdroid/db/CalendarCredentials.kt | 1 + .../icsdroid/db/dao/SubscriptionsDao.kt | 13 +- .../icsdroid/db/entity/Subscription.kt | 17 +-- .../icsdroid/ui/AddCalendarActivity.kt | 3 +- .../icsdroid/ui/AddCalendarDetailsFragment.kt | 8 +- .../icsdroid/ui/ColorPickerActivity.kt | 2 +- 13 files changed, 333 insertions(+), 68 deletions(-) create mode 100644 app/schemas/at.bitfire.icsdroid.db.AppDatabase/2.json rename app/src/main/java/at/bitfire/icsdroid/{db => calendar}/LocalCalendar.kt (71%) rename app/src/main/java/at/bitfire/icsdroid/{db => calendar}/LocalEvent.kt (98%) diff --git a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/2.json b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/2.json new file mode 100644 index 0000000..4171c81 --- /dev/null +++ b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/2.json @@ -0,0 +1,138 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "d61bf6fb08b622a180a1933b983faae2", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `calendarId` INTEGER, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `color` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calendarId", + "columnName": "calendarId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreEmbeddedAlerts", + "columnName": "ignoreEmbeddedAlerts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultAlarmMinutes", + "columnName": "defaultAlarmMinutes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`subscriptionId`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd61bf6fb08b622a180a1933b983faae2')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt b/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt index e8402da..4fba697 100644 --- a/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt +++ b/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt @@ -16,6 +16,7 @@ import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import androidx.work.Configuration +import androidx.work.Data import androidx.work.ListenableWorker.Result import androidx.work.testing.SynchronousExecutor import androidx.work.testing.TestListenableWorkerBuilder @@ -23,16 +24,17 @@ import androidx.work.testing.WorkManagerTestInitHelper import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat import at.bitfire.icsdroid.AppAccount +import at.bitfire.icsdroid.Constants.TAG import at.bitfire.icsdroid.SyncWorker +import at.bitfire.icsdroid.calendar.LocalCalendar import at.bitfire.icsdroid.db.AppDatabase import at.bitfire.icsdroid.db.CalendarCredentials -import at.bitfire.icsdroid.db.LocalCalendar import at.bitfire.icsdroid.db.dao.CredentialsDao import at.bitfire.icsdroid.db.dao.SubscriptionsDao +import at.bitfire.icsdroid.db.entity.Subscription import kotlinx.coroutines.runBlocking import org.junit.* -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull +import org.junit.Assert.* class CalendarToRoomMigrationTest { @@ -104,12 +106,14 @@ class CalendarToRoomMigrationTest { Calendars.NAME to CALENDAR_URL ) ) + val calendarId = ContentUris.parseId(uri) + Log.i(TAG, "Created test calendar $calendarId") val calendar = AndroidCalendar.findByID( account, provider, LocalCalendar.Factory, - ContentUris.parseId(uri) + calendarId ) // associate credentials, too @@ -119,20 +123,27 @@ class CalendarToRoomMigrationTest { } @Test - fun testSubscriptionCreated() { - val worker = TestListenableWorkerBuilder( - context = appContext - ).build() - + fun testMigrateFromV2_0_3() { + // prepare: create local calendar without subscription val calendar = createCalendar() + assertFalse(calendar.isManagedByDB()) + try { runBlocking { - val result = worker.doWork() - assertEquals(result, Result.success()) - - val subscription = subscriptionsDao.getAll().first() - // check that the calendar has been added to the subscriptions list - assertEquals(calendar.id, subscription.id) + // run worker + val result = TestListenableWorkerBuilder(appContext) + .setInputData(Data.Builder() + .putBoolean(SyncWorker.ONLY_MIGRATE, true) + .build()) + .build().doWork() + assertEquals(Result.success(), result) + + // check that calendar is marked as "managed by DB" so that it won't be migrated again + assertTrue(calendar.isManagedByDB()) + + // check that the subscription has been added + val subscription = subscriptionsDao.getByCalendarId(calendar.id)!! + assertEquals(calendar.id, subscription.calendarId) assertEquals(CALENDAR_DISPLAY_NAME, subscription.displayName) assertEquals(Uri.parse(CALENDAR_URL), subscription.url) @@ -146,4 +157,38 @@ class CalendarToRoomMigrationTest { } } + @Test + fun testMigrateFromV2_1() { + // prepare: create local calendar plus subscription with subscription.id = LocalCalendar.id, + // but with calendarId=null and COLUMN_MANAGED_BY_DB=null + val calendar = createCalendar() + assertFalse(calendar.isManagedByDB()) + + val oldSubscriptionId = subscriptionsDao.add(Subscription.fromLegacyCalendar(calendar).copy(id = calendar.id, calendarId = null)) + + try { + runBlocking { + // run worker + val result = TestListenableWorkerBuilder(appContext) + .setInputData(Data.Builder() + .putBoolean(SyncWorker.ONLY_MIGRATE, true) + .build()) + .build().doWork() + assertEquals(Result.success(), result) + + // check that calendar is marked as "managed by DB" so that it won't be migrated again + assertTrue(calendar.isManagedByDB()) + + // check that the subscription has been added + val subscription = subscriptionsDao.getByCalendarId(calendar.id)!! + assertEquals(oldSubscriptionId, subscription.id) + assertEquals(calendar.id, subscription.calendarId) + assertEquals(CALENDAR_DISPLAY_NAME, subscription.displayName) + assertEquals(Uri.parse(CALENDAR_URL), subscription.url) + } + } finally { + calendar.delete() + } + } + } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt index cfd61da..f41375e 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt @@ -12,8 +12,8 @@ import android.util.Log import androidx.core.app.NotificationCompat import at.bitfire.ical4android.Event import at.bitfire.icsdroid.db.AppDatabase -import at.bitfire.icsdroid.db.LocalCalendar -import at.bitfire.icsdroid.db.LocalEvent +import at.bitfire.icsdroid.calendar.LocalCalendar +import at.bitfire.icsdroid.calendar.LocalEvent import at.bitfire.icsdroid.db.entity.Subscription import at.bitfire.icsdroid.ui.EditCalendarActivity import at.bitfire.icsdroid.ui.NotificationUtils diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt index 081d6e6..9bee758 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt @@ -5,15 +5,16 @@ package at.bitfire.icsdroid import android.content.ContentProviderClient +import android.content.ContentUris import android.content.Context import android.util.Log import androidx.work.* import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat import at.bitfire.icsdroid.Constants.TAG +import at.bitfire.icsdroid.calendar.LocalCalendar import at.bitfire.icsdroid.db.AppDatabase import at.bitfire.icsdroid.db.CalendarCredentials -import at.bitfire.icsdroid.db.LocalCalendar import at.bitfire.icsdroid.db.entity.Credential import at.bitfire.icsdroid.db.entity.Subscription import at.bitfire.icsdroid.ui.NotificationUtils @@ -29,16 +30,16 @@ class SyncWorker( const val NAME = "SyncWorker" /** - * An input data for the Worker that tells whether the synchronization should be performed + * An input data (Boolean) for the Worker that tells whether the synchronization should be performed * without taking into account the current network condition. */ - private const val FORCE_RESYNC = "forceResync" + const val FORCE_RESYNC = "forceResync" /** - * An input data for the Worker that tells if only migration should be performed, without + * An input data (Boolean) for the Worker that tells if only migration should be performed, without * fetching data. */ - private const val ONLY_MIGRATE = "onlyMigration" + const val ONLY_MIGRATE = "onlyMigration" /** * Enqueues a sync job for immediate execution. If the sync is forced, @@ -114,7 +115,9 @@ class SyncWorker( // sync local calendars for (subscription in subscriptionsDao.getAll()) { - val calendar = LocalCalendar.findById(account, provider, subscription.id) + // Make sure the subscription has a matching calendar + subscription.calendarId ?: continue + val calendar = LocalCalendar.findById(account, provider, subscription.calendarId) ProcessEventsTask(applicationContext, subscription, calendar, forceReSync).sync() } } catch (e: SecurityException) { @@ -137,26 +140,36 @@ class SyncWorker( * 2. Checks that those calendars have a matching [Subscription] in the database. * 3. If there's no matching [Subscription], create it. */ - @Suppress("DEPRECATION") private fun migrateLegacyCalendars() { - val legacyCredentials = CalendarCredentials(applicationContext) + @Suppress("DEPRECATION") + val legacyCredentials by lazy { CalendarCredentials(applicationContext) } // if there's a provider available, get all the calendars available in the system - for (calendar in LocalCalendar.findAll(account, provider)) { - val match = subscriptionsDao.getById(calendar.id) - if (match == null) { - // still no subscription for this calendar ID, create one (= migration) + for (calendar in LocalCalendar.findUnmanaged(account, provider)) { + Log.i(TAG, "Found unmanaged (<= v2.1.1) calendar ${calendar.id}, migrating") + val url = calendar.url ?: continue + + // Special case v2.1: it created subscriptions, but did not set the COLUMN_MANAGED_BY_DB flag. + val subscription = subscriptionsDao.getByUrl(url) + if (subscription != null) { + // So we already have a subscription and only net to set its calendar_id. + Log.i(TAG, "Migrating from v2.1: updating subscription ${subscription.id} with calendar ID") + subscriptionsDao.updateCalendarId(subscription.id, calendar.id) + + } else { + // before v2.1: if there's no subscription with the same URL val newSubscription = Subscription.fromLegacyCalendar(calendar) - subscriptionsDao.add(newSubscription) - Log.i(TAG, "The calendar #${calendar.id} didn't have a matching subscription. Just created it.") + Log.i(TAG, "Migrating from < v2.1: creating subscription $newSubscription") + val subscriptionId = subscriptionsDao.add(newSubscription) // migrate credentials, too (if available) val (legacyUsername, legacyPassword) = legacyCredentials.get(calendar) if (legacyUsername != null && legacyPassword != null) - credentialsDao.create(Credential( - newSubscription.id, legacyUsername, legacyPassword - )) + credentialsDao.create(Credential(subscriptionId, legacyUsername, legacyPassword)) } + + // set MANAGED_BY_DB=1 so that the calendar won't be migrated anymore + calendar.setManagedByDB() } } @@ -168,17 +181,32 @@ class SyncWorker( * - deleted if there's no [Subscription] for this calendar. */ private fun updateLocalCalendars() { + // subscriptions from DB val subscriptions = subscriptionsDao.getAll() - val calendars = LocalCalendar.findAll(account, provider).associateBy { it.id }.toMutableMap() + // local calendars from provider as Map: + val calendars = LocalCalendar.findManaged(account, provider).associateBy { it.id }.toMutableMap() + + // synchronize them for (subscription in subscriptions) { - val calendar = calendars.remove(subscription.id) - if (calendar != null) { - Log.d(TAG, "Updating local calendar #${calendar.id} from subscription") - calendar.update(subscription.toCalendarProperties()) - } else { + val calendarId = subscription.calendarId + val calendar = calendars.remove(calendarId) + // note that calendar might still be null even if calendarId is not null, + // for instance when the calendar has been removed from the system + + if (calendar == null) { + // no local calendar yet, create it Log.d(TAG, "Creating local calendar from subscription #${subscription.id}") - AndroidCalendar.create(account, provider, subscription.toCalendarProperties()) + // create local calendar + val uri = AndroidCalendar.create(account, provider, subscription.toCalendarProperties()) + // update calendar ID in DB + val newCalendarId = ContentUris.parseId(uri) + subscriptionsDao.updateCalendarId(subscription.id, newCalendarId) + + } else { + // local calendar already existing, update accordingly + Log.d(TAG, "Updating local calendar #$calendarId from subscription") + calendar.update(subscription.toCalendarProperties()) } } diff --git a/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt b/app/src/main/java/at/bitfire/icsdroid/calendar/LocalCalendar.kt similarity index 71% rename from app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt rename to app/src/main/java/at/bitfire/icsdroid/calendar/LocalCalendar.kt index d898733..2f10ecd 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt +++ b/app/src/main/java/at/bitfire/icsdroid/calendar/LocalCalendar.kt @@ -2,7 +2,7 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.icsdroid.db +package at.bitfire.icsdroid.calendar import android.accounts.Account import android.content.ContentProviderClient @@ -28,21 +28,34 @@ class LocalCalendar private constructor( const val DEFAULT_COLOR = 0xFF2F80C7.toInt() + @Deprecated("Use Subscription table") const val COLUMN_ETAG = Calendars.CAL_SYNC1 + @Deprecated("Use Subscription table") const val COLUMN_LAST_MODIFIED = Calendars.CAL_SYNC4 + @Deprecated("Use Subscription table") const val COLUMN_LAST_SYNC = Calendars.CAL_SYNC5 + @Deprecated("Use Subscription table") const val COLUMN_ERROR_MESSAGE = Calendars.CAL_SYNC6 /** * Stores if the calendar's embedded alerts should be ignored. */ + @Deprecated("Use Subscription table") const val COLUMN_IGNORE_EMBEDDED = Calendars.CAL_SYNC8 /** * Stores the default alarm to set to all events in the given calendar. */ + @Deprecated("Use Subscription table") const val COLUMN_DEFAULT_ALARM = Calendars.CAL_SYNC7 + /** + * Whether this calendar is managed by the [at.bitfire.icsdroid.db.entity.Subscription] table. + * All calendars should be set to `1` except legacy calendars from the time before we had a database. + * A `null` value should be considered as _this calendar has not been migrated to the database yet_. + */ + const val COLUMN_MANAGED_BY_DB = Calendars.CAL_SYNC9 + /** * Gets the calendar provider for a given context. * The caller (you) is responsible for closing the client! @@ -60,27 +73,37 @@ class LocalCalendar private constructor( fun findById(account: Account, provider: ContentProviderClient, id: Long) = findByID(account, provider, Factory, id) - fun findAll(account: Account, provider: ContentProviderClient) = - find(account, provider, Factory, null, null) + fun findManaged(account: Account, provider: ContentProviderClient) = + find(account, provider, Factory, "$COLUMN_MANAGED_BY_DB IS NOT NULL", null) + + fun findUnmanaged(account: Account, provider: ContentProviderClient) = + find(account, provider, Factory, "$COLUMN_MANAGED_BY_DB IS NULL", null) } /** URL of iCalendar file */ + @Deprecated("Use Subscription table") var url: String? = null /** iCalendar ETag at last successful sync */ + @Deprecated("Use Subscription table") var eTag: String? = null /** iCalendar Last-Modified at last successful sync (or 0 for none) */ + @Deprecated("Use Subscription table") var lastModified = 0L /** time of last sync (0 if none) */ + @Deprecated("Use Subscription table") var lastSync = 0L /** error message (HTTP status or exception name) of last sync (or null) */ + @Deprecated("Use Subscription table") var errorMessage: String? = null /** Setting: whether to ignore alarms embedded in the Webcal */ + @Deprecated("Use Subscription table") var ignoreEmbeddedAlerts: Boolean? = null /** Setting: Shall a default alarm be added to every event in the calendar? If yes, this * field contains the minutes before the event. If no, it is *null*. */ + @Deprecated("Use Subscription table") var defaultAlarmMinutes: Long? = null @@ -127,6 +150,26 @@ class LocalCalendar private constructor( } } + fun isManagedByDB(): Boolean { + provider.query(calendarSyncURI(), arrayOf(COLUMN_MANAGED_BY_DB), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) + return !cursor.isNull(0) + } + + // row doesn't exist, assume default value + return true + } + + /** + * Updates the entry in the provider to set [COLUMN_MANAGED_BY_DB] to 1. + * The calendar is then marked as _managed by the database_ and won't be migrated anymore, for instance. + */ + fun setManagedByDB() { + val values = ContentValues(1) + values.put(COLUMN_MANAGED_BY_DB, 1) + provider.update(calendarSyncURI(), values, null, null) + } + object Factory : AndroidCalendarFactory { diff --git a/app/src/main/java/at/bitfire/icsdroid/db/LocalEvent.kt b/app/src/main/java/at/bitfire/icsdroid/calendar/LocalEvent.kt similarity index 98% rename from app/src/main/java/at/bitfire/icsdroid/db/LocalEvent.kt rename to app/src/main/java/at/bitfire/icsdroid/calendar/LocalEvent.kt index 7cfb4d3..a58321c 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/LocalEvent.kt +++ b/app/src/main/java/at/bitfire/icsdroid/calendar/LocalEvent.kt @@ -2,7 +2,7 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.icsdroid.db +package at.bitfire.icsdroid.calendar import android.content.ContentValues import android.provider.CalendarContract diff --git a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt index 107e862..1e29e7b 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt @@ -6,6 +6,7 @@ package at.bitfire.icsdroid.db import android.content.Context import androidx.annotation.VisibleForTesting +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase @@ -22,7 +23,16 @@ import at.bitfire.icsdroid.db.entity.Subscription * The database for storing all the ICSx5 subscriptions and other data. Use [getInstance] for getting access to the database. */ @TypeConverters(Converters::class) -@Database(entities = [Subscription::class, Credential::class], version = 1) +@Database( + entities = [Subscription::class, Credential::class], + version = 2, + autoMigrations = [ + AutoMigration ( + from = 1, + to = 2 + ) + ] +) abstract class AppDatabase : RoomDatabase() { companion object { @@ -60,7 +70,6 @@ abstract class AppDatabase : RoomDatabase() { .fallbackToDestructiveMigration() .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { - super.onCreate(db) SyncWorker.run(context, onlyMigrate = true) } }) diff --git a/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt b/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt index 85e759e..b05b1ff 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/CalendarCredentials.kt @@ -5,6 +5,7 @@ package at.bitfire.icsdroid.db import android.content.Context +import at.bitfire.icsdroid.calendar.LocalCalendar @Deprecated( "Use Room's Credentials from database.", diff --git a/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt index 18ffd2b..79a7c9f 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/dao/SubscriptionsDao.kt @@ -10,7 +10,7 @@ import at.bitfire.icsdroid.db.entity.Subscription interface SubscriptionsDao { @Insert - fun add(vararg subscriptions: Subscription): List + fun add(subscription: Subscription): Long @Delete fun delete(vararg subscriptions: Subscription) @@ -24,6 +24,12 @@ interface SubscriptionsDao { @Query("SELECT * FROM subscriptions WHERE id=:id") fun getById(id: Long): Subscription? + @Query("SELECT * FROM subscriptions WHERE calendarId=:calendarId") + fun getByCalendarId(calendarId: Long?): Subscription? + + @Query("SELECT * FROM subscriptions WHERE url=:url") + fun getByUrl(url: String): Subscription? + @Query("SELECT * FROM subscriptions WHERE id=:id") fun getWithCredentialsByIdLive(id: Long): LiveData @@ -31,7 +37,10 @@ interface SubscriptionsDao { fun getErrorMessageLive(id: Long): LiveData @Update - fun update(vararg subscriptions: Subscription) + fun update(subscription: Subscription) + + @Query("UPDATE subscriptions SET calendarId=:calendarId WHERE id=:id") + fun updateCalendarId(id: Long, calendarId: Long?) @Query("UPDATE subscriptions SET lastSync=:lastSync WHERE id=:id") fun updateStatusNotModified(id: Long, lastSync: Long = System.currentTimeMillis()) diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt index 3f0982f..05765b7 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt @@ -10,7 +10,7 @@ import androidx.annotation.ColorInt import androidx.core.content.contentValuesOf import androidx.room.Entity import androidx.room.PrimaryKey -import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.calendar.LocalCalendar /** * Represents the storage of a subscription the user has made. @@ -19,6 +19,8 @@ import at.bitfire.icsdroid.db.LocalCalendar data class Subscription( /** The id of the subscription in the database. */ @PrimaryKey(autoGenerate = true) val id: Long = 0L, + /** The id of the subscription in the system's database */ + val calendarId: Long? = null, /** URL of iCalendar file */ val url: Uri, /** ETag at last successful sync */ @@ -43,12 +45,6 @@ data class Subscription( val color: Int? = null ) { companion object { - /** - * The default color to use in all subscriptions. - */ - @ColorInt - const val DEFAULT_COLOR = 0xFF2F80C7.toInt() - /** * Converts a [LocalCalendar] to a [Subscription] data object. * Must only be used for migrating legacy calendars. @@ -58,7 +54,7 @@ data class Subscription( */ fun fromLegacyCalendar(calendar: LocalCalendar) = Subscription( - id = calendar.id, + calendarId = calendar.id, url = Uri.parse(calendar.url ?: "https://invalid-url"), eTag = calendar.eTag, displayName = calendar.displayName ?: calendar.id.toString(), @@ -77,11 +73,12 @@ data class Subscription( * passed to the calendar provider in order to create/update the local calendar. */ fun toCalendarProperties() = contentValuesOf( - Calendars._ID to id, + Calendars.NAME to url.toString(), Calendars.CALENDAR_DISPLAY_NAME to displayName, Calendars.CALENDAR_COLOR to color, Calendars.CALENDAR_ACCESS_LEVEL to Calendars.CAL_ACCESS_READ, - Calendars.SYNC_EVENTS to 1 + Calendars.SYNC_EVENTS to 1, + LocalCalendar.COLUMN_MANAGED_BY_DB to 1 ) } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt index bffcaef..bbcee2e 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt @@ -7,8 +7,7 @@ package at.bitfire.icsdroid.ui import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import at.bitfire.icsdroid.PermissionUtils -import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.calendar.LocalCalendar class AddCalendarActivity: AppCompatActivity() { diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt index a089475..67d49c2 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt @@ -23,7 +23,6 @@ import at.bitfire.icsdroid.db.entity.Credential import at.bitfire.icsdroid.db.entity.Subscription import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class AddCalendarDetailsFragment: Fragment() { @@ -113,11 +112,8 @@ class AddCalendarDetailsFragment: Fragment() { defaultAlarmMinutes = titleColorModel.defaultAlarmMinutes.value ) - /** A list of all the ids of the inserted rows, should only contain one value */ - val ids = withContext(Dispatchers.IO) { subscriptionsDao.add(subscription) } - - /** The id of the newly inserted subscription */ - val id = ids.first() + /** A list of all the ids of the inserted rows */ + val id = subscriptionsDao.add(subscription) // Create the credential in the IO thread if (credentialsModel.requiresAuth.value == true) { diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt index a2f0e0a..ccbef15 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt @@ -9,7 +9,7 @@ import android.content.Intent import android.os.Bundle import androidx.activity.result.contract.ActivityResultContract import androidx.appcompat.app.AppCompatActivity -import at.bitfire.icsdroid.db.LocalCalendar +import at.bitfire.icsdroid.calendar.LocalCalendar import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.jaredrummler.android.colorpicker.ColorPickerDialogListener -- GitLab From 0b89a6792b4813e62874c20fac6d8170978363bd Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 28 Feb 2023 20:14:59 +0100 Subject: [PATCH 29/51] SyncWorker: handle missing permissions correctly (show notification) --- app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt index 9bee758..156c306 100644 --- a/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt +++ b/app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt @@ -98,7 +98,14 @@ class SyncWorker( val onlyMigrate = inputData.getBoolean(ONLY_MIGRATE, false) Log.i(TAG, "Synchronizing (forceReSync=$forceReSync,onlyMigrate=$onlyMigrate)") - provider = LocalCalendar.getCalendarProvider(applicationContext) + provider = + try { + LocalCalendar.getCalendarProvider(applicationContext) + } catch (e: SecurityException) { + NotificationUtils.showCalendarPermissionNotification(applicationContext) + return Result.failure() + } + try { // migrate old calendar-based subscriptions to database migrateLegacyCalendars() @@ -120,9 +127,6 @@ class SyncWorker( val calendar = LocalCalendar.findById(account, provider, subscription.calendarId) ProcessEventsTask(applicationContext, subscription, calendar, forceReSync).sync() } - } catch (e: SecurityException) { - NotificationUtils.showCalendarPermissionNotification(applicationContext) - return Result.failure() } catch (e: InterruptedException) { Log.e(TAG, "Thread interrupted", e) return Result.retry() -- GitLab From cde89ad0451ce313c5affc1e43033da42eace190 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 28 Feb 2023 20:18:07 +0100 Subject: [PATCH 30/51] Version bump to 2.1.1-rc.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 14901c7..b110b0e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { minSdkVersion 21 targetSdkVersion 33 - versionCode 70 - versionName "2.1" + versionCode 71 + versionName "2.1.1-rc.1" setProperty "archivesBaseName", "icsx5-" + getVersionCode() + "-" + getVersionName() -- GitLab From 89f5fe8af88ee30521581a076fe26a078ce5faf3 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 28 Feb 2023 21:32:34 +0100 Subject: [PATCH 31/51] Run sync after a subscription has been added or removed (closes #136) (#137) --- .../java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt | 4 ++++ .../main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt | 3 +++ 2 files changed, 7 insertions(+) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt index 67d49c2..d496882 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt @@ -18,6 +18,7 @@ import androidx.lifecycle.Observer import androidx.lifecycle.viewModelScope import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.SyncWorker import at.bitfire.icsdroid.db.AppDatabase import at.bitfire.icsdroid.db.entity.Credential import at.bitfire.icsdroid.db.entity.Subscription @@ -130,6 +131,9 @@ class AddCalendarDetailsFragment: Fragment() { } } + // sync the subscription to reflect the changes in the calendar provider + SyncWorker.run(getApplication()) + success.postValue(true) } catch (e: Exception) { Log.e(Constants.TAG, "Couldn't create calendar", e) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt index a2d4471..2a857c8 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt @@ -282,6 +282,9 @@ class EditCalendarActivity: AppCompatActivity() { subscriptionWithCredential.value?.let { subscriptionWithCredentials -> subscriptionsDao.delete(subscriptionWithCredentials.subscription) + // sync the subscription to reflect the changes in the calendar provider + SyncWorker.run(getApplication()) + // notify UI about success successMessage.postValue(getApplication().getString(R.string.edit_calendar_deleted)) } -- GitLab From 6cb041146322fd0b752dd411776098a1d6e801d7 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 1 Mar 2023 16:25:15 +0100 Subject: [PATCH 32/51] Version bump to 2.1.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index b110b0e..6fb337e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { minSdkVersion 21 targetSdkVersion 33 - versionCode 71 - versionName "2.1.1-rc.1" + versionCode 72 + versionName "2.1.1" setProperty "archivesBaseName", "icsx5-" + getVersionCode() + "-" + getVersionName() -- GitLab From 97e19a0a4705d0db4132ec05cf955af5f9976565 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Thu, 2 Mar 2023 12:35:44 +0100 Subject: [PATCH 33/51] Added Locator dependency (#132) * Added Refresh option to overflow menu Signed-off-by: Arnau Mora * Changed wording Signed-off-by: Arnau Mora * Added Locator Signed-off-by: Arnau Mora * Using plugin from Gradle Plugins Signed-off-by: Arnau Mora * Removed mis-imports Signed-off-by: Arnau Mora * Downgraded Kotlin for CodeQL compatibility Signed-off-by: Arnau Mora --------- Signed-off-by: Arnau Mora Co-authored-by: Ricki Hirner --- app/build.gradle | 76 +----------------------------------------------- build.gradle | 5 ++-- 2 files changed, 4 insertions(+), 77 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6fb337e..672cbf2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,6 +3,7 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'com.mikepenz.aboutlibraries.plugin' apply plugin: 'com.google.devtools.ksp' +apply plugin: 'com.arnyminerz.locator' android { compileSdkVersion 33 @@ -22,10 +23,6 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - def locales = getLocales() - buildConfigField "String[]", "TRANSLATION_ARRAY", "new String[]{\""+locales.join("\",\"")+"\"}" - resConfigs locales - ksp { arg("room.schemaLocation", "$projectDir/schemas".toString()) } @@ -71,11 +68,6 @@ android { signingConfig signingConfigs.bitfire } } - sourceSets { - main { - res.srcDirs += ['build/generated/res/locale'] - } - } lint { disable 'ExtraTranslation', 'MissingTranslation', 'InvalidPackage', 'OnClick' @@ -88,72 +80,6 @@ android { } } -import groovy.xml.MarkupBuilder - -import static groovy.io.FileType.DIRECTORIES - -/** - * Obtains a list of all the available locales - * @since 20220928 - * @return A list with the language codes of the locales available. - */ -def getLocales() { - // Initialize the list English, since it's available by default - def list = ["en"] - // Get all directories inside resources - def dir = new File(projectDir, "src/main/res") - dir.traverse(type: DIRECTORIES, maxDepth: 0) { file -> - // Get only values directories - def fileName = file.name - if (!fileName.startsWith("values-")) return - - // Take only the values directories that contain strings - def stringsFile = new File(file, "strings.xml") - if (!stringsFile.exists()) return - - // Add to the list the locale of the strings file - list.add(fileName.substring(fileName.indexOf('-') + 1)) - } - // Log the available locales - println "Supported locales: " + list.join(", ") - // Return the built list - return list -} - -/** - * Writes the available locales obtained from getLocales() in locale-config.xml - * @since 20221016 - * @return A list with the language codes of the locales available. - */ -task updateLocalesConfig() { - println 'Building locale config...' - ext.outputDir = new File(projectDir, 'build/generated/res/locale/xml') - - doFirst { - mkdir outputDir - - new File(outputDir, "locales_config.xml").withWriter { writer -> - def destXml = new MarkupBuilder(new IndentPrinter(writer, " ", true, true)) - destXml.setDoubleQuotes(true) - def destXmlMkp = destXml.getMkp() - destXmlMkp.xmlDeclaration(version: "1.0", encoding: "utf-8") - destXmlMkp.comment("Generated at ${new Date()}") - destXmlMkp.yield "\r\n" - - def locales = getLocales() - destXml."locale-config"(['xmlns:android':"http://schemas.android.com/apk/res/android"]) { - locales.forEach { locale -> - destXml."locale"("android:name": locale) - } - } - } - } -} - -gradle.projectsEvaluated { - preBuild.dependsOn('updateLocalesConfig') -} - dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2' diff --git a/build.gradle b/build.gradle index 6122a92..2f5817b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,9 @@ buildscript { ext.versions = [ aboutLibs: '8.9.4', - kotlin: '1.7.20', + kotlin: '1.8.0', okhttp: '5.0.0-alpha.11', - ksp: '1.0.7', + ksp: '1.0.9', room: '2.5.0' ] @@ -19,6 +19,7 @@ buildscript { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:${versions.aboutLibs}" classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${versions.kotlin}-${versions.ksp}" + classpath "com.arnyminerz.locator:Locator:1.0.2" } } -- GitLab From 468e0290eb6dd979c047d75d9d28bdeb514ae9b3 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 2 Mar 2023 11:45:42 +0100 Subject: [PATCH 34/51] Upgrade dependencies --- app/build.gradle | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 672cbf2..987e870 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -117,7 +117,7 @@ dependencies { // for tests androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation "androidx.test:rules:1.5.0" - androidTestImplementation "androidx.arch.core:core-testing:2.1.0" + androidTestImplementation "androidx.arch.core:core-testing:2.2.0" androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" androidTestImplementation "androidx.work:work-testing:2.8.0" diff --git a/build.gradle b/build.gradle index 2f5817b..1293cba 100644 --- a/build.gradle +++ b/build.gradle @@ -15,7 +15,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.1' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:${versions.aboutLibs}" classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${versions.kotlin}-${versions.ksp}" -- GitLab From c31c742439c01ad5528deafea59f239e1faf3c58 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Tue, 14 Mar 2023 10:56:26 +0100 Subject: [PATCH 35/51] Configured default alarm dialog to improve UX (#139) Signed-off-by: Arnau Mora --- .../main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt index a18032d..f742373 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt @@ -5,9 +5,11 @@ package at.bitfire.icsdroid.ui import android.os.Bundle +import android.text.InputType import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.EditText import androidx.core.widget.addTextChangedListener @@ -35,6 +37,10 @@ class TitleColorFragment : Fragment() { val editText = EditText(requireContext()).apply { setHint(R.string.default_alarm_dialog_hint) + isSingleLine = true + maxLines = 1 + imeOptions = EditorInfo.IME_ACTION_DONE + inputType = InputType.TYPE_CLASS_NUMBER addTextChangedListener { txt -> val text = txt?.toString() -- GitLab From 1909a8c4b984204c0272087c60a131f25e9f7bdc Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 31 Mar 2023 13:10:46 +0200 Subject: [PATCH 36/51] Large runners (#143) * Use large runners for CI tests on AVD * Use 64-bit emulator See bitfireAT/davx5#222 --- .github/workflows/test-dev.yml | 54 ++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 02ca715..4dab107 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -27,13 +27,10 @@ jobs: test_on_emulator: name: Tests with emulator - runs-on: privileged - container: - image: ghcr.io/bitfireat/docker-android-ci:main - options: --privileged - env: - ANDROID_HOME: /sdk - ANDROID_AVD_HOME: /root/.android/avd + runs-on: ubuntu-latest-4-cores + strategy: + matrix: + api-level: [31] steps: - uses: actions/checkout@v2 with: @@ -42,13 +39,44 @@ jobs: with: distribution: 'temurin' java-version: 11 - cache: 'gradle' - - uses: gradle/wrapper-validation-action@v1 + - uses: gradle/gradle-build-action@v2 + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Cache AVD + uses: actions/cache@v3 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew --no-daemon app:connectedStandardDebugAndroidTest - - name: Start emulator - run: start-emulator.sh - - name: Run connected tests - run: ./gradlew --no-daemon app:connectedStandardDebugAndroidTest - name: Archive results if: always() uses: actions/upload-artifact@v2 -- GitLab From a54df01b3d027fbd29fbb4bddb630958dfea6cf5 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 31 Mar 2023 16:06:55 +0200 Subject: [PATCH 37/51] CI: Use gradle/gradle-build-action for caching --- .github/workflows/test-dev.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 4dab107..b4de4a9 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -12,8 +12,7 @@ jobs: with: distribution: 'temurin' java-version: 11 - cache: 'gradle' - - uses: gradle/wrapper-validation-action@v1 + - uses: gradle/gradle-build-action@v2 - name: Check run: ./gradlew --no-daemon app:lintStandardDebug app:testStandardDebugUnitTest @@ -84,4 +83,3 @@ jobs: name: test-results path: | app/build/reports - -- GitLab From b0f8994ad08978d10662bbf0aa515500b79099dc Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 3 Apr 2023 16:18:58 +0200 Subject: [PATCH 38/51] Provide an option to ignore or set reminders for full day events separately from other events (#142) * Implemented Signed-off-by: Arnau Mora * Increased version Signed-off-by: Arnau Mora * Added schema Signed-off-by: Arnau Mora * Small fixes Signed-off-by: Arnau Mora * Renamed TitleColor fragment and layout to SubscriptionSettings Signed-off-by: Arnau Mora * Improved structure of `defaultAlarmObserver` Signed-off-by: Arnau Mora * Minor changes * Fixed missing observers Signed-off-by: Arnau Mora * Renamed Subscription Settings View Model Signed-off-by: Arnau Mora * Removed unnecessary check Signed-off-by: Arnau Mora * Fixed keyboard not hiding automatically Signed-off-by: Arnau Mora * Optimize imports, use setValue() instead of postValue() in UI thread * Show summary for "no default alarm" setting --------- Signed-off-by: Arnau Mora Co-authored-by: Sunik Kupfer Co-authored-by: Ricki Hirner --- .../at.bitfire.icsdroid.db.AppDatabase/3.json | 144 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 2 +- .../at/bitfire/icsdroid/ProcessEventsTask.kt | 18 ++- .../at/bitfire/icsdroid/db/AppDatabase.kt | 6 +- .../icsdroid/db/entity/Subscription.kt | 3 +- .../icsdroid/ui/AddCalendarActivity.kt | 8 +- .../icsdroid/ui/AddCalendarDetailsFragment.kt | 37 +++-- .../ui/AddCalendarEnterUrlFragment.kt | 29 ++-- .../ui/AddCalendarValidationFragment.kt | 18 +-- .../icsdroid/ui/EditCalendarActivity.kt | 50 +++--- ...ent.kt => SubscriptionSettingsFragment.kt} | 122 ++++++++++----- .../main/res/layout/add_calendar_details.xml | 2 +- .../res/layout/add_calendar_enter_url.xml | 2 +- app/src/main/res/layout/edit_calendar.xml | 2 +- ...le_color.xml => subscription_settings.xml} | 16 +- app/src/main/res/values/strings.xml | 4 +- 16 files changed, 342 insertions(+), 121 deletions(-) create mode 100644 app/schemas/at.bitfire.icsdroid.db.AppDatabase/3.json rename app/src/main/java/at/bitfire/icsdroid/ui/{TitleColorFragment.kt => SubscriptionSettingsFragment.kt} (51%) rename app/src/main/res/layout/{title_color.xml => subscription_settings.xml} (84%) diff --git a/app/schemas/at.bitfire.icsdroid.db.AppDatabase/3.json b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/3.json new file mode 100644 index 0000000..65b90f1 --- /dev/null +++ b/app/schemas/at.bitfire.icsdroid.db.AppDatabase/3.json @@ -0,0 +1,144 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "67f30d5e1a3b0c6b44f357e00170f6ab", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `calendarId` INTEGER, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `defaultAllDayAlarmMinutes` INTEGER, `color` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calendarId", + "columnName": "calendarId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreEmbeddedAlerts", + "columnName": "ignoreEmbeddedAlerts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultAlarmMinutes", + "columnName": "defaultAlarmMinutes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "defaultAllDayAlarmMinutes", + "columnName": "defaultAllDayAlarmMinutes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`subscriptionId`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '67f30d5e1a3b0c6b44f357e00170f6ab')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ad457b2..a55da00 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -120,7 +120,7 @@ android:name=".ui.EditCalendarActivity" android:label="@string/activity_edit_calendar" android:parentActivityName=".ui.CalendarListActivity" - android:windowSoftInputMode="stateHidden" /> + android:windowSoftInputMode="stateAlwaysHidden" /> - // Check if already added alarm - val alarm = alarms.find { it.description.value.contains("*added by ICSx5") } - if (alarm != null) return@let + val isAllDay = DateUtils.isDate(dtStart) + val alarmMinutes = if (isAllDay) + subscription.defaultAllDayAlarmMinutes + else + subscription.defaultAlarmMinutes + if (alarmMinutes != null) { // Add the default alarm to the event Log.d(Constants.TAG, "Adding the default alarm to ${uid}.") alarms.add( @@ -91,7 +95,7 @@ class ProcessEventsTask( // Set action to DISPLAY add(Action.DISPLAY) // Add the trigger x minutes before - val duration = Duration.ofMinutes(-minutes) + val duration = Duration.ofMinutes(-alarmMinutes) add(Trigger(duration)) } ) diff --git a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt index 1e29e7b..af9aa72 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt @@ -25,11 +25,15 @@ import at.bitfire.icsdroid.db.entity.Subscription @TypeConverters(Converters::class) @Database( entities = [Subscription::class, Credential::class], - version = 2, + version = 3, autoMigrations = [ AutoMigration ( from = 1, to = 2 + ), + AutoMigration ( + from = 2, + to = 3 ) ] ) diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt index 05765b7..85e850d 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt @@ -6,7 +6,6 @@ package at.bitfire.icsdroid.db.entity import android.net.Uri import android.provider.CalendarContract.Calendars -import androidx.annotation.ColorInt import androidx.core.content.contentValuesOf import androidx.room.Entity import androidx.room.PrimaryKey @@ -40,6 +39,8 @@ data class Subscription( val ignoreEmbeddedAlerts: Boolean = false, /** setting: Shall a default alarm be added to every event in the calendar? If yes, this field contains the minutes before the event. If no, it is `null`. */ val defaultAlarmMinutes: Long? = null, + /** setting: Shall a default alarm be added to every all-day event in the calendar? If yes, this field contains the minutes before the event. If no, it is `null`. */ + val defaultAllDayAlarmMinutes: Long? = null, /** The color that represents the subscription. */ val color: Int? = null diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt index bbcee2e..beb9c66 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt @@ -16,7 +16,7 @@ class AddCalendarActivity: AppCompatActivity() { const val EXTRA_COLOR = "color" } - private val titleColorModel by viewModels() + private val subscriptionSettingsModel by viewModels() override fun onCreate(inState: Bundle?) { @@ -32,13 +32,13 @@ class AddCalendarActivity: AppCompatActivity() { intent?.apply { data?.let { uri -> - titleColorModel.url.value = uri.toString() + subscriptionSettingsModel.url.value = uri.toString() } getStringExtra(EXTRA_TITLE)?.let { - titleColorModel.title.value = it + subscriptionSettingsModel.title.value = it } if (hasExtra(EXTRA_COLOR)) - titleColorModel.color.value = getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) + subscriptionSettingsModel.color.value = getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) } } } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt index d496882..13c4b0b 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt @@ -27,7 +27,7 @@ import kotlinx.coroutines.launch class AddCalendarDetailsFragment: Fragment() { - private val titleColorModel by activityViewModels() + private val subscriptionSettingsModel by activityViewModels() private val credentialsModel by activityViewModels() private val model by activityViewModels() @@ -37,13 +37,15 @@ class AddCalendarDetailsFragment: Fragment() { val invalidateOptionsMenu = Observer { requireActivity().invalidateOptionsMenu() } - titleColorModel.title.observe(this, invalidateOptionsMenu) - titleColorModel.color.observe(this, invalidateOptionsMenu) - titleColorModel.ignoreAlerts.observe(this, invalidateOptionsMenu) - titleColorModel.defaultAlarmMinutes.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.title.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.color.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.ignoreAlerts.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.defaultAlarmMinutes.observe(this, invalidateOptionsMenu) + subscriptionSettingsModel.defaultAllDayAlarmMinutes.observe(this, invalidateOptionsMenu) // Set the default value to null so that the visibility of the summary is updated - titleColorModel.defaultAlarmMinutes.postValue(null) + subscriptionSettingsModel.defaultAlarmMinutes.value = null + subscriptionSettingsModel.defaultAllDayAlarmMinutes.value = null } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { @@ -54,11 +56,7 @@ class AddCalendarDetailsFragment: Fragment() { model.success.observe(viewLifecycleOwner) { success -> if (success) { // success, show notification and close activity - Toast.makeText( - requireActivity(), - requireActivity().getString(R.string.add_calendar_created), - Toast.LENGTH_LONG - ).show() + Toast.makeText(requireActivity(), requireActivity().getString(R.string.add_calendar_created),Toast.LENGTH_LONG).show() requireActivity().finish() } @@ -76,12 +74,12 @@ class AddCalendarDetailsFragment: Fragment() { override fun onPrepareOptionsMenu(menu: Menu) { val itemGo = menu.findItem(R.id.create_calendar) - itemGo.isEnabled = !titleColorModel.title.value.isNullOrBlank() + itemGo.isEnabled = !subscriptionSettingsModel.title.value.isNullOrBlank() } override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == R.id.create_calendar) { - model.create(titleColorModel, credentialsModel) + model.create(subscriptionSettingsModel, credentialsModel) true } else false @@ -100,17 +98,18 @@ class AddCalendarDetailsFragment: Fragment() { * Creates a new subscription taking the data from the given models. */ fun create( - titleColorModel: TitleColorFragment.TitleColorModel, + subscriptionSettingsModel: SubscriptionSettingsFragment.SubscriptionSettingsModel, credentialsModel: CredentialsFragment.CredentialsModel, ) { viewModelScope.launch(Dispatchers.IO) { try { val subscription = Subscription( - displayName = titleColorModel.title.value!!, - url = Uri.parse(titleColorModel.url.value), - color = titleColorModel.color.value, - ignoreEmbeddedAlerts = titleColorModel.ignoreAlerts.value ?: false, - defaultAlarmMinutes = titleColorModel.defaultAlarmMinutes.value + displayName = subscriptionSettingsModel.title.value!!, + url = Uri.parse(subscriptionSettingsModel.url.value), + color = subscriptionSettingsModel.color.value, + ignoreEmbeddedAlerts = subscriptionSettingsModel.ignoreAlerts.value ?: false, + defaultAlarmMinutes = subscriptionSettingsModel.defaultAlarmMinutes.value, + defaultAllDayAlarmMinutes = subscriptionSettingsModel.defaultAllDayAlarmMinutes.value, ) /** A list of all the ids of the inserted rows */ diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt index ab42cab..7e14917 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt @@ -8,7 +8,12 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.util.Log -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -17,13 +22,13 @@ import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.HttpUtils import at.bitfire.icsdroid.R import at.bitfire.icsdroid.databinding.AddCalendarEnterUrlBinding -import okhttp3.HttpUrl.Companion.toHttpUrl import java.net.URI import java.net.URISyntaxException +import okhttp3.HttpUrl.Companion.toHttpUrl class AddCalendarEnterUrlFragment: Fragment() { - private val titleColorModel by activityViewModels() + private val subscriptionSettingsModel by activityViewModels() private val credentialsModel by activityViewModels() private lateinit var binding: AddCalendarEnterUrlBinding @@ -41,17 +46,17 @@ class AddCalendarEnterUrlFragment: Fragment() { requireActivity().invalidateOptionsMenu() } arrayOf( - titleColorModel.url, - credentialsModel.requiresAuth, - credentialsModel.username, - credentialsModel.password + subscriptionSettingsModel.url, + credentialsModel.requiresAuth, + credentialsModel.username, + credentialsModel.password ).forEach { it.observe(viewLifecycleOwner, invalidate) } binding = AddCalendarEnterUrlBinding.inflate(inflater, container, false) binding.lifecycleOwner = this - binding.model = titleColorModel + binding.model = subscriptionSettingsModel setHasOptionsMenu(true) return binding.root @@ -91,7 +96,7 @@ class AddCalendarEnterUrlFragment: Fragment() { var uri: Uri try { try { - uri = Uri.parse(titleColorModel.url.value ?: return null) + uri = Uri.parse(subscriptionSettingsModel.url.value ?: return null) } catch (e: URISyntaxException) { Log.d(Constants.TAG, "Invalid URL", e) errorMsg = e.localizedMessage @@ -102,11 +107,11 @@ class AddCalendarEnterUrlFragment: Fragment() { if (uri.scheme.equals("webcal", true)) { uri = uri.buildUpon().scheme("http").build() - titleColorModel.url.value = uri.toString() + subscriptionSettingsModel.url.value = uri.toString() return null } else if (uri.scheme.equals("webcals", true)) { uri = uri.buildUpon().scheme("https").build() - titleColorModel.url.value = uri.toString() + subscriptionSettingsModel.url.value = uri.toString() return null } @@ -134,7 +139,7 @@ class AddCalendarEnterUrlFragment: Fragment() { credentialsModel.password.value = credentials.elementAtOrNull(1) val urlWithoutPassword = URI(uri.scheme, null, uri.host, uri.port, uri.path, uri.query, null) - titleColorModel.url.value = urlWithoutPassword.toString() + subscriptionSettingsModel.url.value = urlWithoutPassword.toString() return null } } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt index 069b6c0..e2a6b1a 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt @@ -23,23 +23,23 @@ import at.bitfire.icsdroid.HttpClient import at.bitfire.icsdroid.HttpUtils.toURI import at.bitfire.icsdroid.HttpUtils.toUri import at.bitfire.icsdroid.R +import java.io.InputStream +import java.io.InputStreamReader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import net.fortuna.ical4j.model.property.Color import okhttp3.MediaType -import java.io.InputStream -import java.io.InputStreamReader class AddCalendarValidationFragment: DialogFragment() { - private val titleColorModel by activityViewModels() + private val subscriptionSettingsModel by activityViewModels() private val credentialsModel by activityViewModels() private val validationModel by viewModels { object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - val uri = Uri.parse(titleColorModel.url.value ?: throw IllegalArgumentException("No URL given"))!! + val uri = Uri.parse(subscriptionSettingsModel.url.value ?: throw IllegalArgumentException("No URL given"))!! val authenticate = credentialsModel.requiresAuth.value ?: false return ValidationModel( requireActivity().application, @@ -60,13 +60,13 @@ class AddCalendarValidationFragment: DialogFragment() { val exception = info.exception if (exception == null) { - titleColorModel.url.value = info.uri.toString() + subscriptionSettingsModel.url.value = info.uri.toString() - if (titleColorModel.color.value == null) - titleColorModel.color.value = info.calendarColor ?: resources.getColor(R.color.lightblue) + if (subscriptionSettingsModel.color.value == null) + subscriptionSettingsModel.color.value = info.calendarColor ?: resources.getColor(R.color.lightblue) - if (titleColorModel.title.value.isNullOrBlank()) - titleColorModel.title.value = info.calendarName ?: info.uri.toString() + if (subscriptionSettingsModel.title.value.isNullOrBlank()) + subscriptionSettingsModel.title.value = info.calendarName ?: info.uri.toString() parentFragmentManager .beginTransaction() diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt index 2a857c8..ebe3f92 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt @@ -42,7 +42,7 @@ class EditCalendarActivity: AppCompatActivity() { const val EXTRA_THROWABLE = "errorThrowable" } - private val titleColorModel by viewModels() + private val subscriptionSettingsModel by viewModels() private val credentialsModel by viewModels() private val model by viewModels { @@ -70,10 +70,11 @@ class EditCalendarActivity: AppCompatActivity() { invalidateOptionsMenu() } arrayOf( - titleColorModel.title, - titleColorModel.color, - titleColorModel.ignoreAlerts, - titleColorModel.defaultAlarmMinutes, + subscriptionSettingsModel.title, + subscriptionSettingsModel.color, + subscriptionSettingsModel.ignoreAlerts, + subscriptionSettingsModel.defaultAlarmMinutes, + subscriptionSettingsModel.defaultAllDayAlarmMinutes, credentialsModel.requiresAuth, credentialsModel.username, credentialsModel.password @@ -136,7 +137,7 @@ class EditCalendarActivity: AppCompatActivity() { else View.GONE - val titleOK = !titleColorModel.title.value.isNullOrBlank() + val titleOK = !subscriptionSettingsModel.title.value.isNullOrBlank() val authOK = credentialsModel.run { if (requiresAuth.value == true) username.value != null && password.value != null @@ -152,22 +153,26 @@ class EditCalendarActivity: AppCompatActivity() { private fun onSubscriptionLoaded(subscriptionWithCredential: SubscriptionsDao.SubscriptionWithCredential) { val subscription = subscriptionWithCredential.subscription - titleColorModel.url.value = subscription.url.toString() + subscriptionSettingsModel.url.value = subscription.url.toString() subscription.displayName.let { - titleColorModel.originalTitle = it - titleColorModel.title.value = it + subscriptionSettingsModel.originalTitle = it + subscriptionSettingsModel.title.value = it } subscription.color.let { - titleColorModel.originalColor = it - titleColorModel.color.value = it + subscriptionSettingsModel.originalColor = it + subscriptionSettingsModel.color.value = it } subscription.ignoreEmbeddedAlerts.let { - titleColorModel.originalIgnoreAlerts = it - titleColorModel.ignoreAlerts.postValue(it) + subscriptionSettingsModel.originalIgnoreAlerts = it + subscriptionSettingsModel.ignoreAlerts.postValue(it) } subscription.defaultAlarmMinutes.let { - titleColorModel.originalDefaultAlarmMinutes = it - titleColorModel.defaultAlarmMinutes.postValue(it) + subscriptionSettingsModel.originalDefaultAlarmMinutes = it + subscriptionSettingsModel.defaultAlarmMinutes.postValue(it) + } + subscription.defaultAllDayAlarmMinutes.let { + subscriptionSettingsModel.originalDefaultAllDayAlarmMinutes = it + subscriptionSettingsModel.defaultAllDayAlarmMinutes.postValue(it) } val credential = subscriptionWithCredential.credential @@ -191,7 +196,7 @@ class EditCalendarActivity: AppCompatActivity() { /* user actions */ fun onSave(item: MenuItem?) { - model.updateSubscription(titleColorModel, credentialsModel) + model.updateSubscription(subscriptionSettingsModel, credentialsModel) } fun onAskDelete(item: MenuItem) { @@ -220,7 +225,7 @@ class EditCalendarActivity: AppCompatActivity() { } } - private fun dirty(): Boolean = titleColorModel.dirty() || credentialsModel.dirty() + private fun dirty(): Boolean = subscriptionSettingsModel.dirty() || credentialsModel.dirty() /* view model and data source */ @@ -242,7 +247,7 @@ class EditCalendarActivity: AppCompatActivity() { * Updates the loaded subscription from the data provided by the view models. */ fun updateSubscription( - titleColorModel: TitleColorFragment.TitleColorModel, + subscriptionSettingsModel: SubscriptionSettingsFragment.SubscriptionSettingsModel, credentialsModel: CredentialsFragment.CredentialsModel ) { viewModelScope.launch(Dispatchers.IO) { @@ -250,10 +255,11 @@ class EditCalendarActivity: AppCompatActivity() { val subscription = subscriptionWithCredentials.subscription val newSubscription = subscription.copy( - displayName = titleColorModel.title.value ?: subscription.displayName, - color = titleColorModel.color.value, - defaultAlarmMinutes = titleColorModel.defaultAlarmMinutes.value, - ignoreEmbeddedAlerts = titleColorModel.ignoreAlerts.value ?: false + displayName = subscriptionSettingsModel.title.value ?: subscription.displayName, + color = subscriptionSettingsModel.color.value, + defaultAlarmMinutes = subscriptionSettingsModel.defaultAlarmMinutes.value, + defaultAllDayAlarmMinutes = subscriptionSettingsModel.defaultAllDayAlarmMinutes.value, + ignoreEmbeddedAlerts = subscriptionSettingsModel.ignoreAlerts.value ?: false ) subscriptionsDao.update(newSubscription) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/SubscriptionSettingsFragment.kt similarity index 51% rename from app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt rename to app/src/main/java/at/bitfire/icsdroid/ui/SubscriptionSettingsFragment.kt index f742373..ee365aa 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/TitleColorFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/SubscriptionSettingsFragment.kt @@ -12,26 +12,94 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.EditText +import android.widget.TextView import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import at.bitfire.icsdroid.R -import at.bitfire.icsdroid.databinding.TitleColorBinding +import at.bitfire.icsdroid.databinding.SubscriptionSettingsBinding import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.switchmaterial.SwitchMaterial import org.joda.time.Minutes import org.joda.time.format.PeriodFormat -class TitleColorFragment : Fragment() { +class SubscriptionSettingsFragment : Fragment() { - private val model by activityViewModels() + private val model by activityViewModels() - private lateinit var binding: TitleColorBinding + private lateinit var binding: SubscriptionSettingsBinding - private val checkboxCheckedChanged: OnCheckedChangeListener = OnCheckedChangeListener { _, checked -> + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { + binding = SubscriptionSettingsBinding.inflate(inflater, container, false) + binding.lifecycleOwner = this + binding.model = model + + model.defaultAlarmMinutes.observe( + viewLifecycleOwner, + defaultAlarmObserver( + binding.defaultAlarmSwitch, + binding.defaultAlarmText, + model.defaultAlarmMinutes + ) + ) + model.defaultAllDayAlarmMinutes.observe( + viewLifecycleOwner, + defaultAlarmObserver( + binding.defaultAlarmAllDaySwitch, + binding.defaultAlarmAllDayText, + model.defaultAllDayAlarmMinutes + ) + ) + + val colorPickerContract = registerForActivityResult(ColorPickerActivity.Contract()) { color -> + model.color.value = color + } + binding.color.setOnClickListener { + colorPickerContract.launch(model.color.value) + } + + return binding.root + } + + /** + * Provides an observer for the default alarm fields. + * @param switch The switch view that updates the currently stored minutes. + * @param textView The viewer for the current value of the stored minutes. + * @param selectedMinutes The LiveData instance that holds the currently selected amount of minutes. + */ + private fun defaultAlarmObserver( + switch: SwitchMaterial, + textView: TextView, + selectedMinutes: MutableLiveData + ) = Observer { min: Long? -> + switch.isChecked = min != null + // We add the listener once the switch has an initial value + switch.setOnCheckedChangeListener(getOnCheckedChangeListener(switch, selectedMinutes)) + + if (min == null) + textView.text = getString(R.string.add_calendar_alarms_default_none) + else { + val alarmPeriodText = PeriodFormat.wordBased().print(Minutes.minutes(min.toInt())) + textView.text = getString(R.string.add_calendar_alarms_default_description, alarmPeriodText) + } + } + + /** + * Provides an [OnCheckedChangeListener] for watching the checked changes of a switch that + * provides the alarm time in minutes for a given parameter. Also holds the alert dialog that + * asks the user the amount of time to set. + * @param switch The switch that is going to update the selection of minutes. + * @param observable The state holder of the amount of minutes selected. + */ + private fun getOnCheckedChangeListener( + switch: SwitchMaterial, + observable: MutableLiveData + ) = OnCheckedChangeListener { _, checked -> if (!checked) { - model.defaultAlarmMinutes.postValue(null) + observable.value = null return@OnCheckedChangeListener } @@ -57,47 +125,18 @@ class TitleColorFragment : Fragment() { .setView(editText) .setPositiveButton(R.string.default_alarm_dialog_set) { dialog, _ -> if (editText.error == null) { - model.defaultAlarmMinutes.postValue(editText.text?.toString()?.toLongOrNull()) + observable.value = editText.text?.toString()?.toLongOrNull() dialog.dismiss() } } .setOnCancelListener { - binding.defaultAlarmSwitch.isChecked = false + switch.isChecked = false } .create() .show() } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { - binding = TitleColorBinding.inflate(inflater, container, false) - binding.lifecycleOwner = this - binding.model = model - - model.defaultAlarmMinutes.observe(viewLifecycleOwner) { min: Long? -> - binding.defaultAlarmSwitch.isChecked = min != null - // We add the listener once the switch has an initial value - binding.defaultAlarmSwitch.setOnCheckedChangeListener(checkboxCheckedChanged) - - if (min == null) { - binding.defaultAlarmText.visibility = View.GONE - } else { - val alarmPeriodText = PeriodFormat.wordBased().print(Minutes.minutes(min.toInt())) - binding.defaultAlarmText.text = getString(R.string.add_calendar_alarms_default_description, alarmPeriodText) - binding.defaultAlarmText.visibility = View.VISIBLE - } - } - - val colorPickerContract = registerForActivityResult(ColorPickerActivity.Contract()) { color -> - model.color.postValue(color) - } - binding.color.setOnClickListener { - colorPickerContract.launch(model.color.value) - } - - return binding.root - } - - class TitleColorModel : ViewModel() { + class SubscriptionSettingsModel : ViewModel() { var url = MutableLiveData() var originalTitle: String? = null @@ -112,8 +151,11 @@ class TitleColorFragment : Fragment() { var originalDefaultAlarmMinutes: Long? = null val defaultAlarmMinutes = MutableLiveData() + var originalDefaultAllDayAlarmMinutes: Long? = null + val defaultAllDayAlarmMinutes = MutableLiveData() + fun dirty(): Boolean = originalTitle != title.value || originalColor != color.value || originalIgnoreAlerts != ignoreAlerts.value || - originalDefaultAlarmMinutes != defaultAlarmMinutes.value + originalDefaultAlarmMinutes != defaultAlarmMinutes.value || originalDefaultAllDayAlarmMinutes != defaultAllDayAlarmMinutes.value } -} +} \ No newline at end of file diff --git a/app/src/main/res/layout/add_calendar_details.xml b/app/src/main/res/layout/add_calendar_details.xml index a0b271f..9bb64c7 100644 --- a/app/src/main/res/layout/add_calendar_details.xml +++ b/app/src/main/res/layout/add_calendar_details.xml @@ -5,7 +5,7 @@ diff --git a/app/src/main/res/layout/add_calendar_enter_url.xml b/app/src/main/res/layout/add_calendar_enter_url.xml index 6062aef..61f0e72 100644 --- a/app/src/main/res/layout/add_calendar_enter_url.xml +++ b/app/src/main/res/layout/add_calendar_enter_url.xml @@ -1,7 +1,7 @@ - + + android:name="at.bitfire.icsdroid.ui.SubscriptionSettingsFragment"/> + type="at.bitfire.icsdroid.ui.SubscriptionSettingsFragment.SubscriptionSettingsModel" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 508ad67..2cbca2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,7 +46,9 @@ Ignore alerts embed in the calendar If enabled, all the incoming alarms from the server will be dismissed. Add a default alarm for all events - Alarms set to %s before + Add a default alarm for all-day events + Alarm %s before start + No default alarm Add default alarm This will add an alarm for all events Minutes before event -- GitLab From 8d7fac17e83004508c08b77b4911906104b6fbb3 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Thu, 13 Apr 2023 15:40:01 +0200 Subject: [PATCH 39/51] Added privacy policy link (#147) * Added privacy policy link Signed-off-by: Arnau Mora * Changed to lower case Signed-off-by: Arnau Mora * Renamed function Signed-off-by: Arnau Mora * Renamed function Signed-off-by: Arnau Mora * Moved privacy policy url into a constant Signed-off-by: Arnau Mora * Added `UriUtils` Signed-off-by: Arnau Mora --------- Signed-off-by: Arnau Mora --- .../main/java/at/bitfire/icsdroid/UriUtils.kt | 35 +++++++++++++++++++ .../icsdroid/ui/CalendarListActivity.kt | 7 ++++ .../main/res/menu/activity_calendar_list.xml | 5 +++ app/src/main/res/values/strings.xml | 2 ++ 4 files changed, 49 insertions(+) create mode 100644 app/src/main/java/at/bitfire/icsdroid/UriUtils.kt diff --git a/app/src/main/java/at/bitfire/icsdroid/UriUtils.kt b/app/src/main/java/at/bitfire/icsdroid/UriUtils.kt new file mode 100644 index 0000000..80267d3 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/UriUtils.kt @@ -0,0 +1,35 @@ +package at.bitfire.icsdroid + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast + +object UriUtils { + /** + * Starts the [Intent.ACTION_VIEW] intent with the given URL, if possible. + * If the intent can't be resolved (for instance, because there is no browser + * installed), this method does nothing. + * + * @param toastInstallBrowser whether to show "Please install a browser" toast when + * the Intent could not be resolved + * + * @return true on success, false if the Intent could not be resolved (for instance, because + * there is no user agent installed) + */ + fun launchUri(context: Context, uri: Uri, action: String = Intent.ACTION_VIEW, toastInstallBrowser: Boolean = true): Boolean { + val intent = Intent(action, uri) + try { + context.startActivity(intent) + return true + } catch (e: ActivityNotFoundException) { + // no browser available + } + + if (toastInstallBrowser) + Toast.makeText(context, R.string.install_browser, Toast.LENGTH_LONG).show() + + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt index 0544ba0..34ee739 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt @@ -8,6 +8,7 @@ import android.annotation.SuppressLint import android.app.Application import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager @@ -44,6 +45,8 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis * Set this extra to request calendar permission when the activity starts. */ const val EXTRA_REQUEST_CALENDAR_PERMISSION = "permission" + + const val PRIVACY_POLICY_URL = "https://icsx5.bitfire.at/privacy/" } private val model by viewModels() @@ -211,6 +214,10 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis ) } + fun onShowPrivacyPolicy(item: MenuItem) { + UriUtils.launchUri(this, Uri.parse(PRIVACY_POLICY_URL)) + } + class SubscriptionListAdapter( val context: Context diff --git a/app/src/main/res/menu/activity_calendar_list.xml b/app/src/main/res/menu/activity_calendar_list.xml index 6d60e29..67ef69e 100644 --- a/app/src/main/res/menu/activity_calendar_list.xml +++ b/app/src/main/res/menu/activity_calendar_list.xml @@ -17,6 +17,11 @@ android:onClick="onToggleDarkMode" app:showAsAction="never" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cbca2d..9ce3703 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ Sync problems Permissions required Grant + Please install a Web browser My subscriptions @@ -25,6 +26,7 @@ Settings Force dark theme + Privacy policy Subscribe to calendar -- GitLab From 3576da125d21b8f3eeed2608fec0bafae5d5936e Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Wed, 3 May 2023 12:50:43 +0200 Subject: [PATCH 40/51] Update to AGP 8.0 (#152) * Updated wrapper version Signed-off-by: Arnau Mora Gras * Updated AGP Signed-off-by: Arnau Mora Gras * Updated Kotlin Signed-off-by: Arnau Mora Gras * Updated KSP Signed-off-by: Arnau Mora Gras * Updated to Java 17 Signed-off-by: Arnau Mora Gras * Updated dependencies Signed-off-by: Arnau Mora Gras * Updated own dependencies Signed-off-by: Arnau Mora Gras * Annotated `getWithCredentialsByIdLive` with `@Transaction` Signed-off-by: Arnau Mora Gras * Migrated LiveData map method Signed-off-by: Arnau Mora Gras * Removed `jvmTarget` Signed-off-by: Arnau Mora Gras * Enabled `buildConfig` feature Signed-off-by: Arnau Mora Gras * Updated missing Java version to 17 Signed-off-by: Arnau Mora Gras * Update Room, remove unnecessary @Transaction --------- Signed-off-by: Arnau Mora Gras Co-authored-by: Ricki Hirner --- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test-dev.yml | 4 ++-- app/build.gradle | 20 +++++++++---------- .../icsdroid/ui/CalendarListActivity.kt | 4 ++-- build.gradle | 8 ++++---- cert4android | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- ical4android | 2 +- 9 files changed, 22 insertions(+), 24 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cd1dd6a..0f09ebd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/setup-java@v2 with: distribution: 'temurin' - java-version: 11 + java-version: 17 cache: 'gradle' - uses: gradle/wrapper-validation-action@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55d6460..114fc8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/setup-java@v2 with: distribution: 'temurin' - java-version: 11 + java-version: 17 cache: 'gradle' - uses: gradle/wrapper-validation-action@v1 diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index b4de4a9..412ba1f 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-java@v2 with: distribution: 'temurin' - java-version: 11 + java-version: 17 - uses: gradle/gradle-build-action@v2 - name: Check @@ -37,7 +37,7 @@ jobs: - uses: actions/setup-java@v2 with: distribution: 'temurin' - java-version: 11 + java-version: 17 - uses: gradle/gradle-build-action@v2 - name: Enable KVM group perms diff --git a/app/build.gradle b/app/build.gradle index 987e870..0cc1584 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,14 +31,12 @@ android { compileOptions { coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } buildFeatures { + buildConfig = true viewBinding = true dataBinding = true } @@ -82,19 +80,19 @@ android { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' implementation project(':cert4android') implementation project(':ical4android') implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.core:core-ktx:1.9.0' - implementation 'androidx.fragment:fragment-ktx:1.5.5' + implementation 'androidx.core:core-ktx:1.10.0' + implementation 'androidx.fragment:fragment-ktx:1.5.7' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'androidx.work:work-runtime-ktx:2.8.0' + implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'com.google.android.material:material:1.8.0' implementation 'com.jaredrummler:colorpicker:1.1.0' @@ -120,7 +118,7 @@ dependencies { androidTestImplementation "androidx.arch.core:core-testing:2.2.0" androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" - androidTestImplementation "androidx.work:work-testing:2.8.0" + androidTestImplementation "androidx.work:work-testing:2.8.1" testImplementation 'junit:junit:4.13.2' } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt index 34ee739..a827924 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt @@ -23,7 +23,7 @@ import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView @@ -283,7 +283,7 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis class SubscriptionsModel(application: Application): AndroidViewModel(application) { /** whether there are running sync workers */ - val isRefreshing = Transformations.map(SyncWorker.liveStatus(application)) { workInfos -> + val isRefreshing = SyncWorker.liveStatus(application).map { workInfos -> workInfos.any { it.state == WorkInfo.State.RUNNING } } diff --git a/build.gradle b/build.gradle index 1293cba..cf3ffc4 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,10 @@ buildscript { ext.versions = [ aboutLibs: '8.9.4', - kotlin: '1.8.0', + kotlin: '1.8.20', okhttp: '5.0.0-alpha.11', - ksp: '1.0.9', - room: '2.5.0' + ksp: '1.0.10', + room: '2.5.1' ] repositories { @@ -15,7 +15,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' + classpath 'com.android.tools.build:gradle:8.0.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:${versions.aboutLibs}" classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${versions.kotlin}-${versions.ksp}" diff --git a/cert4android b/cert4android index 3428543..42637da 160000 --- a/cert4android +++ b/cert4android @@ -1 +1 @@ -Subproject commit 342854322b04a3a51815c016d30d8e54534956a4 +Subproject commit 42637da768a6889e650e1c1074ab6f252040da0b diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f42e62f..3a02907 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/ical4android b/ical4android index 43ef146..2cdc726 160000 --- a/ical4android +++ b/ical4android @@ -1 +1 @@ -Subproject commit 43ef1460bf457dadfae9bc432b3041415cd88329 +Subproject commit 2cdc7261709a090194b05cf9a766d4d94cdb21b7 -- GitLab From eb0dde677c64440afaf867f8ce552c4e33cb14be Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 8 May 2023 19:59:30 +0200 Subject: [PATCH 41/51] Jetpack Compose - Phase I (#140) * Completed Phase I Signed-off-by: Arnau Mora * Upgraded Kotlin to `1.8.10` Signed-off-by: Arnau Mora * Migrated `Transformations.map` Signed-off-by: Arnau Mora * Made `useDarkTheme` nullable Signed-off-by: Arnau Mora * Moved version check Signed-off-by: Arnau Mora * Removed custom theme Signed-off-by: Arnau Mora Gras * Optimized and updated dependencies Signed-off-by: Arnau Mora Gras * Using Compose-based AboutLibraries Signed-off-by: Arnau Mora Gras * Added header Signed-off-by: Arnau Mora Gras * Updated compose compiler Signed-off-by: Arnau Mora Gras * Moved info header to its own function Signed-off-by: Arnau Mora Gras * Simplified layout Signed-off-by: Arnau Mora Gras * Increased logo padding Signed-off-by: Arnau Mora Gras * Use ComponentActivity; small layout changes --------- Signed-off-by: Arnau Mora Signed-off-by: Arnau Mora Gras Co-authored-by: Ricki Hirner --- app/build.gradle | 26 ++- .../at/bitfire/icsdroid/ui/InfoActivity.kt | 219 ++++++++++++++---- app/src/main/res/menu/app_info_activity.xml | 15 -- app/src/main/res/values/strings.xml | 5 +- build.gradle | 8 +- 5 files changed, 212 insertions(+), 61 deletions(-) delete mode 100644 app/src/main/res/menu/app_info_activity.xml diff --git a/app/build.gradle b/app/build.gradle index 0cc1584..e14301b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -39,6 +39,13 @@ android { buildConfig = true viewBinding = true dataBinding = true + compose = true + } + + composeOptions { + // Keep this in sync with Kotlin version: + // https://developer.android.com/jetpack/androidx/releases/compose-kotlin + kotlinCompilerExtensionVersion = "${versions.compose}" } flavorDimensions "distribution" @@ -85,6 +92,7 @@ dependencies { implementation project(':cert4android') implementation project(':ical4android') + implementation 'androidx.activity:activity-compose:1.7.1' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.core:core-ktx:1.10.0' @@ -93,10 +101,19 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.work:work-runtime-ktx:2.8.1' - implementation 'com.google.android.material:material:1.8.0' + implementation 'com.google.android.material:material:1.9.0' + + // Jetpack Compose + def composeBom = platform("androidx.compose:compose-bom:${versions.composeBom}") + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.material:material' + debugImplementation "androidx.compose.ui:ui-tooling" + implementation "androidx.compose.ui:ui-tooling-preview" + implementation 'com.google.accompanist:accompanist-themeadapter-material:0.30.1' implementation 'com.jaredrummler:colorpicker:1.1.0' - implementation "com.mikepenz:aboutlibraries:${versions.aboutLibs}" + implementation "com.mikepenz:aboutlibraries-compose:${versions.aboutLibs}" implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}" implementation "com.squareup.okhttp3:okhttp-coroutines:${versions.okhttp}" @@ -121,4 +138,9 @@ dependencies { androidTestImplementation "androidx.work:work-testing:2.8.1" testImplementation 'junit:junit:4.13.2' +} + +aboutLibraries { + duplicationMode = com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE + includePlatform = false } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt index 268c3b6..736d99c 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt @@ -9,57 +9,67 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.util.Log -import android.view.Menu -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity +import android.widget.TextView +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.drawable.toBitmap +import androidx.core.text.HtmlCompat import at.bitfire.icsdroid.BuildConfig import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.R -import com.mikepenz.aboutlibraries.Libs -import com.mikepenz.aboutlibraries.LibsBuilder +import com.google.accompanist.themeadapter.material.MdcTheme +import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer -class InfoActivity: AppCompatActivity() { +class InfoActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (savedInstanceState == null) { - val builder = LibsBuilder() - .withAboutIconShown(true) - .withAboutAppName(getString(R.string.app_name)) - .withAboutDescription(getString(R.string.app_info_description)) - .withAboutVersionShownName(true) - .withAboutVersionString(getString(R.string.app_info_version, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR)) - .withAboutSpecial1(getString(R.string.app_info_gplv3)) - .withAboutSpecial1Description(getString(R.string.app_info_gplv3_note)) - .withLicenseShown(true) - - .withFields(R.string::class.java.fields) - .withLibraryModification("org_brotli__dec", Libs.LibraryFields.LIBRARY_NAME, "Brotli") - .withLibraryModification("org_brotli__dec", Libs.LibraryFields.AUTHOR_NAME, "Google") - - if (BuildConfig.FLAVOR != "gplay") { - builder - .withAboutSpecial2(getString(R.string.app_info_donate)) - .withAboutSpecial2Description(getString(R.string.donate_message)) - } - - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, builder.supportFragment()) - .commit() + setContent { + MainLayout() } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.app_info_activity, menu) - return true - } - - fun showWebSite(item: MenuItem) { + fun showWebSite() { launchUri(Uri.parse("https://icsx5.bitfire.at/?pk_campaign=icsx5-app&pk_kwd=info-activity")) } - fun showTwitter(item: MenuItem) { + fun showTwitter() { launchUri(Uri.parse("https://twitter.com/icsx5app")) } @@ -68,8 +78,139 @@ class InfoActivity: AppCompatActivity() { try { startActivity(intent) } catch (e: ActivityNotFoundException) { - Log.w(Constants.TAG, "No browser installed") + Toast.makeText(this, getString(R.string.no_browser), Toast.LENGTH_LONG).show() + Log.w(Constants.TAG, "No browser to view $uri") + } + } + + + @Composable + @Preview + fun MainLayout() { + MdcTheme { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton({ onNavigateUp() }) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = null + ) + } + }, + title = { + Text( + stringResource(R.string.app_name) + ) + }, + actions = { + IconButton({ showWebSite() }) { + Icon( + painter = painterResource(R.drawable.ic_public), + contentDescription = stringResource(R.string.app_info_web_site) + ) + } + IconButton({ showTwitter() }) { + Icon( + painter = painterResource(R.drawable.twitter_white), + contentDescription = stringResource(R.string.app_info_web_site) + ) + } + } + ) + } + ) { contentPadding -> + Column(Modifier.padding(contentPadding)) { + Header() + License() + LibrariesContainer() + } + } + } + } + + @Composable + fun Header() { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val context = LocalContext.current + + Image( + bitmap = context.applicationInfo + .loadIcon(context.packageManager) + .toBitmap() + .asImageBitmap(), + contentDescription = null, + modifier = Modifier + .padding(vertical = 12.dp) + .size(72.dp) + ) + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.h5, + color = MaterialTheme.colors.onBackground + ) + Text( + text = stringResource( + R.string.app_info_version, + BuildConfig.VERSION_NAME, + BuildConfig.FLAVOR + ), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onBackground, + modifier = Modifier.alpha(ContentAlpha.medium) + ) } } -} + @Composable + fun License() { + val showLicenseDialog = rememberSaveable { mutableStateOf(false) } + if (showLicenseDialog.value) + TextDialog(R.string.app_info_gplv3_note, showLicenseDialog) + + val showDonateDialog = rememberSaveable { mutableStateOf(false) } + if (showDonateDialog.value) + TextDialog(R.string.donate_message, showDonateDialog) + + Row { + OutlinedButton( + onClick = { showLicenseDialog.value = true }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) { + Text(stringResource(R.string.app_info_gplv3)) + } + OutlinedButton( + onClick = { showDonateDialog.value = true }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) { + Text(stringResource(R.string.app_info_donate)) + } + } + } + + @Composable + fun TextDialog(@StringRes text: Int, state: MutableState, buttons: @Composable () -> Unit = {}) { + AlertDialog( + text = { + AndroidView({ context -> + TextView(context).also { + it.text = HtmlCompat.fromHtml( + getString(text).replace("\n", "
"), + HtmlCompat.FROM_HTML_MODE_COMPACT) + } + }, modifier = Modifier.verticalScroll(rememberScrollState())) + }, + buttons = buttons, + onDismissRequest = { state.value = false } + ) + } + +} \ No newline at end of file diff --git a/app/src/main/res/menu/app_info_activity.xml b/app/src/main/res/menu/app_info_activity.xml deleted file mode 100644 index 49301e5..0000000 --- a/app/src/main/res/menu/app_info_activity.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ce3703..767df68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Calendar permissions required Notification permissions required Couldn\'t load calendar + No browser installed Sync problems Permissions required Grant @@ -104,7 +105,7 @@ App info Version %1$s-%2$s GPLv3 - https://www.gnu.org/licenses/. - ]]> + "]]>
Donate Subscribe to Webcal feeds News & updates diff --git a/build.gradle b/build.gradle index cf3ffc4..822a254 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,12 @@ buildscript { ext.versions = [ - aboutLibs: '8.9.4', + aboutLibs: '10.6.2', kotlin: '1.8.20', okhttp: '5.0.0-alpha.11', - ksp: '1.0.10', - room: '2.5.1' + ksp: '1.0.11', + room: '2.5.1', + compose: '1.4.6', + composeBom: '2023.04.01' // https://developer.android.com/jetpack/compose/bom ] repositories { -- GitLab From 8996266d290655a88c72682980d88ab7b159919b Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 9 May 2023 11:55:40 +0200 Subject: [PATCH 42/51] Improve build speed with gradle configuration cache (#154) - remove Locator dependency (closes #153) - disable per-app language preferences for now (can be enabled later when AGP 8.1.0 is ready) --- app/build.gradle | 1 - app/src/main/AndroidManifest.xml | 10 ---------- build.gradle | 1 - gradle.properties | 7 ++++++- 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e14301b..7fa356e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,7 +3,6 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'com.mikepenz.aboutlibraries.plugin' apply plugin: 'com.google.devtools.ksp' -apply plugin: 'com.arnyminerz.locator' android { compileSdkVersion 33 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a55da00..00b1e97 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,7 +44,6 @@ android:requestLegacyExternalStorage="true" android:theme="@style/AppTheme" android:enableOnBackInvokedCallback="true" - android:localeConfig="@xml/locales_config" tools:ignore="UnusedAttribute"> - - - -
\ No newline at end of file diff --git a/build.gradle b/build.gradle index 822a254..6728f0c 100644 --- a/build.gradle +++ b/build.gradle @@ -21,7 +21,6 @@ buildscript { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:${versions.aboutLibs}" classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${versions.kotlin}-${versions.ksp}" - classpath "com.arnyminerz.locator:Locator:1.0.2" } } diff --git a/gradle.properties b/gradle.properties index 69d2d97..cf4a0ea 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,11 @@ +# [https://developer.android.com/build/optimize-your-build#optimize] org.gradle.parallel=true -org.gradle.jvmargs=-Xmx4g +org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=512m # use AndroidX android.useAndroidX=true android.databinding.incremental=true + +# configuration cache [https://developer.android.com/build/optimize-your-build#use-the-configuration-cache-experimental] +org.gradle.unsafe.configuration-cache=true +org.gradle.unsafe.configuration-cache-problems=warn -- GitLab From 62814abf0a89988b07d7e91aa69be80d665aa5d8 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sat, 13 May 2023 22:33:25 +0200 Subject: [PATCH 43/51] Update libraries and workflows --- .github/workflows/codeql.yml | 27 ++------------------------- .github/workflows/release.yml | 10 +++++----- .github/workflows/test-dev.yml | 14 ++++++++------ app/build.gradle | 13 ++++++------- build.gradle | 11 +++++------ cert4android | 2 +- ical4android | 2 +- 7 files changed, 28 insertions(+), 51 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f09ebd..a3df4e2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,45 +22,23 @@ jobs: fail-fast: false matrix: language: [ 'java' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v3 with: submodules: recursive - - uses: actions/setup-java@v2 + - uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: 17 - cache: 'gradle' - - uses: gradle/wrapper-validation-action@v1 + - uses: gradle/gradle-build-action@v2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - # - name: Autobuild - # uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - name: Build run: ./gradlew --no-daemon app:assembleStandardDebug @@ -69,4 +47,3 @@ jobs: uses: github/codeql-action/analyze@v2 with: category: "/language:${{matrix.language}}" - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 114fc8d..a3a0db2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,21 +10,21 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: true - - uses: actions/setup-java@v2 + - uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: 17 - cache: 'gradle' - - uses: gradle/wrapper-validation-action@v1 + - uses: gradle/gradle-build-action@v2 - name: Prepare keystore run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks - name: Build signed packages - run: ./gradlew --no-daemon app:assembleRelease + # AboutLibraries 10.6.3 doesn't show any dependencies when configuration cache is used + run: ./gradlew --no-configuration-cache --no-daemon app:assembleRelease env: ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }} diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 412ba1f..b5c5565 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -5,18 +5,20 @@ jobs: name: Tests without emulator runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: true - - uses: actions/setup-java@v2 + - uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: 17 - uses: gradle/gradle-build-action@v2 - name: Check - run: ./gradlew --no-daemon app:lintStandardDebug app:testStandardDebugUnitTest + run: ./gradlew app:lintStandardDebug app:testStandardDebugUnitTest + - name: Archive results + if: always() uses: actions/upload-artifact@v2 with: name: test-results @@ -31,10 +33,10 @@ jobs: matrix: api-level: [31] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: true - - uses: actions/setup-java@v2 + - uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: 17 @@ -74,7 +76,7 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew --no-daemon app:connectedStandardDebugAndroidTest + script: ./gradlew app:connectedStandardDebugAndroidTest - name: Archive results if: always() diff --git a/app/build.gradle b/app/build.gradle index 7fa356e..0276d72 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,7 +6,7 @@ apply plugin: 'com.google.devtools.ksp' android { compileSdkVersion 33 - buildToolsVersion '33.0.0' + buildToolsVersion '33.0.2' namespace 'at.bitfire.icsdroid' @@ -36,15 +36,14 @@ android { buildFeatures { buildConfig = true - viewBinding = true - dataBinding = true compose = true + dataBinding = true + viewBinding = true } composeOptions { - // Keep this in sync with Kotlin version: - // https://developer.android.com/jetpack/androidx/releases/compose-kotlin - kotlinCompilerExtensionVersion = "${versions.compose}" + // Keep in sync with Kotlin version: https://developer.android.com/jetpack/androidx/releases/compose-kotlin + kotlinCompilerExtensionVersion = '1.4.7' } flavorDimensions "distribution" @@ -85,7 +84,7 @@ android { } dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' implementation project(':cert4android') diff --git a/build.gradle b/build.gradle index 6728f0c..a87c8f6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,11 @@ buildscript { ext.versions = [ - aboutLibs: '10.6.2', - kotlin: '1.8.20', - okhttp: '5.0.0-alpha.11', + aboutLibs: '10.6.3', + composeBom: '2023.05.01', // https://developer.android.com/jetpack/compose/bom + kotlin: '1.8.21', // keep in sync with app/build.gradle composeOptions.kotlinCompilerExtensionVersion ksp: '1.0.11', - room: '2.5.1', - compose: '1.4.6', - composeBom: '2023.04.01' // https://developer.android.com/jetpack/compose/bom + okhttp: '5.0.0-alpha.11', + room: '2.5.1' ] repositories { diff --git a/cert4android b/cert4android index 42637da..53503d8 160000 --- a/cert4android +++ b/cert4android @@ -1 +1 @@ -Subproject commit 42637da768a6889e650e1c1074ab6f252040da0b +Subproject commit 53503d8c258687ffb36625630bb5a4d8ff07bd89 diff --git a/ical4android b/ical4android index 2cdc726..8c34e81 160000 --- a/ical4android +++ b/ical4android @@ -1 +1 @@ -Subproject commit 2cdc7261709a090194b05cf9a766d4d94cdb21b7 +Subproject commit 8c34e814a44f75f4b1200e2f031d1c469d181a63 -- GitLab From a83d9c7b3dba302e48032dad006244f4e5bf6f5c Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Wed, 24 May 2023 10:45:54 +0200 Subject: [PATCH 44/51] Fixed NullPointerException (#155) * Fixed NullPointerException Signed-off-by: Arnau Mora Gras * Handle nulls Signed-off-by: Arnau Mora Gras --------- Signed-off-by: Arnau Mora Gras --- .../java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt | 2 +- .../java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt index 13c4b0b..caac241 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt @@ -34,7 +34,7 @@ class AddCalendarDetailsFragment: Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val invalidateOptionsMenu = Observer { + val invalidateOptionsMenu = Observer { requireActivity().invalidateOptionsMenu() } subscriptionSettingsModel.title.observe(this, invalidateOptionsMenu) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt index 7e14917..034731d 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt @@ -42,7 +42,7 @@ class AddCalendarEnterUrlFragment: Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { - val invalidate = Observer { + val invalidate = Observer { requireActivity().invalidateOptionsMenu() } arrayOf( -- GitLab From 03c4bea3c64e3a54dfa4417cea894a4c01c232ff Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 24 May 2023 14:01:19 +0200 Subject: [PATCH 45/51] Update dependencies, use cert4android from jitpack (instead of a submodule) --- .gitmodules | 3 --- app/build.gradle | 6 +++--- build.gradle | 7 +++---- cert4android | 1 - 4 files changed, 6 insertions(+), 11 deletions(-) delete mode 160000 cert4android diff --git a/.gitmodules b/.gitmodules index 39d79d8..dca204d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "ical4android"] path = ical4android url = ../ical4android.git -[submodule "cert4android"] - path = cert4android - url = ../cert4android.git diff --git a/app/build.gradle b/app/build.gradle index 0276d72..baeb973 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -87,13 +87,13 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' - implementation project(':cert4android') + implementation 'com.github.bitfireAT:cert4android:3817e62d9f173d8f8b800d24769f42cb205f560e' implementation project(':ical4android') implementation 'androidx.activity:activity-compose:1.7.1' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.core:core-ktx:1.10.0' + implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.fragment:fragment-ktx:1.5.7' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' @@ -115,7 +115,7 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}" implementation "com.squareup.okhttp3:okhttp-coroutines:${versions.okhttp}" - implementation "joda-time:joda-time:2.12.1" + implementation "joda-time:joda-time:2.12.5" // latest commons that don't require Java 8 //noinspection GradleDependency diff --git a/build.gradle b/build.gradle index a87c8f6..872038b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ buildscript { ext.versions = [ - aboutLibs: '10.6.3', + aboutLibs: '10.7.0', composeBom: '2023.05.01', // https://developer.android.com/jetpack/compose/bom kotlin: '1.8.21', // keep in sync with app/build.gradle composeOptions.kotlinCompilerExtensionVersion ksp: '1.0.11', @@ -10,10 +10,8 @@ buildscript { repositories { google() - maven { - url "https://plugins.gradle.org/m2/" - } mavenCentral() + maven { url "https://plugins.gradle.org/m2/" } } dependencies { classpath 'com.android.tools.build:gradle:8.0.1' @@ -27,5 +25,6 @@ allprojects { repositories { google() mavenCentral() + maven { url "https://jitpack.io" } } } \ No newline at end of file diff --git a/cert4android b/cert4android deleted file mode 160000 index 53503d8..0000000 --- a/cert4android +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 53503d8c258687ffb36625630bb5a4d8ff07bd89 -- GitLab From 3be624a4b44bf189597d8e1b19a8f4b6922597a4 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 24 May 2023 14:02:42 +0200 Subject: [PATCH 46/51] Version bump to 2.2-beta.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index baeb973..c085d75 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { minSdkVersion 21 targetSdkVersion 33 - versionCode 72 - versionName "2.1.1" + versionCode 73 + versionName "2.2-beta.1" setProperty "archivesBaseName", "icsx5-" + getVersionCode() + "-" + getVersionName() -- GitLab From 30f3ea1c0337e0f814d03550bd1fb3af7f737367 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 24 May 2023 14:03:57 +0200 Subject: [PATCH 47/51] Fetch translations from Transifex --- .tx/config | 2 +- app/src/main/res/values-ca/strings.xml | 21 ++--- app/src/main/res/values-cs/strings.xml | 20 +++++ app/src/main/res/values-da/strings.xml | 15 ---- app/src/main/res/values-de/strings.xml | 8 -- app/src/main/res/values-es/strings.xml | 6 -- app/src/main/res/values-fr/strings.xml | 21 ++++- app/src/main/res/values-gl/strings.xml | 13 --- app/src/main/res/values-hu/strings.xml | 15 ---- app/src/main/res/values-ja/strings.xml | 28 ++++--- app/src/main/res/values-nl/strings.xml | 16 ---- app/src/main/res/values-pl-rPL/strings.xml | 94 ++++++++++++++++++++++ app/src/main/res/values-pt-rBR/strings.xml | 14 ---- app/src/main/res/values-pt-rPT/strings.xml | 14 ---- app/src/main/res/values-ru-rUA/strings.xml | 14 ---- app/src/main/res/values-ru/strings.xml | 27 ++++--- app/src/main/res/values-zh/strings.xml | 33 ++++---- 17 files changed, 189 insertions(+), 172 deletions(-) create mode 100644 app/src/main/res/values-pl-rPL/strings.xml diff --git a/.tx/config b/.tx/config index 1e4673c..d2d8fa8 100644 --- a/.tx/config +++ b/.tx/config @@ -1,6 +1,6 @@ [main] host = https://www.transifex.com -lang_map = pt_BR: pt-rBR, pt_PT: pt-rPT, ru_UA: ru-rUA, uk_UA: uk-rUA +lang_map = pl_PL: pl-rPL, pt_BR: pt-rBR, pt_PT: pt-rPT, ru_UA: ru-rUA, uk_UA: uk-rUA [o:bitfireAT:p:icsx5:r:icsx5] file_filter = app/src/main/res/values-/strings.xml diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index fad2f22..1921500 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -4,15 +4,18 @@ Següent Cal permís d\'accés al calendari + Cal el permís de notificacions No s\'ha pogut carregar el calendari Problemes de sincronització Permisos necessaris + Concedir Les meues subscripcions Per a subscriure\'t a un fil Webcal, prem el botó +, o obre una URL Webcal. Sobre ICSx⁵ encara no sincronitzat Període de sincronització + Sincronitza ara Bateria: afegeix ICSx⁵ a la llista blanca per a intervals curts Configuració @@ -28,7 +31,6 @@ Títol i Color Nom del calendari Introdueix una direcció Webcal: - https://example.com/webcal.ics També pots seleccionar un fitxer de l\'emmagatzematge local. Tria un fitxer Nom d\'usuari @@ -37,7 +39,9 @@ Ignora les alertes incloses al calendari Si està habilitat, totes les alarmes que vinguen del servidor seran eliminades. Afegeix una alarma per defecte per als esdeveniments - Alarmes establertes per a %s abans + Afegeix una alarma per defecte per als esdeveniments de tot el dia + Alarma %s abans de començar + Cap alarma per defecte Afegir alarma per defecte Açò afegirà una alarma per a tots els esdeveniments Minuts abans de l\'esdeveniment @@ -77,19 +81,6 @@ Informació de l\'app Versió %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> Donar Subscriu-te a fils Webcal Notícies i Novetats diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 95a0cd3..935f214 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2,23 +2,43 @@ Další + Kalendář nelze načíst + Povolit O aplikaci ICSx⁵ + zatím nesynchromizováno Nastavit interval synchronizace + Synchronizovat nyní Nastavení Heslo Vyžaduje autentizaci + Jméno kalendáře + Nebo zvolte soubor ze zařízení. + Zvolit soubor Uživatelské jméno + Sdílet detaily Zrušit Operace selhala Uložit + Změny uloženy + Chyba synchronizace Uložit + + Pouze ručně + Každých 15 minut + Každou hodinu + Každé 2 hodiny + Každé 4 hodiny + Jednou denně + Jednou týdně + Informace o aplikaci + Jsme rádi, že používáte ICSx⁵, což je open-source software (GPLv3). Protože vývoj ICSx⁵ vyžadoval a stále vyžaduje hodně práce, zvažte prosím přispění. Možná později diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index ea5f32e..0205d2d 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -26,7 +26,6 @@ Titel & Farve Kalendernavn Angiv Webcal adresse - https://example.com/webcal.ics Eller, vælg fil fra lokal lagerplads. Vælg fil Brugernavn @@ -64,20 +63,6 @@ App information Version %1$s-%2$s - https://www.gnu.org/licenses/ -]]> Donér Abonner Webcal feeds Nyheder & opdateringer diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9bd857a..30b1336 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -28,7 +28,6 @@ ICSx⁵ deaktivieren
Name & Farbe Kalendername Webcal Adresse eingeben - https://beispieldomain.de/webcal.ics Alternativ eine Datei aus dem lokalen Dateisystem auswählen Datei auswählen Benutzername @@ -67,13 +66,6 @@ ICSx⁵ deaktivieren
Über ICSx⁵ Version %1$s-%2$s - Spenden Webcal-Feeds abonnieren Aktuelles diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9c8d4c5..b569359 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -58,12 +58,6 @@ Info de la app %2$sVersión %1$s- - <![CDATA[ -Este programa es software libre: puedes redistribuirlo o modificarlo bajo los términos de la Licencia Pública General de GNU publicada por la Free Software Foundation, en la versión 3 de la Licencia o (a tu elección) cualquier versión posterior. - -Est programa se distribuye con la esperanza de que sea útil, pero SIN GARANTÍA; sin ni siquiera la garantía implícita de COMERCIABILIDAD o ADECUACIÓN A UN PROPÓSITO PARTICULAR. Véase la Licencia Pública General de GN para más detalles. - -eberías haber recibido una copia de la Licencia Pública General de GNU junto con este programa. Si no es así, ver <a href="https://www.gnu.org/licenses/">https://www.gnu.org/licenses/</a>. Donar Suscribirse a fuentes Webcal Noticias y actualizaciones diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8e5a09e..f6838da 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -3,15 +3,19 @@ Abonnements à des calendriers Suivant - Permissions du calendrier requises + Autorisations d\'agenda requises + Autorisations de notification requises Impossible de charger le calendrier Problèmes de synchro. + Autorisations requises + Accorder Mes abonnements Pour s\'abonner à un flux Webcal, utilisez le bouton + ou ouvrez une URL Webcal À propos d\'ICSx⁵ pas encore synchronisé Intervalle de synchro. + Synchroniser maintenant Batterie : Ajoutez ICSx⁵ aux exceptions pour des intervalles de synchronisation courts Paramètres @@ -27,11 +31,22 @@ Intitulé & couleur Nom du calendrier Entrez une adresse Webcal : - https://example.com/webcal.ics - Ou sinon, sélectionnez un fichier du stockage local. + Ou sélectionnez un fichier du stockage local. Choisir un fichier Nom d\'utilisateur Validation du calendrier… + Alarmes + Ignorer les alertes inclues dans le calendrier + Si activé, toutes les alarmes venant du serveur seront ignorées. + Ajouter une alarme par défaut à tous les événements + Ajouter une alarme par défaut pour les événements durant toute la journée + Alarme %s avant le début + Pas d\'alarme par défaut + Ajouter une alarme par défaut + Ceci ajoutera une alarme à tous les événements + Minutes avant l\'événement + Définir + Ajouter un nombre valide Partager les détails diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 0cd82c5..926cd85 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -60,19 +60,6 @@ Info da app Versión %1$s - %2$s - https://www.gnu.org/licenses/. - ]]> Doar Subscríbete a fontes Webcal Novas e actualizacións diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index b227b6f..0288b8f 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -27,7 +27,6 @@ Cím és szín Naptár neve Addja meg a Webcal címet: - https://example.com/webcal.ics Alternatívaként adjon meg egy az eszközön tárolt fájlt. Fájl kiválasztása Felhasználónév @@ -66,20 +65,6 @@ Alkalmazásinformáció Verziószám: %1$s-%2$s - https://www.gnu.org/licenses/ címen. - ]]> Támogatás Feliratkozás egy Webcal folyamra Hírek és frissítések diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5d1970c..601fa3a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -6,19 +6,23 @@ カレンダーへのアクセスを許可してください 通知を許可してください カレンダーを読み込めませんでした + ブラウザーがインストールされていません 同期に問題が発生しました 権限を許可してください 許可 + ウェブブラウザーをインストールしてください 購読中のカレンダー + ボタンから登録するか Webcal URL を開いて Webcal フィードを購読できます ICSx⁵ について まだ同期されていません 同期間隔を設定 + 今すぐ同期 バッテリー: 一定間隔で同期を実行するため ICSx⁵ をホワイトリストに追加してください 設定 ダークテーマを使用 + プライバシーポリシー カレンダーを購読 有効な URI が必要です @@ -30,7 +34,6 @@ 件名と色 カレンダーの名前 Webcal アドレスを入力: - https://example.com/webcal.ics ローカルストレージからファイルを選択することもできます ファイルを選択 ユーザー名 @@ -38,11 +41,13 @@ アラーム カレンダーに埋め込まれた通知を無視 有効にすると、そのサーバーから送信されるすべてのアラームが無視されます - すべてのイベントにデフォルトのアラームを追加 - アラームは %s前に設定されます + すべての予定にデフォルトのアラームを追加 + 終日の予定にデフォルトのアラームを追加 + 開始 %s前にアラーム + デフォルトのアラームはありません デフォルトのアラームを追加 - 設定すると、すべてのイベントにアラームを追加します - イベントの...分前 + 設定すると、すべての予定にアラームを追加します + 予定の...分前 設定 有効な数字を入力してください @@ -79,13 +84,14 @@ アプリ情報 バージョン %1$s-%2$s - https://www.gnu.org/licenses/ を参照してください。 - ]]> +あなたはこのプログラムと共に、GNU 一般公衆利用許諾書のコピーを一部受け取っ +ているはずです。もし受け取っていなければ、https://www.gnu.org/licenses/ をご覧ください。 + \"]]>
寄付 Webcal フィードを購読 ニュース & アップデート @@ -93,5 +99,5 @@ オープンソース情報 オープンソースソフトウェア (GPLv3) として ICSx⁵ をお届けできることをとても嬉しく思っています。ICSx⁵ の開発と維持を支える寄付をご検討ください。 寄付ページを表示 - 後で + あとで diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 9fe09fc..e2ac7a9 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -28,7 +28,6 @@ Titel & Kleur Kalendernaam Webcal-adres invoeren: - https://example.com/webcal.ics Of een bestand uit de lokale opslag kiezen. Bestand kiezen Gebruikersnaam @@ -37,7 +36,6 @@ Negeer ingebouwde kalender-herinneringen Bij inschakelen worden alle inkomende herinneringen van de server genegeerd. Voeg een standaard herinnering toe voor elke gebeurtenis - Herinnering instellen op %s voor Standaard herinnering toevoegen Dit voegt een herinnering voor alle gebeurtenissen toe Minuten voor gebeurtenis @@ -77,20 +75,6 @@ App-info Versie %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> Doneren Op Webcal feeds abonneren Nieuws & updates diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml new file mode 100644 index 0000000..c932e61 --- /dev/null +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -0,0 +1,94 @@ + + + Subskrypcje kalendarzy + + Dalej + Wymagane uprawnienia kalendarza + Wymagane uprawnienia do powiadomień + Nie można załadować kalendarza + Problemy z synchronizacją + Wymagane uprawnienia + Udziel + Zainstaluj przeglądarkę internetową + + Moje subskrypcje + Aby zasubskrybować kanał Webcal, użyj przycisku + lub otwórz adres URL Webcal. + O aplikacji ICSx⁵ + jeszcze nie zsynchronizowane + Ustaw interwał synchronizacji + Zsynchronizuj teraz + Bateria: Biała lista ICSx⁵ dla krótkich interwałów synchronizacji + Ustawienia + + Wymuś ciemny motyw + Polityka prywatności + + Subskrybuj kalendarz + Wymagany prawidłowy identyfikator URI + Osoby trzecie mogą łatwo przechwycić Twoje dane uwierzytelniające. Użyj protokołu HTTPS do bezpiecznego uwierzytelniania. + Subskrybuj teraz + Zasubskrybowano pomyślnie + Hasło + Wymaga uwierzytelnienia + Tytuł i kolor + Nazwa kalendarza + Wpisz adres Webcal: + Alternatywnie wybierz plik z pamięci lokalnej. + Wybierz plik + Nazwa użytkownika + Sprawdzanie poprawności zasobu kalendarza... + Alarmy + Ignoruj ​​alerty osadzone w kalendarzu + Jeśli ta opcja jest włączona, wszystkie alarmy przychodzące z serwera zostaną odrzucone. + Dodaj domyślny alarm dla wszystkich wydarzeń + Dodaj domyślny alarm dla wydarzeń całodniowych + Alarm %s przed uruchomieniem + Brak domyślnego alarmu + Dodaj domyślny alarm + Spowoduje to dodanie alarmu dla wszystkich wydarzeń + Minuty przed wydarzeniem + Komplet + Wprowadź prawidłowy numer + + Udostępnij szczegóły + + Edytuj subskrypcję + Anuluj + Anuluj subskrypcję + Subskrypcja została pomyślnie anulowana + Odrzuć + Operacja nie powiodła się + Czy na pewno chcesz anulować subskrypcję tego kalendarza? + Zapisz + Zmiany zapisane + Wyślij adres URL + Zsynchronizuj ten kalendarz + Istnieją niezapisane zmiany. + + Błąd synchronizacji + Zapisz + Ustaw interwał synchronizacji: + + Tylko ręcznie + Co 15 minut + Co godzinę + Co 2 godziny + Co 4 godziny + Raz dziennie + Raz w tygodniu + + Wymagane uprawnienia + Potrzebne uprawnienia do synchronizacji kalendarza + Nie można otworzyć pliku z pamięci masowej + + Informacje o aplikacji + Wersja %1$s-%2$s + Darowizna + Subskrybuj kanały Webcal + Nowości i aktualizacje + Strona internetowa + Informacje Open Source + Cieszymy się, że korzystasz z ICSx⁵, oprogramowania open source (GPLv3). Ponieważ rozwój i utrzymanie ICSx⁵ było i nadal wymaga wiele pracy, rozważ darowiznę. + Pokaż stronę darowizny + Może później + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9807b0b..1e38026 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -58,20 +58,6 @@ Informações do aplicativo Versão %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> Doações Inscrição em feeds Webcal Novidades e atualizações diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index ce6131d..44841f3 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -58,20 +58,6 @@ Informação da aplicação Versão %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> Doar Subscrever feed Webcal Notícias e updates diff --git a/app/src/main/res/values-ru-rUA/strings.xml b/app/src/main/res/values-ru-rUA/strings.xml index 19b4577..39ebc03 100644 --- a/app/src/main/res/values-ru-rUA/strings.xml +++ b/app/src/main/res/values-ru-rUA/strings.xml @@ -58,20 +58,6 @@ Информация о приложении Версия %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> Пожертвовать Подписаться на Webcal календари Новости & обновления diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 87d9869..638e29f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -6,19 +6,23 @@ Необходимы разрешения для работы с календарем Требуются разрешения на отправку уведомлений Не удалось загрузить календарь + Браузер не установлен Проблемы с синхронизацией Требуются разрешения Предоставить + Пожалуйста, установите браузер Мои подписки Для подписки на ленту Webcal используйте кнопку + или откройте веб-адрес Webcal. О ICSx⁵ еще не синхронизировано Задать интервал синхронизации + Синхронизировать сейчас Батарея: внесите ICSx⁵ в белый список для использования небольших интервалов синхронизации Настройки Темная тема + Политика конфиденциальности Подписаться на календарь Требуется корректный URI @@ -30,7 +34,6 @@ Название и цвет Название календаря Введите адрес Webcal: - https://example.com/webcal.ics Или выберите файл из локального хранилища. Выбрать файл Имя пользователя @@ -39,7 +42,9 @@ Игнорировать оповещения, встроенные в календарь Если включено, все входящие оповещения с сервера будут отклонены. Добавить оповещение по умолчанию для всех событий - Оповещения, установленные до %s + Добавьте оповещение по умолчанию для событий на весь день + Оповещение за %s до начала + Нет оповещений по умолчанию Добавить оповещение по умолчанию Это добавит оповещение для всех событий За несколько минут до события @@ -79,17 +84,13 @@ Информация о приложении Версия %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> + https://www.gnu.org/licenses/. + \"]]> Пожертвовать Подписаться на календари Webcal Новости и обновления diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f5a6487..4f4fde1 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -6,19 +6,23 @@ 请授予访问日历的权限 需要通知权限 无法加载日历 + 未安装浏览器 同步问题 需要权限 授予 + 请安装网络浏览器 我的订阅 如需订阅Webcal源,请点击 + 按钮或打开Webcal的URL地址 关于ICSx⁵ 尚未同步 设置同步间隔时间 + 立即同步 电池:请将ICSx⁵列入允许频繁同步的白名单 设置 强制深色主题 + 隐私政策 订阅至日历 URI无效 @@ -30,7 +34,6 @@ 标题 & 颜色 日历名称 输入 Webcal 地址 - https://example.com/webcal.ics 或者从本地存储选择一个文件 选择文件 用户名 @@ -39,7 +42,9 @@ 忽略嵌入在日历中的警报 一旦启用,所有之后来自该服务器的闹铃都将被无视。 为所有事件添加默认闹铃 - 闹铃设为事件发生前 %s + 为全天事件添加默认闹铃 + 开始前 %s响铃 + 无默认闹铃 添加默认闹铃 这会为所有事件添加一个闹铃 事件开始多少分钟 @@ -79,20 +84,20 @@ 应用信息 版本 %1$s-%2$s - https://www.gnu.org/licenses/. - ]]> +You should have received a copy of the GNU General Public License +along with this program. If not, see https://www.gnu.org/licenses/. + \"]]>
捐赠 订阅Webcal源 新闻 & 更新 -- GitLab From e6c22344d19fe56a981e195b487a45a66a4af56d Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 24 May 2023 14:38:50 +0200 Subject: [PATCH 48/51] Fix ProGuard rules --- app/proguard-rules.pro | 29 ++++++++++++++++------------- settings.gradle | 2 +- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a6e5b62..7012b93 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,20 +7,23 @@ -dontobfuscate --optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* --optimizationpasses 5 --allowaccessmodification --dontpreverify +# ical4j: keep all iCalendar properties/parameters (used via reflection) +-keep class net.fortuna.ical4j.** { *; } -# ical4j: ignore unused dynamic libraries --dontwarn aQute.** --dontwarn groovy.** # Groovy-based ContentBuilder not used --dontwarn javax.cache.** # no JCache support in Android --dontwarn net.fortuna.ical4j.model.** --dontwarn org.codehaus.groovy.** --dontwarn org.apache.log4j.** # ignore warnings from log4j dependency --keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime) --keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing) +# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations) +-keepclassmembers,allowoptimization enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} # keep ICSx⁵ and ical4android -keep class at.bitfire.** { *; } # all ICSx⁵ code is required + +# Additional rules which are now required since missing classes can't be ignored in R8 anymore. +# [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning] +-dontwarn groovy.** +-dontwarn java.beans.Transient +-dontwarn org.codehaus.groovy.** +-dontwarn org.joda.** +-dontwarn org.json.* +-dontwarn org.xmlpull.** diff --git a/settings.gradle b/settings.gradle index c1aa803..cfa2a37 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,4 +4,4 @@ pluginManagement { } } -include ':app', ':cert4android', ':ical4android' +include ':app', ':ical4android' -- GitLab From ebe2bb5fe4a19820e5eb9e7d3658ce323b50492c Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sun, 25 Jun 2023 14:16:24 +0200 Subject: [PATCH 49/51] Replaced Twitter link with Mastodon (#157) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c0fbe48..ea6f69e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ time tables of your school/university or event files of your sports team). Please see the [ICSx⁵ Web site](https://icsx5.bitfire.at) for comprehensive information about ICSx⁵. -News and updates: [@icsx5app](https://twitter.com/icsx5app) +News and updates: [@davx5app@fosstodon.org](https://fosstodon.org/@davx5app) Help, discussion, ideas, bug reports: [ICSx⁵ forum](https://icsx5.bitfire.at/forums/) -- GitLab From 78a7ae8f5876ffe0831ce7e646082dc053c45d68 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Fri, 7 Jul 2023 20:49:35 +0200 Subject: [PATCH 50/51] Upgraded AGP to 8.0.2 (#159) Signed-off-by: Arnau Mora --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 872038b..8a8aaa5 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ buildscript { maven { url "https://plugins.gradle.org/m2/" } } dependencies { - classpath 'com.android.tools.build:gradle:8.0.1' + classpath 'com.android.tools.build:gradle:8.0.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:${versions.aboutLibs}" classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${versions.kotlin}-${versions.ksp}" -- GitLab From 03d91e212beb29b6c93388f74a37adb65e17f065 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Sat, 8 Jul 2023 12:04:58 +0200 Subject: [PATCH 51/51] Using ical4android as library from Jitpack (#158) * Using ical4android as library from Jitpack Signed-off-by: Arnau Mora * Excluded incompatible dependencies Signed-off-by: Arnau Mora --------- Signed-off-by: Arnau Mora --- .gitmodules | 3 --- app/build.gradle | 13 ++++++++++++- ical4android | 1 - 3 files changed, 12 insertions(+), 5 deletions(-) delete mode 160000 ical4android diff --git a/.gitmodules b/.gitmodules index dca204d..e69de29 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "ical4android"] - path = ical4android - url = ../ical4android.git diff --git a/app/build.gradle b/app/build.gradle index c085d75..7af7195 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -83,12 +83,23 @@ android { } } +configurations { + all { + // exclude modules which are in conflict with system libraries + exclude module: "commons-logging" + exclude group: "org.json", module: "json" + + // Groovy requires SDK 26+, and it's not required, so exclude it + exclude group: 'org.codehaus.groovy' + } +} + dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' implementation 'com.github.bitfireAT:cert4android:3817e62d9f173d8f8b800d24769f42cb205f560e' - implementation project(':ical4android') + implementation 'com.github.bitfireAT:ical4android:a78e72f580' implementation 'androidx.activity:activity-compose:1.7.1' implementation 'androidx.appcompat:appcompat:1.6.1' diff --git a/ical4android b/ical4android deleted file mode 160000 index 8c34e81..0000000 --- a/ical4android +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8c34e814a44f75f4b1200e2f031d1c469d181a63 -- GitLab