diff --git a/app/build.gradle b/app/build.gradle index 268615fab2b062fda972a0f95de962acb7d3e685..41d291c8829d0bb6e023b0040e0fd1ecd04d660f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,14 +18,14 @@ android { defaultConfig { applicationId "foundation.e.accountmanager" - versionCode 403080000 - versionName '4.3.8' + versionCode 403090002 + versionName '4.3.9' buildConfigField "long", "buildTime", System.currentTimeMillis() + "L" setProperty "archivesBaseName", "davx5-ose-" + getVersionName() - minSdkVersion 24 // Android 7.1 + minSdkVersion 24 // Android 7.0 targetSdkVersion 33 // Android 13 buildConfigField "String", "userAgent", "\"DAVx5\"" @@ -205,14 +205,11 @@ dependencies { implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}" //noinspection GradleDependency - don't update until API level 26 (Android 8) is the minimum API [https://github.com/bitfireAT/davx5/issues/130] implementation 'commons-io:commons-io:2.8.0' - //noinspection GradleDependency - dnsjava 3+ needs Java 8/Android 7 - implementation 'dnsjava:dnsjava:2.1.9' + implementation 'dnsjava:dnsjava:3.5.2' + implementation "io.github.nsk90:kstatemachine-jvm:0.22.1" implementation 'net.openid:appauth:0.11.1' - //noinspection GradleDependency implementation "org.apache.commons:commons-collections4:${versions.commonsCollections}" - //noinspection GradleDependency implementation "org.apache.commons:commons-lang3:${versions.commonsLang}" - //noinspection GradleDependency implementation "org.apache.commons:commons-text:${versions.commonsText}" implementation 'junit:junit:4.13.2' implementation 'foundation.e:elib:0.0.1-alpha11' @@ -245,6 +242,7 @@ dependencies { androidTestImplementation 'junit:junit:4.13.2' testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" + testImplementation 'junit:junit:4.13.2' } def retrieveKey(String keyName) { diff --git a/app/proguard-rules-release.pro b/app/proguard-rules-release.pro index 18cfc53e3527c7cfafde38857aa0b2d61eebeeaf..6afb753244e41d9ab9532a062c5d4e0a35c698cc 100644 --- a/app/proguard-rules-release.pro +++ b/app/proguard-rules-release.pro @@ -70,9 +70,13 @@ # 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 com.android.org.conscrypt.SSLParametersImpl +-dontwarn com.sun.jna.** # dnsjava -dontwarn groovy.** -dontwarn java.beans.Transient +-dontwarn javax.naming.NamingException # dnsjava +-dontwarn javax.naming.directory.** # dnsjava -dontwarn junit.textui.TestRunner +-dontwarn lombok.** # dnsjava -dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl -dontwarn org.bouncycastle.jsse.** -dontwarn org.codehaus.groovy.** diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorkerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorkerTest.kt index c2baac00b6949c417a55a6ec47988175fab017de..2e9367fb721e1a86286f5c5204dac184094407fe 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorkerTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorkerTest.kt @@ -143,13 +143,13 @@ class RefreshCollectionsWorkerTest { } @Test - fun testQueryHomesets() { + fun testDiscoverHomesets() { val service = createTestService(Service.TYPE_CARDDAV)!! val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL) // Query home sets RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient) - .queryHomeSets(baseUrl) + .discoverHomesets(baseUrl) // Check home sets have been saved to database assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt index b62ff918c7b16a40697e50d4c6fa3948dfdcbe03..edf26ad9787cd29a45fa23729d839937afb2b4ab 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt @@ -5,7 +5,7 @@ package at.bitfire.davdroid.ui.webdav import android.security.NetworkSecurityPolicy -import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.core.app.ApplicationProvider import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.WebDavMount import dagger.hilt.android.testing.HiltAndroidRule @@ -34,7 +34,7 @@ class AddWebdavMountActivityTest { fun setUp() { hiltRule.inject() - model = spyk(AddWebdavMountActivity.Model(InstrumentationRegistry.getInstrumentation().targetContext, db)) + model = spyk(AddWebdavMountActivity.Model(ApplicationProvider.getApplicationContext(), db)) Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/App.kt b/app/src/main/kotlin/at/bitfire/davdroid/App.kt index aa2b223071a8f4ff9a944dded394cecf13436fc7..4dee16d0e6a9db73886e8d2d319ca8582258f007 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/App.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/App.kt @@ -63,6 +63,11 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide @Inject lateinit var workerFactory: HiltWorkerFactory + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + override fun onCreate() { super.onCreate() Logger.initialize(this) @@ -105,11 +110,6 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide } } - override fun getWorkManagerConfiguration() = - Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() - override fun uncaughtException(t: Thread, e: Throwable) { Logger.log.log(Level.SEVERE, "Unhandled exception!", e) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ForegroundService.kt b/app/src/main/kotlin/at/bitfire/davdroid/ForegroundService.kt index d9c78b5626691bb14f422f95bc35325e9cc8ef3b..7dab5f896de9ae168ea48d7ad7c71d9898d0e722 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ForegroundService.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ForegroundService.kt @@ -12,6 +12,7 @@ import android.os.Build import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.content.getSystemService import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.AppSettingsActivity @@ -24,6 +25,25 @@ import dagger.hilt.components.SingletonComponent class ForegroundService : Service() { + override fun onCreate() { + super.onCreate() + + /* Call startForeground as soon as possible (must be within 5 seconds after the service has been created). + If the foreground service shouldn't remain active (because the setting has been disabled), + we'll immediately stop it with stopForeground() in onStartCommand(). */ + val settingsIntent = Intent(this, AppSettingsActivity::class.java).apply { + putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, Settings.FOREGROUND_SERVICE) + } + val builder = NotificationCompat.Builder(this, NotificationUtils.CHANNEL_STATUS) + .setSmallIcon(R.drawable.ic_foreground_notify) + .setContentTitle(getString(R.string.foreground_service_notify_title)) + .setContentText(getString(R.string.foreground_service_notify_text)) + .setStyle(NotificationCompat.BigTextStyle()) + .setContentIntent(PendingIntent.getActivity(this, 0, settingsIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + .setCategory(NotificationCompat.CATEGORY_STATUS) + startForeground(NotificationUtils.NOTIFY_FOREGROUND, builder.build()) + } + companion object { @EntryPoint @@ -43,12 +63,8 @@ class ForegroundService : Service() { * Whether the app is currently exempted from battery optimization. * @return true if battery optimization is not applied to the current app; false if battery optimization is applied */ - fun batteryOptimizationWhitelisted(context: Context) = - if (Build.VERSION.SDK_INT >= 23) { // battery optimization exists since Android 6 (SDK level 23) - val powerManager = context.getSystemService(PowerManager::class.java) - powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) - } else - true + private fun batteryOptimizationWhitelisted(context: Context) = + context.getSystemService()!!.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) /** * Whether the foreground service is enabled (checked) in the app settings. @@ -67,6 +83,7 @@ class ForegroundService : Service() { if (batteryOptimizationWhitelisted(context)) { val serviceIntent = Intent(ACTION_FOREGROUND, null, context, ForegroundService::class.java) if (Build.VERSION.SDK_INT >= 26) + // we now have 5 seconds to call Service.startForeground() [https://developer.android.com/about/versions/oreo/android-8.0-changes.html#back-all] context.startForegroundService(serviceIntent) else context.startService(serviceIntent) @@ -97,21 +114,14 @@ class ForegroundService : Service() { override fun onBind(intent: Intent?): Nothing? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (foregroundServiceActivated(this)) { - val settingsIntent = Intent(this, AppSettingsActivity::class.java).apply { - putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, Settings.FOREGROUND_SERVICE) - } - val builder = NotificationCompat.Builder(this, NotificationUtils.CHANNEL_STATUS) - .setSmallIcon(R.drawable.ic_foreground_notify) - .setContentTitle(getString(R.string.foreground_service_notify_title)) - .setContentText(getString(R.string.foreground_service_notify_text)) - .setStyle(NotificationCompat.BigTextStyle()) - .setContentIntent(PendingIntent.getActivity(this, 0, settingsIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - .setCategory(NotificationCompat.CATEGORY_STATUS) - startForeground(NotificationUtils.NOTIFY_FOREGROUND, builder.build()) + // Command is always ACTION_FOREGROUND → re-evaluate foreground setting + if (foregroundServiceActivated(this)) + // keep service open return START_STICKY - } else { + else { + // don't keep service active stopForeground(true) + stopSelf() // Stop the service so that onCreate() will run again for the next command return START_NOT_STICKY } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/Android10Resolver.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/Android10Resolver.kt index 2479b7aa940d49113378c6890d44ff34ed267ca0..892fe0b158adfcb0b24c300b458b92f6115a63e0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/Android10Resolver.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/Android10Resolver.kt @@ -11,10 +11,12 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.runBlocking +import org.xbill.DNS.EDNSOption import org.xbill.DNS.Message import org.xbill.DNS.Resolver import org.xbill.DNS.ResolverListener import org.xbill.DNS.TSIG +import java.time.Duration /** * dnsjava Resolver that uses Android's [DnsResolver] API, which is available since Android 10. @@ -42,6 +44,7 @@ object Android10Resolver: Resolver { future.await() } + @Deprecated("Deprecated in dnsjava") override fun sendAsync(query: Message, listener: ResolverListener) = // currently not used by dnsjava, so no need to implement it throw NotImplementedError() @@ -63,7 +66,12 @@ object Android10Resolver: Resolver { // not applicable } - override fun setEDNS(level: Int, payloadSize: Int, flags: Int, options: MutableList?) { + override fun setEDNS( + version: Int, + payloadSize: Int, + flags: Int, + options: MutableList? + ) { // not applicable } @@ -71,12 +79,18 @@ object Android10Resolver: Resolver { // not applicable } + @Deprecated("Deprecated in dnsjava") override fun setTimeout(secs: Int, msecs: Int) { // not applicable } + @Deprecated("Deprecated in dnsjava") override fun setTimeout(secs: Int) { // not applicable } + override fun setTimeout(timeout: Duration?) { + // not applicable + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt index 4f260d8919114409d631df0e2cfed5ce8395c812..a8a7f62960b1fa6e938baa4d0d87fd690f784537 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBook.kt @@ -5,7 +5,11 @@ package at.bitfire.davdroid.resource import android.accounts.Account import android.accounts.AccountManager -import android.content.* +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context import android.os.Build import android.os.Bundle import android.os.RemoteException @@ -14,7 +18,6 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.Groups import android.provider.ContactsContract.RawContacts import android.util.Base64 -import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.SyncState @@ -23,10 +26,13 @@ import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.util.DavUtils import at.bitfire.davdroid.util.setAndVerifyUserData -import at.bitfire.vcard4android.* - +import at.bitfire.vcard4android.AndroidAddressBook +import at.bitfire.vcard4android.AndroidContact +import at.bitfire.vcard4android.AndroidGroup +import at.bitfire.vcard4android.Constants +import at.bitfire.vcard4android.GroupMethod import java.io.ByteArrayOutputStream -import java.util.* +import java.util.LinkedList import java.util.logging.Level /** @@ -36,10 +42,15 @@ import java.util.logging.Level * DAVx5 main account. */ open class LocalAddressBook( - private val context: Context, - account: Account, - provider: ContentProviderClient? -): AndroidAddressBook(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection { + private val context: Context, + account: Account, + provider: ContentProviderClient? +) : AndroidAddressBook( + account, + provider, + LocalContact.Factory, + LocalGroup.Factory +), LocalCollection { companion object { @@ -57,8 +68,16 @@ open class LocalAddressBook( * @param info collection where to take the name and settings from * @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info] */ - fun create(context: Context, db: AppDatabase, provider: ContentProviderClient, mainAccount: Account, info: Collection, forceReadOnly: Boolean): LocalAddressBook { - val service = db.serviceDao().getByAccountName(mainAccount.name) ?: throw IllegalArgumentException("Service not found") + fun create( + context: Context, + db: AppDatabase, + provider: ContentProviderClient, + mainAccount: Account, + info: Collection, + forceReadOnly: Boolean + ): LocalAddressBook { + val service = db.serviceDao().getByAccountName(mainAccount.name) + ?: throw IllegalArgumentException("Service not found") val account = Account(accountName(mainAccount, info), service.addressBookAccountType) val userData = initialUserData(mainAccount, info.url.toString()) @@ -79,24 +98,29 @@ open class LocalAddressBook( return addressBook } - /** - * Finds and returns all the local address books belonging to a given main account - * - * @param mainAccount the main account to use - * @return list of [mainAccount]'s address books - */ - fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account?): List { + /** + * Finds and returns all the local address books belonging to a given main account + * + * @param mainAccount the main account to use + * @return list of [mainAccount]'s address books + */ + fun findAll( + context: Context, + provider: ContentProviderClient?, + mainAccount: Account? + ): List { val accounts = AccountUtils.getAddressBookAccounts(context) return accounts.toTypedArray().map { LocalAddressBook(context, it, provider) } .filter { mainAccount == null || it.mainAccount == mainAccount } .toList() - } + } fun accountName(mainAccount: Account, info: Collection): String { val baos = ByteArrayOutputStream() baos.write(info.url.hashCode()) - val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING) + val hash = + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING) val sb = StringBuilder(info.displayName.let { if (it.isNullOrEmpty()) @@ -157,6 +181,7 @@ open class LocalAddressBook( get() = groupMethod == GroupMethod.GROUP_VCARDS private var _mainAccount: Account? = null + /** * The associated main account which this address book's accounts belong to. * @@ -172,8 +197,16 @@ open class LocalAddressBook( } set(newMainAccount) { AccountManager.get(context).let { accountManager -> - accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name) - accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type) + accountManager.setAndVerifyUserData( + account, + USER_DATA_MAIN_ACCOUNT_NAME, + newMainAccount.name + ) + accountManager.setAndVerifyUserData( + account, + USER_DATA_MAIN_ACCOUNT_TYPE, + newMainAccount.type + ) } _mainAccount = newMainAccount @@ -181,12 +214,13 @@ open class LocalAddressBook( var url: String get() = AccountManager.get(context).getUserData(account, USER_DATA_URL) - ?: throw IllegalStateException("Address book has no URL") + ?: throw IllegalStateException("Address book has no URL") set(url) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_URL, url) override var readOnly: Boolean get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null - set(readOnly) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null) + set(readOnly) = AccountManager.get(context) + .setAndVerifyUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null) override var lastSyncState: SyncState? get() = syncState?.let { SyncState.fromString(String(it)) } @@ -212,12 +246,16 @@ open class LocalAddressBook( } override fun removeNotDirtyMarked(flags: Int): Int { - var number = provider!!.delete(rawContactsSyncUri(), - "NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString())) + var number = provider!!.delete( + rawContactsSyncUri(), + "NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()) + ) if (includeGroups) - number += provider!!.delete(groupsSyncUri(), - "NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString())) + number += provider!!.delete( + groupsSyncUri(), + "NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()) + ) return number } @@ -253,7 +291,12 @@ open class LocalAddressBook( // update data rows val dataValues = ContentValues(1) dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0) - provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null) + provider!!.update( + syncAdapterURI(ContactsContract.Data.CONTENT_URI), + dataValues, + null, + null + ) // update group rows val groupValues = ContentValues(1) @@ -267,22 +310,6 @@ open class LocalAddressBook( fun delete() { val accountManager = AccountManager.get(context) - val email = accountManager.getUserData(account, AccountSettings.KEY_EMAIL_ADDRESS) - - @Suppress("DEPRECATION") - if (Build.VERSION.SDK_INT >= 22) { - removeAccount(accountManager, email) - } - else { - removeAccountForOlderSdk(accountManager, email) - } - } - - private fun removeAccountForOlderSdk(accountManager: AccountManager, email: String?) { - accountManager.removeAccount(account, null, null) - } - - private fun removeAccount(accountManager: AccountManager, email: String?) { accountManager.removeAccount(account, null, null, null) } @@ -305,14 +332,19 @@ open class LocalAddressBook( // Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want) for (periodicSync in ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY)) - ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras) + ContentResolver.removePeriodicSync( + periodicSync.account, + periodicSync.authority, + periodicSync.extras + ) } /* operations on members (contacts/groups) */ override fun findByName(name: String): LocalAddress? { - val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull() + val result = + queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull() return if (includeGroups) result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull() else @@ -325,10 +357,10 @@ open class LocalAddressBook( * @throws RemoteException on content provider errors */ override fun findDeleted() = - if (includeGroups) - findDeletedContacts() + findDeletedGroups() - else - findDeletedContacts() + if (includeGroups) + findDeletedContacts() + findDeletedGroups() + else + findDeletedContacts() fun findDeletedContacts() = queryContacts(RawContacts.DELETED, null) fun findDeletedGroups() = queryGroups(Groups.DELETED, null) @@ -338,10 +370,11 @@ open class LocalAddressBook( * @throws RemoteException on content provider errors */ override fun findDirty() = - if (includeGroups) - findDirtyContacts() + findDirtyGroups() - else - findDirtyContacts() + if (includeGroups) + findDirtyContacts() + findDirtyGroups() + else + findDirtyContacts() + fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null) fun findDirtyGroups() = queryGroups(Groups.DIRTY, null) @@ -359,9 +392,13 @@ open class LocalAddressBook( fun getContactIdsByGroupMembership(groupId: Long): List { val ids = LinkedList() - provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.RAW_CONTACT_ID), + provider!!.query( + syncAdapterURI(ContactsContract.Data.CONTENT_URI), + arrayOf(GroupMembership.RAW_CONTACT_ID), "(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?)", - arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupId.toString()), null)?.use { cursor -> + arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupId.toString()), + null + )?.use { cursor -> while (cursor.moveToNext()) ids += cursor.getLong(0) } @@ -369,8 +406,10 @@ open class LocalAddressBook( } fun getContactUidFromId(contactId: Long): String? { - provider!!.query(rawContactsSyncUri(), arrayOf(AndroidContact.COLUMN_UID), - "${RawContacts._ID}=?", arrayOf(contactId.toString()), null)?.use { cursor -> + provider!!.query( + rawContactsSyncUri(), arrayOf(AndroidContact.COLUMN_UID), + "${RawContacts._ID}=?", arrayOf(contactId.toString()), null + )?.use { cursor -> if (cursor.moveToNext()) return cursor.getString(0) } @@ -387,8 +426,8 @@ open class LocalAddressBook( * @throws RemoteException on content provider errors */ fun verifyDirty(): Int { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - throw IllegalStateException("verifyDirty() should not be called on Android != 7") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + throw IllegalStateException("verifyDirty() should not be called on Android != 7.0") var reallyDirty = 0 for (contact in findDirtyContacts()) { @@ -396,10 +435,18 @@ open class LocalAddressBook( val currentHash = contact.dataHashCode() if (lastHash == currentHash) { // hash is code still the same, contact is not "really dirty" (only metadata been have changed) - Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact) + Logger.log.log( + Level.FINE, + "Contact data hash has not changed, resetting dirty flag", + contact + ) contact.resetDirty() } else { - Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact) + Logger.log.log( + Level.FINE, + "Contact data has changed from hash $lastHash to $currentHash", + contact + ) reallyDirty++ } } @@ -422,15 +469,18 @@ open class LocalAddressBook( */ @Synchronized fun findOrCreateGroup(title: String): Long { - provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID), - "${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor -> + provider!!.query( + syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID), + "${Groups.TITLE}=?", arrayOf(title), null + )?.use { cursor -> if (cursor.moveToNext()) return cursor.getLong(0) } val values = ContentValues(1) values.put(Groups.TITLE, title) - val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group") + val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) + ?: throw RemoteException("Couldn't create contact group") return ContentUris.parseId(uri) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt index a1c6afc20d1d645016ca02dbb3cef92210d0fbaf..dffc2b41aaf89b5a0a635b1f9131eb52206813a0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt @@ -12,12 +12,21 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.RawContacts.Data import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.resource.contactrow.* -import at.bitfire.vcard4android.* +import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler +import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder +import at.bitfire.davdroid.resource.contactrow.GroupMembershipHandler +import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesBuilder +import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesHandler +import at.bitfire.vcard4android.AndroidAddressBook +import at.bitfire.vcard4android.AndroidContact +import at.bitfire.vcard4android.AndroidContactFactory +import at.bitfire.vcard4android.BatchOperation +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact import ezvcard.Ezvcard import org.apache.commons.lang3.StringUtils import java.io.FileNotFoundException -import java.util.* +import java.util.UUID class LocalContact: AndroidContact, LocalAddress { @@ -91,7 +100,7 @@ class LocalContact: AndroidContact, LocalAddress { values.put(COLUMN_ETAG, eTag) values.put(ContactsContract.RawContacts.DIRTY, 0) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { // workaround for Android 7 which sets DIRTY flag when only meta-data is changed val hashCode = dataHashCode() values.put(COLUMN_HASHCODE, hashCode) @@ -132,7 +141,7 @@ class LocalContact: AndroidContact, LocalAddress { * @return hash code of contact data (including group memberships) */ internal fun dataHashCode(): Int { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) throw IllegalStateException("dataHashCode() should not be called on Android != 7") // reset contact so that getContact() reads from database @@ -146,7 +155,7 @@ class LocalContact: AndroidContact, LocalAddress { } fun updateHashCode(batch: BatchOperation?) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) throw IllegalStateException("updateHashCode() should not be called on Android != 7") val hashCode = dataHashCode() @@ -163,7 +172,7 @@ class LocalContact: AndroidContact, LocalAddress { } fun getLastHashCode(): Int { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) throw IllegalStateException("getLastHashCode() should not be called on Android != 7") addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c -> diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt index 56e555a97af0961c2c06b28e62fdad7b647a2b99..25db4e73443fe03a3e30eaeadbce19998e67b060 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt @@ -15,9 +15,16 @@ import android.provider.ContactsContract.Groups import android.provider.ContactsContract.RawContacts import android.provider.ContactsContract.RawContacts.Data import at.bitfire.davdroid.log.Logger -import at.bitfire.vcard4android.* +import at.bitfire.vcard4android.AndroidAddressBook +import at.bitfire.vcard4android.AndroidContact +import at.bitfire.vcard4android.AndroidGroup +import at.bitfire.vcard4android.AndroidGroupFactory +import at.bitfire.vcard4android.BatchOperation +import at.bitfire.vcard4android.CachedGroupMembership +import at.bitfire.vcard4android.Contact import org.apache.commons.lang3.StringUtils -import java.util.* +import java.util.LinkedList +import java.util.UUID class LocalGroup: AndroidGroup, LocalAddress { @@ -80,7 +87,7 @@ class LocalGroup: AndroidGroup, LocalAddress { changeContactIDs += missingMember.id!! } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) // workaround for Android 7 which sets DIRTY flag when only meta-data is changed changeContactIDs .map { addressBook.findContactById(it) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt index a1edacb26d2f11affe6343c2be5389b5db17a185..8efd63168117130b56c14f93fc6424736b8e26a4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -62,10 +62,10 @@ import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationUtils import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible import at.bitfire.davdroid.ui.account.SettingsActivity +import at.bitfire.davdroid.util.DavUtils.parent import com.google.common.util.concurrent.ListenableFuture import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import net.openid.appauth.AuthState import okhttp3.HttpUrl import okhttp3.OkHttpClient import java.util.logging.Level @@ -92,7 +92,7 @@ class RefreshCollectionsWorker @AssistedInject constructor( @Assisted workerParams: WorkerParameters, var db: AppDatabase, var settings: SettingsManager -): Worker(appContext, workerParams) { +) : Worker(appContext, workerParams) { companion object { @@ -160,14 +160,16 @@ class RefreshCollectionsWorker @AssistedInject constructor( * @return boolean true if worker with matching state was found */ fun isWorkerInState(context: Context, workerName: String, workState: WorkInfo.State) = - WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName).map { - workInfoList -> workInfoList.any { workInfo -> workInfo.state == workState } - } + WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName) + .map { workInfoList -> + workInfoList.any { workInfo -> workInfo.state == workState } + } } val serviceId: Long = inputData.getLong(ARG_SERVICE_ID, -1) - val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service #$serviceId not found") + val service = db.serviceDao().get(serviceId) + ?: throw IllegalArgumentException("Service #$serviceId not found") val account = Account(service.accountName, service.accountType) override fun doWork(): Result { @@ -188,7 +190,7 @@ class RefreshCollectionsWorker @AssistedInject constructor( // refresh home set list (from principal url) service.principal?.let { principalUrl -> Logger.log.fine("Querying principal $principalUrl for home sets") - refresher.queryHomeSets(principalUrl) + refresher.discoverHomesets(principalUrl) } // refresh home sets and their member collections @@ -201,7 +203,7 @@ class RefreshCollectionsWorker @AssistedInject constructor( refresher.refreshPrincipals() } - } catch(e: InvalidAccountException) { + } catch (e: InvalidAccountException) { Logger.log.log(Level.SEVERE, "Invalid account", e) return Result.failure() } catch (e: UnauthorizedException) { @@ -214,7 +216,7 @@ class RefreshCollectionsWorker @AssistedInject constructor( settingsIntent ) return Result.failure() - } catch(e: Exception) { + } catch (e: Exception) { Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e) val debugIntent = DebugInfoActivity.IntentBuilder(applicationContext) @@ -229,36 +231,48 @@ class RefreshCollectionsWorker @AssistedInject constructor( } - // Success return Result.success() } override fun getForegroundInfoAsync(): ListenableFuture = CallbackToFutureAdapter.getFuture { completer -> - val notification = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_STATUS) - .setSmallIcon(R.drawable.ic_foreground_notify) - .setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title)) - .setContentText(applicationContext.getString(R.string.foreground_service_notify_text)) - .setStyle(NotificationCompat.BigTextStyle()) - .setCategory(NotificationCompat.CATEGORY_STATUS) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() + val notification = + NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_STATUS) + .setSmallIcon(R.drawable.ic_foreground_notify) + .setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title)) + .setContentText(applicationContext.getString(R.string.foreground_service_notify_text)) + .setStyle(NotificationCompat.BigTextStyle()) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() completer.set(ForegroundInfo(NotificationUtils.NOTIFY_SYNC_EXPEDITED, notification)) } private fun notifyRefreshError(contentText: String, contentIntent: Intent) { - val notify = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_GENERAL) - .setSmallIcon(R.drawable.ic_sync_problem_notify) - .setContentTitle(applicationContext.getString(R.string.refresh_collections_worker_refresh_failed)) - .setContentText(contentText) - .setContentIntent(PendingIntent.getActivity(applicationContext, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - .setSubText(account.name) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .build() + val notify = + NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(applicationContext.getString(R.string.refresh_collections_worker_refresh_failed)) + .setContentText(contentText) + .setContentIntent( + PendingIntent.getActivity( + applicationContext, + 0, + contentIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + .setSubText(account.name) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .build() NotificationManagerCompat.from(applicationContext) - .notifyIfPossible(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify) + .notifyIfPossible( + serviceId.toString(), + NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, + notify + ) } /** @@ -271,28 +285,25 @@ class RefreshCollectionsWorker @AssistedInject constructor( val httpClient: OkHttpClient ) { + val alreadyQueried = mutableSetOf() + /** - * Checks if the given URL defines home sets and adds them to given home set list. - * - * @param principalUrl Principal URL to query - * @param forPersonalHomeset Whether this is the first call of this recursive method. - * Indicates that these found home sets are considered "personal", as they belong to the - * current-user-principal. + * Starting at current-user-principal URL, tries to recursively find and save all user relevant home sets. * - * Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by - * other principals and still be considered "personal" (belonging to the current-user-principal). * - * *true* = found home sets belong to the current-user-principal; recurse if - * calendar proxies or group memberships are found + * @param principalUrl URL of principal to query (user-provided principal or current-user-principal) + * @param level Current recursion level (limited to 0, 1 or 2): * - * *false* = found home sets don't directly belong to the current-user-principal; don't recurse + * - 0: We assume found home sets belong to the current-user-principal + * - 1 or 2: We assume found home sets don't directly belong to the current-user-principal * * @throws java.io.IOException * @throws HttpException * @throws at.bitfire.dav4jvm.exception.DavException */ - internal fun queryHomeSets(principalUrl: HttpUrl, forPersonalHomeset: Boolean = true) { - val related = mutableSetOf() + internal fun discoverHomesets(principalUrl: HttpUrl, level: Int = 0) { + Logger.log.fine("Discovering homesets of $principalUrl") + val relatedResources = mutableSetOf() // Define homeset class and properties to look for val homeSetClass: Class @@ -300,60 +311,97 @@ class RefreshCollectionsWorker @AssistedInject constructor( when (service.type) { Service.TYPE_CARDDAV -> { homeSetClass = AddressbookHomeSet::class.java - properties = arrayOf(DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) + properties = arrayOf( + DisplayName.NAME, + AddressbookHomeSet.NAME, + GroupMembership.NAME, + ResourceType.NAME + ) } + Service.TYPE_CALDAV -> { homeSetClass = CalendarHomeSet::class.java - properties = arrayOf(DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) + properties = arrayOf( + DisplayName.NAME, + CalendarHomeSet.NAME, + CalendarProxyReadFor.NAME, + CalendarProxyWriteFor.NAME, + GroupMembership.NAME, + ResourceType.NAME + ) } + else -> throw IllegalArgumentException() } - val dav = DavResource(httpClient, principalUrl) + // Query the URL + val principal = DavResource(httpClient, principalUrl) + val personal = level == 0 try { - // Query for the given service with properties - dav.propfind(0, *properties) { davResponse, _ -> - - // Check we got back the right service and save it - davResponse[homeSetClass]?.let { homeSet -> - for (href in homeSet.hrefs) - dav.location.resolve(href)?.let { - val foundUrl = UrlUtils.withTrailingSlash(it) + principal.propfind(0, *properties) { davResponse, _ -> + alreadyQueried += davResponse.href + + // If response holds home sets, save them + davResponse[homeSetClass]?.let { homeSets -> + for (homeSetHref in homeSets.hrefs) + principal.location.resolve(homeSetHref)?.let { homesetUrl -> + val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl) + // Homeset is considered personal if this is the outer recursion call, + // This is because we assume the first call to query the current-user-principal + // Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by + // other principals and still be considered "personal" (belonging to the current-user-principal). db.homeSetDao().insertOrUpdateByUrl( - HomeSet(0, service.id, forPersonalHomeset, foundUrl) + HomeSet(0, service.id, personal, resolvedHomeSetUrl) ) } } - // If personal (outer call of recursion), find/refresh related resources - if (forPersonalHomeset) { - val relatedResourcesTypes = mapOf( - CalendarProxyReadFor::class.java to "read-only proxy for", // calendar-proxy-read-for - CalendarProxyWriteFor::class.java to "read/write proxy for ", // calendar-proxy-read/write-for - GroupMembership::class.java to "member of group") // direct group memberships - - for ((type, logString) in relatedResourcesTypes) { + // Add related principals to be queried afterwards + if (personal) { + val relatedResourcesTypes = listOf( + // current resource is a read/write-proxy for other principals + CalendarProxyReadFor::class.java, + CalendarProxyWriteFor::class.java, + // current resource is a member of a group (principal that can also have proxies) + GroupMembership::class.java + ) + for (type in relatedResourcesTypes) davResponse[type]?.let { - for (href in it.hrefs) { - Logger.log.fine("Principal is a $logString for $href, checking for home sets") - dav.location.resolve(href)?.let { url -> - related += url + for (href in it.hrefs) + principal.location.resolve(href)?.let { url -> + relatedResources += url } - } } - } + } + + // If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too. + davResponse[ResourceType::class.java]?.let { resourceType -> + val proxyProperties = arrayOf( + ResourceType.CALENDAR_PROXY_READ, + ResourceType.CALENDAR_PROXY_WRITE, + ) + if (proxyProperties.any { resourceType.types.contains(it) }) + relatedResources += davResponse.href.parent() } } } catch (e: HttpException) { - if (e.code/100 == 4) - Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e) + if (e.code / 100 == 4) + Logger.log.log( + Level.INFO, + "Ignoring Client Error 4xx while looking for ${service.type} home sets", + e + ) else throw e } - // query related homesets (those that do not belong to the current-user-principal) - for (resource in related) - queryHomeSets(resource, false) + // query related resources + if (level <= 1) + for (resource in relatedResources) + if (alreadyQueried.contains(resource)) + Logger.log.warning("$resource already queried, skipping") + else + discoverHomesets(resource, level + 1) } /** @@ -366,8 +414,9 @@ class RefreshCollectionsWorker @AssistedInject constructor( * and a null value for it's homeset. Refreshing of collections without homesets is then handled by [refreshHomelessCollections]. */ internal fun refreshHomesetsAndTheirCollections() { - val homesets = db.homeSetDao().getByService(service.id).associateBy { it.url }.toMutableMap() - for((homeSetUrl, localHomeset) in homesets) { + val homesets = + db.homeSetDao().getByService(service.id).associateBy { it.url }.toMutableMap() + for ((homeSetUrl, localHomeset) in homesets) { Logger.log.fine("Listing home set $homeSetUrl") // To find removed collections in this homeset: create a queue from existing collections and remove every collection that @@ -378,15 +427,20 @@ class RefreshCollectionsWorker @AssistedInject constructor( .toMutableMap() try { - DavResource(httpClient, homeSetUrl).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation -> + DavResource(httpClient, homeSetUrl).propfind( + 1, + *DAV_COLLECTION_PROPERTIES + ) { response, relation -> // Note: This callback may be called multiple times ([MultiResponseCallback]) if (!response.isSuccess()) return@propfind if (relation == Response.HrefRelation.SELF) { // this response is about the homeset itself - localHomeset.displayName = response[DisplayName::class.java]?.displayName - localHomeset.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true + localHomeset.displayName = + response[DisplayName::class.java]?.displayName + localHomeset.privBind = + response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true db.homeSetDao().insertOrUpdateByUrl(localHomeset) } @@ -436,10 +490,15 @@ class RefreshCollectionsWorker @AssistedInject constructor( * It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them. */ internal fun refreshHomelessCollections() { - val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap() + val homelessCollections = + db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url } + .toMutableMap() - for((url, localCollection) in homelessCollections) try { - DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ -> + for ((url, localCollection) in homelessCollections) try { + DavResource(httpClient, url).propfind( + 0, + *DAV_COLLECTION_PROPERTIES + ) { response, _ -> if (!response.isSuccess()) { db.collectionDao().delete(localCollection) return@propfind @@ -449,14 +508,16 @@ class RefreshCollectionsWorker @AssistedInject constructor( Collection.fromDavResponse(response)?.let { collection -> if (!isUsableCollection(collection)) return@let - collection.serviceId = localCollection.serviceId // use same service ID as previous entry + collection.serviceId = + localCollection.serviceId // use same service ID as previous entry // .. and save the principal url (collection owner) response[Owner::class.java]?.href ?.let { response.href.resolve(it) } ?.let { principalUrl -> val principal = Principal.fromServiceAndUrl(service, principalUrl) - val principalId = db.principalDao().insertOrUpdate(service.id, principal) + val principalId = + db.principalDao().insertOrUpdate(service.id, principal) collection.ownerId = principalId } @@ -484,7 +545,10 @@ class RefreshCollectionsWorker @AssistedInject constructor( val principalUrl = oldPrincipal.url Logger.log.fine("Querying principal $principalUrl") try { - DavResource(httpClient, principalUrl).propfind(0, *DAV_PRINCIPAL_PROPERTIES) { response, _ -> + DavResource(httpClient, principalUrl).propfind( + 0, + *DAV_PRINCIPAL_PROPERTIES + ) { response, _ -> if (!response.isSuccess()) return@propfind Principal.fromDavResponse(service.id, response)?.let { principal -> @@ -498,7 +562,7 @@ class RefreshCollectionsWorker @AssistedInject constructor( } // Delete principals which don't own any collections - db.principalDao().getAllWithoutCollections().forEach {principal -> + db.principalDao().getAllWithoutCollections().forEach { principal -> db.principalDao().delete(principal) } } @@ -510,7 +574,10 @@ class RefreshCollectionsWorker @AssistedInject constructor( */ private fun isUsableCollection(collection: Collection) = (service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) || - (service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) || + (service.type == Service.TYPE_CALDAV && arrayOf( + Collection.TYPE_CALENDAR, + Collection.TYPE_WEBCAL + ).contains(collection.type)) || (collection.type == Collection.TYPE_WEBCAL && collection.source != null) /** @@ -549,7 +616,7 @@ class RefreshCollectionsWorker @AssistedInject constructor( .filter { homeset -> homeset.personal } .map { homeset -> homeset.id } .contains(collection.homeSetId) - && !excluded + && !excluded else -> // don't preselect false diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt index 0bec6539b889cebf69ced31339bedf2be3cf8dfa..6e2bed802bda17bb796fd946b4901716d65f0289 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt @@ -25,7 +25,6 @@ import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.resource.LocalTask import at.bitfire.davdroid.resource.TaskUtils import at.bitfire.davdroid.syncadapter.SyncUtils -import at.bitfire.davdroid.util.closeCompat import at.bitfire.davdroid.util.setAndVerifyUserData import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.AndroidEvent @@ -218,7 +217,7 @@ class AccountSettingsMigrations( } } } finally { - provider.closeCompat() + provider.close() } } } @@ -258,7 +257,7 @@ class AccountSettingsMigrations( provider.update( CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account), AndroidCalendar.calendarBaseValues, null, null) - provider.closeCompat() + provider.close() } } @@ -315,7 +314,7 @@ class AccountSettingsMigrations( try { AndroidCalendar.insertColors(provider, account) } finally { - provider.closeCompat() + provider.close() } } @@ -380,7 +379,7 @@ class AccountSettingsMigrations( throw ContactsStorageException("Couldn't migrate contacts to new address book", e) } finally { parcel.recycle() - provider.closeCompat() + provider.close() } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AddressBookSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AddressBookSyncer.kt index c099cb55464d426de44d5852b6539de0b3e7f94d..508425c4982819e65af0d91d8545823129667ec2 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AddressBookSyncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AddressBookSyncer.kt @@ -19,7 +19,6 @@ import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.settings.Settings import at.bitfire.davdroid.settings.SettingsManager -import at.bitfire.davdroid.util.closeCompat import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -115,7 +114,7 @@ class AddressBookSyncer(context: Context): Syncer(context) { LocalAddressBook.create(context, db, contactsProvider, account, info, forceAllReadOnly) } } finally { - contactsProvider?.closeCompat() + contactsProvider?.close() } return true diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt index 2f96ff59ac6b8ef4d550334329c80e53d75913a6..33982fd447e3e29e816f49c68be968c8aa8ad3f6 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt @@ -111,7 +111,7 @@ class ContactsSyncManager( override fun prepare(): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { // workaround for Android 7 which sets DIRTY flag when only meta-data is changed val reallyDirty = localCollection.verifyDirty() val deleted = localCollection.findDeleted().size @@ -379,7 +379,7 @@ class ContactsSyncManager( syncResult.stats.numInserts++ } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) // workaround for Android 7 which sets DIRTY flag when only meta-data is changed (local as? LocalContact)?.updateHashCode(null) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/PeriodicSyncWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/PeriodicSyncWorker.kt index 8dd086be99f93dec23e0a3233775c10a2fca921d..93c8f215780a5f92103318f1923f79a6ef73e025 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/PeriodicSyncWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/PeriodicSyncWorker.kt @@ -9,7 +9,15 @@ import android.accounts.AccountManager import android.content.Context import android.provider.CalendarContract import androidx.hilt.work.HiltWorker -import androidx.work.* +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.Operation +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.settings.AccountSettings import dagger.assisted.Assisted @@ -102,23 +110,6 @@ class PeriodicSyncWorker @AssistedInject constructor( WorkManager.getInstance(context) .cancelUniqueWork(workerName(account, authority)) - /** - * Finds out whether the [PeriodicSyncWorker] is currently enqueued or running - * - * @param account account to check - * @param authority authority to check (for instance: [CalendarContract.AUTHORITY]]) - * @return boolean whether the [PeriodicSyncWorker] is running or enqueued - */ - fun isEnabled(context: Context, account: Account, authority: String): Boolean = - WorkManager.getInstance(context) - .getWorkInfos( - WorkQuery.Builder - .fromTags(listOf(workerName(account, authority))) - .addStates(listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING)) - .build() - ).get() - .isNotEmpty() - } override fun doWork(): Result { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncUtils.kt index 31e6a1acbf6f9a8d432022d0f76bf0289c4dddef..dc16a7db7c2ac1df0987f9c02e7cf8bd7a66a48c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncUtils.kt @@ -13,8 +13,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.BitmapDrawable import android.net.Uri -import android.os.Build -import android.os.Bundle import android.provider.CalendarContract import android.provider.ContactsContract import androidx.annotation.WorkerThread @@ -58,7 +56,8 @@ object SyncUtils { */ fun notifyProviderTooOld(context: Context, e: TaskProvider.ProviderTooOldException) { val nm = NotificationManagerCompat.from(context) - val message = context.getString(R.string.sync_error_tasks_required_version, e.provider.minVersionName) + val message = + context.getString(R.string.sync_error_tasks_required_version, e.provider.minVersionName) val pm = context.packageManager val tasksAppInfo = pm.getPackageInfo(e.provider.packageName, 0) @@ -79,11 +78,9 @@ object SyncUtils { // couldn't get provider app icon } - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}")) - - var flags = PendingIntent.FLAG_UPDATE_CURRENT - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - flags = flags or PendingIntent.FLAG_IMMUTABLE + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}")) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE if (intent.resolveActivity(pm) != null) notify.setContentIntent(PendingIntent.getActivity(context, 0, intent, flags)) @@ -131,12 +128,17 @@ object SyncUtils { } // check all accounts and (de)activate task provider(s) if a CalDAV service is defined - val db = EntryPointAccessors.fromApplication(context, SyncUtilsEntryPoint::class.java).appDatabase() + val db = EntryPointAccessors.fromApplication(context, SyncUtilsEntryPoint::class.java) + .appDatabase() val accountManager = AccountManager.get(context) for (account in accountManager.accounts) { - val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null + val hasCalDAV = + db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null for (providerName in TaskProvider.ProviderName.values()) { - val isSyncable = ContentResolver.getIsSyncable(account, providerName.authority) // may be -1 (unknown state) + val isSyncable = ContentResolver.getIsSyncable( + account, + providerName.authority + ) // may be -1 (unknown state) val shallBeSyncable = hasCalDAV && providerName == currentProvider if ((shallBeSyncable && isSyncable != 1) || (!shallBeSyncable && isSyncable != 0)) { try { @@ -153,7 +155,11 @@ object SyncUtils { } // if sync has just been enabled: check whether additional permissions are required - if (shallBeSyncable && !PermissionUtils.havePermissions(context, providerName.permissions)) + if (shallBeSyncable && !PermissionUtils.havePermissions( + context, + providerName.permissions + ) + ) permissionsRequired = true } } @@ -165,14 +171,25 @@ object SyncUtils { } } - private fun setSyncableFromSettings(context: Context, account: Account, authority: String, syncable: Boolean) { - val settingsManager by lazy { EntryPointAccessors.fromApplication(context, SyncUtilsEntryPoint::class.java).settingsManager() } + private fun setSyncableFromSettings( + context: Context, + account: Account, + authority: String, + syncable: Boolean + ) { + val settingsManager by lazy { + EntryPointAccessors.fromApplication( + context, + SyncUtilsEntryPoint::class.java + ).settingsManager() + } if (syncable) { Logger.log.info("Enabling $authority sync for $account") ContentResolver.setIsSyncable(account, authority, 1) try { val settings = AccountSettings(context, account) - val interval = settings.getTasksSyncInterval() ?: Constants.DEFAULT_CALENDAR_SYNC_INTERVAL + val interval = + settings.getTasksSyncInterval() ?: Constants.DEFAULT_CALENDAR_SYNC_INTERVAL settings.setSyncInterval(authority, interval) } catch (e: InvalidAccountException) { // account has already been removed diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncWorker.kt index 046932ae20ba7d80f3e3acc47b5475b83291a0bf..6e62c8c7d3c10f78fdde43eb44131d8efe2032b8 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncWorker.kt @@ -5,11 +5,7 @@ package at.bitfire.davdroid.syncadapter import android.accounts.Account -import android.content.ContentProviderClient -import android.content.ContentResolver -import android.content.Context -import android.content.Intent -import android.content.SyncResult +import android.content.* import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.os.Build @@ -23,20 +19,7 @@ import androidx.core.content.getSystemService import androidx.hilt.work.HiltWorker import androidx.lifecycle.LiveData import androidx.lifecycle.map -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.Data -import androidx.work.ExistingWorkPolicy -import androidx.work.ForegroundInfo -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.WorkQuery -import androidx.work.WorkRequest -import androidx.work.Worker -import androidx.work.WorkerParameters +import androidx.work.* import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.network.ConnectionUtils.internetAvailable @@ -47,7 +30,6 @@ import at.bitfire.davdroid.ui.NotificationUtils import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible import at.bitfire.davdroid.ui.account.WifiPermissionsActivity import at.bitfire.davdroid.util.PermissionUtils -import at.bitfire.davdroid.util.closeCompat import at.bitfire.ical4android.TaskProvider import com.google.common.util.concurrent.ListenableFuture import dagger.assisted.Assisted @@ -312,7 +294,7 @@ class SyncWorker @AssistedInject constructor( // Check internet connection val ignoreVpns = AccountSettings(applicationContext, account).getIgnoreVpns() val connectivityManager = applicationContext.getSystemService()!! - if (Build.VERSION.SDK_INT >= 23 && !internetAvailable(connectivityManager, ignoreVpns)) { + if (!internetAvailable(connectivityManager, ignoreVpns)) { Logger.log.info("WorkManager started SyncWorker without Internet connection. Aborting.") return Result.failure() } @@ -383,7 +365,7 @@ class SyncWorker @AssistedInject constructor( } catch (e: SecurityException) { Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority") } finally { - provider.closeCompat() + provider.close() } // Check for errors @@ -448,7 +430,7 @@ class SyncWorker @AssistedInject constructor( } override fun onStopped() { - Logger.log.info("Stopping sync thread") + Logger.log.info("Work stopped (reason ${if (Build.VERSION.SDK_INT >= 31) stopReason else "n/a"}), stopping sync thread") syncThread?.interrupt() } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt index 17f77e07dd4825f8c332a03e912550ee43adaac9..6692b125c2cbe2309f64e44bcd005f29553acb0f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AboutActivity.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.core.text.HtmlCompat +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter @@ -81,14 +82,24 @@ class AboutActivity: AppCompatActivity() { binding.viewpager.adapter = TabsAdapter(supportFragmentManager) binding.tabs.setupWithViewPager(binding.viewpager, false) - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_about, menu) - return true + addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_about, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem) = + when (menuItem.itemId) { + R.id.show_website -> { + showWebsite() + true + } + else -> false + } + }) } - fun showWebsite(item: MenuItem) { + fun showWebsite() { UiUtils.launchUri(this, App.homepageUrl(this)) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountListFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountListFragment.kt index 5a44f96a30700cc2578dc53615d023ec415750ff..a376cd4306e41bdd95a6afe0950b61cbf2cfe974 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountListFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountListFragment.kt @@ -15,16 +15,17 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.os.Bundle import android.provider.Settings 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.annotation.AnyThread import androidx.core.content.ContextCompat +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel @@ -64,8 +65,6 @@ class AccountListFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - setHasOptionsMenu(true) - _binding = AccountListBinding.inflate(inflater, container, false) return binding.root } @@ -113,18 +112,15 @@ class AccountListFragment : Fragment() { } model.dataSaverOn.observe(viewLifecycleOwner) { datasaverOn -> - binding.datasaverOnInfo.visibility = - if (Build.VERSION.SDK_INT >= 24 && datasaverOn) View.VISIBLE else View.GONE + binding.datasaverOnInfo.visibility = if (datasaverOn) View.VISIBLE else View.GONE } binding.manageDatasaver.setOnClickListener { - if (Build.VERSION.SDK_INT >= 24) { - val intent = Intent( - Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, - Uri.parse("package:" + requireActivity().packageName) - ) - if (intent.resolveActivity(requireActivity().packageManager) != null) - startActivity(intent) - } + val intent = Intent( + Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, + Uri.parse("package:" + requireActivity().packageName) + ) + if (intent.resolveActivity(requireActivity().packageManager) != null) + startActivity(intent) } // Accounts adapter @@ -144,16 +140,29 @@ class AccountListFragment : Fragment() { accountAdapter.submitList(accounts) requireActivity().invalidateOptionsMenu() } - } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = - inflater.inflate(R.menu.activity_accounts, menu) + requireActivity().addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_accounts, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem) = + when (menuItem.itemId) { + R.id.syncAll -> { + (activity as AccountsActivity).syncAllAccounts() + true + } - override fun onPrepareOptionsMenu(menu: Menu) { - // Show "Sync all" only when there is at least one account - model.accounts.value?.let { accounts -> - menu.findItem(R.id.syncAll).setVisible(accounts.isNotEmpty()) - } + else -> false + } + + override fun onPrepareMenu(menu: Menu) { + // Show "Sync all" only when there is at least one account + model.accounts.value?.let { accounts -> + menu.findItem(R.id.syncAll).setVisible(accounts.isNotEmpty()) + } + } + }) } override fun onResume() { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt index e50d1a5613948dece35430a05933792c62df2829..a3d6e1b7391685cd3017200bd27ab193027fbceb 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsActivity.kt @@ -4,11 +4,11 @@ package at.bitfire.davdroid.ui -import android.app.Activity import android.app.Application import android.content.Intent import android.os.Bundle import android.view.MenuItem +import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity @@ -28,18 +28,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @AndroidEntryPoint -class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { +class AccountsActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { - companion object { - const val REQUEST_INTRO = 0 - } - - @Inject lateinit var accountsDrawerHandler: AccountsDrawerHandler + @Inject + lateinit var accountsDrawerHandler: AccountsDrawerHandler private lateinit var binding: ActivityAccountsBinding val model by viewModels() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -55,13 +51,26 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele setSupportActionBar(binding.content.toolbar) val toggle = ActionBarDrawerToggle( - this, binding.drawerLayout, binding.content.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close) + this, + binding.drawerLayout, + binding.content.toolbar, + R.string.navigation_drawer_open, + R.string.navigation_drawer_close + ) binding.drawerLayout.addDrawerListener(toggle) toggle.syncState() binding.navView.setNavigationItemSelectedListener(this) binding.navView.itemIconTintList = null + onBackPressedDispatcher.addCallback(this) { + if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { + binding.drawerLayout.closeDrawer(GravityCompat.START) + } else { + finish() + } + } + // handle "Sync all" intent from launcher shortcut if (savedInstanceState == null && intent.action == Intent.ACTION_SYNC) syncAllAccounts() @@ -72,21 +81,6 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele accountsDrawerHandler.initMenu(this, binding.navView.menu) } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == REQUEST_INTRO && resultCode == Activity.RESULT_CANCELED) - finish() - else - super.onActivityResult(requestCode, resultCode, data) - } - - override fun onBackPressed() { - if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) - binding.drawerLayout.closeDrawer(GravityCompat.START) - else - super.onBackPressed() - } - override fun onNavigationItemSelected(item: MenuItem): Boolean { accountsDrawerHandler.onNavigationItemSelected(this, item) binding.drawerLayout.closeDrawer(GravityCompat.START) @@ -95,11 +89,15 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele private fun allAccounts() = AccountUtils.getMainAccounts(this) - fun syncAllAccounts(item: MenuItem? = null) { + fun syncAllAccounts() { // Notify user that sync will get enqueued if we're not connected to the internet model.networkAvailable.value?.let { networkAvailable -> if (!networkAvailable) - Snackbar.make(binding.drawerLayout, R.string.no_internet_sync_scheduled, Snackbar.LENGTH_LONG).show() + Snackbar.make( + binding.drawerLayout, + R.string.no_internet_sync_scheduled, + Snackbar.LENGTH_LONG + ).show() } // Enqueue sync worker for all accounts and authorities. Will sync once internet is available @@ -114,7 +112,7 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele application: Application, val settings: SettingsManager, warnings: AppWarningsManager - ): AndroidViewModel(application) { + ) : AndroidViewModel(application) { val networkAvailable = warnings.networkAvailable diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt index 9ed784ff9f49b5cef5525adf9879b3e5d182b780..b6b976017bf4b5effd5c713e8bb9199a6f813fa4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsActivity.kt @@ -15,6 +15,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.UiThread import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.getSystemService import androidx.preference.* import at.bitfire.cert4android.CustomCertStore import at.bitfire.davdroid.BuildConfig @@ -148,21 +149,18 @@ class AppSettingsActivity: AppCompatActivity() { // debug settings findPreference(Settings.BATTERY_OPTIMIZATION)!!.apply { // battery optimization exists since Android 6 (API level 23) - if (Build.VERSION.SDK_INT >= 23) { - val powerManager = requireActivity().getSystemService(PowerManager::class.java) - val whitelisted = powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) - isChecked = whitelisted - isEnabled = !whitelisted - onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, nowChecked -> - if (nowChecked as Boolean) - onBatteryOptimizationResult.launch(Intent( - android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - Uri.parse("package:" + BuildConfig.APPLICATION_ID) - )) - false - } - } else - isVisible = false + val powerManager = requireActivity().getSystemService()!! + val whitelisted = powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) + isChecked = whitelisted + isEnabled = !whitelisted + onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, nowChecked -> + if (nowChecked as Boolean) + onBatteryOptimizationResult.launch(Intent( + android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + BuildConfig.APPLICATION_ID) + )) + false + } } findPreference(Settings.FOREGROUND_SERVICE)!!.apply { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppWarningsManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppWarningsManager.kt index f09a1bf861e4080cf948eba97e98b9de785d23e4..fdbf9214ed41bdeca7c0a08ebf0ed241fe03c539 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppWarningsManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppWarningsManager.kt @@ -4,7 +4,12 @@ package at.bitfire.davdroid.ui -import android.content.* +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SyncStatusObserver import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities @@ -62,17 +67,15 @@ class AppWarningsManager @Inject constructor( watchConnectivity() // Data saver - if (Build.VERSION.SDK_INT >= 24) { - val listener = object: BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - checkDataSaver() - } + val listener = object: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + checkDataSaver() } - - val dataSaverChangedFilter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED) - context.registerReceiver(listener, dataSaverChangedFilter) - dataSaverChangedListener = listener } + + val dataSaverChangedFilter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED) + context.registerReceiver(listener, dataSaverChangedFilter) + dataSaverChangedListener = listener checkDataSaver() } @@ -81,61 +84,39 @@ class AppWarningsManager @Inject constructor( } private fun watchConnectivity() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { // API level <26 - networkReceiver = object: BroadcastReceiver() { - init { - update() - } - - override fun onReceive(context: Context?, intent: Intent?) = update() + networkAvailable.postValue(false) + + // check for working (e.g. WiFi after captive portal login) Internet connection + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + val callback = object: ConnectivityManager.NetworkCallback() { + val availableNetworks = hashSetOf() + + override fun onAvailable(network: Network) { + availableNetworks += network + update() + } - private fun update() { - networkAvailable.postValue(connectivityManager.allNetworkInfo.any { it.isConnected }) - } + override fun onLost(network: Network) { + availableNetworks -= network + update() } - @Suppress("DEPRECATION") - context.registerReceiver(networkReceiver, - IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) - ) - - } else { // API level >= 26 - networkAvailable.postValue(false) - - // check for working (e.g. WiFi after captive portal login) Internet connection - val networkRequest = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - .build() - val callback = object: ConnectivityManager.NetworkCallback() { - val availableNetworks = hashSetOf() - - override fun onAvailable(network: Network) { - availableNetworks += network - update() - } - - override fun onLost(network: Network) { - availableNetworks -= network - update() - } - - private fun update() { - networkAvailable.postValue(availableNetworks.isNotEmpty()) - } + + private fun update() { + networkAvailable.postValue(availableNetworks.isNotEmpty()) } - connectivityManager.registerNetworkCallback(networkRequest, callback) - networkCallback = callback } + connectivityManager.registerNetworkCallback(networkRequest, callback) + networkCallback = callback } private fun checkDataSaver() { dataSaverEnabled.postValue( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - context.getSystemService()?.let { connectivityManager -> - connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED - } - else - false + context.getSystemService()?.let { connectivityManager -> + connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED + } ) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt index 27038353a3a6083bcd0ea8bf1fd875d92b73a456..70e1d10350ea3296362c21569975eab9321229ed 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt @@ -16,6 +16,7 @@ import android.net.Uri import android.os.* import android.provider.CalendarContract import android.provider.ContactsContract +import android.text.format.DateUtils import android.view.View import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -44,7 +45,6 @@ import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker import at.bitfire.davdroid.syncadapter.SyncWorker -import at.bitfire.davdroid.util.closeCompat import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider.ProviderName import at.techbee.jtx.JtxContract @@ -63,7 +63,6 @@ import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.exception.ExceptionUtils import org.dmfs.tasks.contract.TaskContract import java.io.* -import java.util.Locale import java.util.TimeZone import java.util.logging.Level import java.util.zip.ZipEntry @@ -376,10 +375,7 @@ class DebugInfoActivity : AppCompatActivity() { } // system info - val locales: Any = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - LocaleList.getAdjustedDefault() - else - Locale.getDefault() + val locales: Any = LocaleList.getAdjustedDefault() writer.append( "\nSYSTEM INFORMATION\n\n" + "Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" + @@ -398,7 +394,7 @@ class DebugInfoActivity : AppCompatActivity() { // connectivity context.getSystemService()?.let { connectivityManager -> writer.append("\nCONNECTIVITY\n\n") - val activeNetwork = if (Build.VERSION.SDK_INT >= 23) connectivityManager.activeNetwork else null + val activeNetwork = connectivityManager.activeNetwork connectivityManager.allNetworks.sortedByDescending { it == activeNetwork }.forEach { network -> val properties = connectivityManager.getLinkProperties(network) connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> @@ -418,19 +414,17 @@ class DebugInfoActivity : AppCompatActivity() { } writer.append('\n') - if (Build.VERSION.SDK_INT >= 23) - connectivityManager.defaultProxy?.let { proxy -> - writer.append("System default proxy: ${proxy.host}:${proxy.port}\n") + connectivityManager.defaultProxy?.let { proxy -> + writer.append("System default proxy: ${proxy.host}:${proxy.port}\n") + } + writer.append("Data saver: ").append( + when (connectivityManager.restrictBackgroundStatus) { + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> "enabled" + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED -> "whitelisted" + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> "disabled" + else -> connectivityManager.restrictBackgroundStatus.toString() } - if (Build.VERSION.SDK_INT >= 24) - writer.append("Data saver: ").append( - when (connectivityManager.restrictBackgroundStatus) { - ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> "enabled" - ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED -> "whitelisted" - ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> "disabled" - else -> connectivityManager.restrictBackgroundStatus.toString() - } - ).append('\n') + ).append('\n') writer.append('\n') } @@ -444,12 +438,11 @@ class DebugInfoActivity : AppCompatActivity() { writer.append(" (RESTRICTED!)") writer.append('\n') } - if (Build.VERSION.SDK_INT >= 23) - context.getSystemService()?.let { powerManager -> - writer.append("Power saving disabled: ") - .append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no") - .append('\n') - } + context.getSystemService()?.let { powerManager -> + writer.append("Power saving disabled: ") + .append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no") + .append('\n') + } // system-wide sync writer.append("System-wide synchronization: ") .append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually") @@ -638,7 +631,7 @@ class DebugInfoActivity : AppCompatActivity() { } private fun dumpAccount(account: Account, infos: Iterable): String { - val table = TextTable("Authority", "getIsSyncable", "getSyncAutomatically", "PeriodicSyncWorker", "Interval", "Entries") + val table = TextTable("Authority", "isSyncable", "syncAutomatically", "Interval", "Entries") for (info in infos) { var nrEntries = "—" var client: ContentProviderClient? = null @@ -654,14 +647,13 @@ class DebugInfoActivity : AppCompatActivity() { } catch (e: Exception) { nrEntries = e.toString() } finally { - client?.closeCompat() + client?.close() } val accountSettings = AccountSettings(context, account) table.addLine( info.authority, ContentResolver.getIsSyncable(account, info.authority), ContentResolver.getSyncAutomatically(account, info.authority), // content-triggered sync - PeriodicSyncWorker.isEnabled(context, account, info.authority), // should always be false for address book accounts accountSettings.getSyncInterval(info.authority)?.let {"${it/60} min"}, nrEntries ) @@ -675,7 +667,7 @@ class DebugInfoActivity : AppCompatActivity() { * whether they exist one by one */ private fun dumpSyncWorkersInfo(account: Account): String { - val table = TextTable("Tags", "Authority", "State", "Retries", "Generation", "ID") + val table = TextTable("Tags", "Authority", "State", "Next run", "Retries", "Generation", "Periodicity") listOf( context.getString(R.string.address_books_authority), CalendarContract.AUTHORITY, @@ -691,12 +683,20 @@ class DebugInfoActivity : AppCompatActivity() { WorkQuery.Builder.fromUniqueWorkNames(listOf(workerName)).build() ).get().forEach { workInfo -> table.addLine( - workInfo.tags.map { StringUtils.removeStartIgnoreCase(it, SyncWorker::class.java.getPackage()!!.name + ".") }, + workInfo.tags.map { it.replace("\\bat\\.bitfire\\.davdroid\\.".toRegex(), ".") }, authority, - workInfo.state, + "${workInfo.state} (${workInfo.stopReason})", + workInfo.nextScheduleTimeMillis.let { nextRun -> + when (nextRun) { + Long.MAX_VALUE -> "—" + else -> DateUtils.getRelativeTimeSpanString(nextRun) + } + }, workInfo.runAttemptCount, workInfo.generation, - workInfo.id + workInfo.periodicityInfo?.let { periodicity -> + "every ${periodicity.repeatIntervalMillis/60000} min" + } ?: "not periodic" ) } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/NotificationUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/NotificationUtils.kt index 8f30e663632e0ce69080718141ee99573bf61f00..e41544d538bb2a6f08bda820781ad4d7361c0c77 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/NotificationUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/NotificationUtils.kt @@ -15,7 +15,6 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService import androidx.core.content.res.ResourcesCompat -import at.bitfire.davdroid.App import at.bitfire.davdroid.R import at.bitfire.davdroid.log.Logger import java.util.logging.Level @@ -77,15 +76,9 @@ object NotificationUtils { } } - fun newBuilder(context: Context, channel: String): NotificationCompat.Builder { - val builder = NotificationCompat.Builder(context, channel) - .setColor(ResourcesCompat.getColor(context.resources, R.color.primaryColor, null)) - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) - builder.setLargeIcon(App.getLauncherBitmap(context)) - - return builder - } + fun newBuilder(context: Context, channel: String): NotificationCompat.Builder = + NotificationCompat.Builder(context, channel) + .setColor(ResourcesCompat.getColor(context.resources, R.color.primaryColor, null)) fun NotificationManagerCompat.notifyIfPossible(tag: String?, id: Int, notification: Notification) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksFragment.kt index 8e72429743c8c5d9863d46b2c356519dfaac83a7..04dc578c6e6504577b957dcabed1002d5160b8f1 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/TasksFragment.kt @@ -4,6 +4,7 @@ package at.bitfire.davdroid.ui +import android.app.Application import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -16,8 +17,8 @@ import androidx.annotation.AnyThread import androidx.databinding.ObservableBoolean import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.PackageChangedReceiver import at.bitfire.davdroid.R @@ -28,7 +29,6 @@ import at.bitfire.ical4android.TaskProvider.ProviderName import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @AndroidEntryPoint @@ -102,9 +102,9 @@ class TasksFragment: Fragment() { @HiltViewModel class Model @Inject constructor( - @ApplicationContext val context: Context, + application: Application, val settings: SettingsManager - ) : ViewModel(), SettingsManager.OnChangeListener { + ) : AndroidViewModel(application), SettingsManager.OnChangeListener { companion object { @@ -117,6 +117,8 @@ class TasksFragment: Fragment() { } + val context: Context get() = getApplication() + val currentProvider = MutableLiveData() val openTasksInstalled = MutableLiveData() val openTasksRequested = MutableLiveData() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt index 1ce2d987762c1ccbb3a1ca34d6d8ca79f77040b3..0b7dc6f7e143d01192fcf467228327126ac4fcc1 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt @@ -9,15 +9,16 @@ import android.accounts.AccountManager import android.accounts.OnAccountsUpdateListener import android.app.Application import android.content.Intent -import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.TooltipCompat +import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentActivity import androidx.lifecycle.* import androidx.viewpager2.adapter.FragmentStateAdapter @@ -37,27 +38,25 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch - +import kotlinx.coroutines.* import java.util.logging.Level import javax.inject.Inject @AndroidEntryPoint -class AccountActivity: AppCompatActivity() { +class AccountActivity : AppCompatActivity() { companion object { const val EXTRA_ACCOUNT = "account" } - @Inject lateinit var modelFactory: Model.Factory + @Inject + lateinit var modelFactory: Model.Factory val model by viewModels { val account = intent.getParcelableExtra(EXTRA_ACCOUNT) as? Account - ?: throw IllegalArgumentException("AccountActivity requires EXTRA_ACCOUNT") - object: ViewModelProvider.Factory { + ?: throw IllegalArgumentException("AccountActivity requires EXTRA_ACCOUNT") + object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class) = + override fun create(modelClass: Class) = modelFactory.create(account) as T } } @@ -107,62 +106,63 @@ class AccountActivity: AppCompatActivity() { SyncWorker.enqueueAllAuthorities(this, model.account) } } - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_account, menu) - return true + addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_account, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem) = + when (menuItem.itemId) { + R.id.settings -> { + openAccountSettings() + true + } + + R.id.rename_account -> { + renameAccount() + true + } + + R.id.delete_account -> { + deleteAccountDialog() + true + } + + else -> false + } + }) } // menu actions - fun openAccountSettings(menuItem: MenuItem) { + fun openAccountSettings() { val intent = Intent(this, SettingsActivity::class.java) intent.putExtra(SettingsActivity.EXTRA_ACCOUNT, model.account) startActivity(intent, null) } - fun renameAccount(menuItem: MenuItem) { + fun renameAccount() { RenameAccountFragment.newInstance(model.account).show(supportFragmentManager, null) } - fun deleteAccount(menuItem: MenuItem) { + fun deleteAccountDialog() { MaterialAlertDialogBuilder(this, R.style.CustomAlertDialogStyle) - .setIcon(R.drawable.ic_error) - .setTitle(R.string.account_delete_confirmation_title) - .setMessage(R.string.account_delete_confirmation_text) - .setNegativeButton(android.R.string.no, null) - .setPositiveButton(android.R.string.yes) { _, _ -> - deleteAccount() - } - .show() + .setIcon(R.drawable.ic_error) + .setTitle(R.string.account_delete_confirmation_title) + .setMessage(R.string.account_delete_confirmation_text) + .setNegativeButton(android.R.string.no, null) + .setPositiveButton(android.R.string.yes) { _, _ -> + deleteAccount() + } + .show() } private fun deleteAccount() { val accountManager = AccountManager.get(this) val email = accountManager.getUserData(model.account, AccountSettings.KEY_EMAIL_ADDRESS) - if (Build.VERSION.SDK_INT >= 22) - removeAccount(accountManager, email) - else - removeAccountForOlderSdk(accountManager, email) - } - - private fun removeAccountForOlderSdk(accountManager: AccountManager, email: String?) { - accountManager.removeAccount(model.account, { future -> - try { - if (future.result) - Handler(Looper.getMainLooper()).post { - finish() - } - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't remove account", e) - } - }, null) - } - - private fun removeAccount(accountManager: AccountManager, email: String?) { accountManager.removeAccount(model.account, this, { future -> try { if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)) @@ -204,14 +204,13 @@ class AccountActivity: AppCompatActivity() { } - // adapter class FragmentsAdapter( val activity: FragmentActivity, private val cardDavSvcId: Long?, private val calDavSvcId: Long? - ): FragmentStateAdapter(activity) { + ) : FragmentStateAdapter(activity) { private val idxCardDav: Int? private val idxCalDav: Int? @@ -236,8 +235,8 @@ class AccountActivity: AppCompatActivity() { override fun getItemCount() = (if (idxCardDav != null) 1 else 0) + - (if (idxCalDav != null) 1 else 0) + - (if (idxWebcal != null) 1 else 0) + (if (idxCalDav != null) 1 else 0) + + (if (idxWebcal != null) 1 else 0) override fun createFragment(position: Int) = when (position) { @@ -245,23 +244,35 @@ class AccountActivity: AppCompatActivity() { AddressBooksFragment().apply { arguments = Bundle(2).apply { putLong(CollectionsFragment.EXTRA_SERVICE_ID, cardDavSvcId!!) - putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_ADDRESSBOOK) + putString( + CollectionsFragment.EXTRA_COLLECTION_TYPE, + Collection.TYPE_ADDRESSBOOK + ) } } + idxCalDav -> CalendarsFragment().apply { arguments = Bundle(2).apply { putLong(CollectionsFragment.EXTRA_SERVICE_ID, calDavSvcId!!) - putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_CALENDAR) + putString( + CollectionsFragment.EXTRA_COLLECTION_TYPE, + Collection.TYPE_CALENDAR + ) } } + idxWebcal -> WebcalFragment().apply { arguments = Bundle(2).apply { putLong(CollectionsFragment.EXTRA_SERVICE_ID, calDavSvcId!!) - putString(CollectionsFragment.EXTRA_COLLECTION_TYPE, Collection.TYPE_WEBCAL) + putString( + CollectionsFragment.EXTRA_COLLECTION_TYPE, + Collection.TYPE_WEBCAL + ) } } + else -> throw IllegalArgumentException() } @@ -283,7 +294,7 @@ class AccountActivity: AppCompatActivity() { val db: AppDatabase, @Assisted val account: Account, warnings: AppWarningsManager - ): AndroidViewModel(application), OnAccountsUpdateListener { + ) : AndroidViewModel(application), OnAccountsUpdateListener { @AssistedFactory interface Factory { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt index 2a30b1304006b33ee452e4e1ada28e8afc45d008..67cda108bc7924e44457beaa469f1faeadb372a3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AddressBooksFragment.kt @@ -5,7 +5,10 @@ package at.bitfire.davdroid.ui.account import android.content.Intent +import android.os.Bundle import android.view.* +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentManager import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.davdroid.R @@ -22,26 +25,39 @@ class AddressBooksFragment: CollectionsFragment() { override val noCollectionsStringId = R.string.account_no_address_books - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = - inflater.inflate(R.menu.carddav_actions, menu) + private val menuProvider = object : CollectionsMenuProvider() { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.carddav_actions, menu) + } - override fun onPrepareOptionsMenu(menu: Menu) { - menu.findItem(R.id.create_address_book).isVisible = model.hasWriteableCollections.value ?: false - super.onPrepareOptionsMenu(menu) - } + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.create_address_book).isVisible = model.hasWriteableCollections.value ?: false + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (super.onMenuItemSelected(menuItem)) + return true - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (super.onOptionsItemSelected(item)) - return true + if (menuItem.itemId == R.id.create_address_book) { + val intent = Intent(requireActivity(), CreateAddressBookActivity::class.java) + intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, accountModel.account) + startActivity(intent) + return true + } - if (item.itemId == R.id.create_address_book) { - val intent = Intent(requireActivity(), CreateAddressBookActivity::class.java) - intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, accountModel.account) - startActivity(intent) - return true + return false } + } + + override fun onResume() { + super.onResume() + requireActivity().addMenuProvider(menuProvider) + } - return false + override fun onPause() { + super.onPause() + requireActivity().removeMenuProvider(menuProvider) } override fun checkPermissions() { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CalendarsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CalendarsFragment.kt index cbd7916dcb262e4f8f58310249656e77526f5bbb..9e58b0bf912e24c9bfd39edc6483a0d0f7e7f4bd 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CalendarsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CalendarsFragment.kt @@ -5,7 +5,10 @@ package at.bitfire.davdroid.ui.account import android.content.Intent +import android.os.Bundle import android.view.* +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentManager import at.bitfire.davdroid.Constants import at.bitfire.davdroid.util.PermissionUtils @@ -18,26 +21,40 @@ class CalendarsFragment: CollectionsFragment() { override val noCollectionsStringId = R.string.account_no_calendars - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = - inflater.inflate(R.menu.caldav_actions, menu) + private val menuProvider = object : CollectionsMenuProvider() { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.caldav_actions, menu) + } - override fun onPrepareOptionsMenu(menu: Menu) { - menu.findItem(R.id.create_calendar).isVisible = model.hasWriteableCollections.value ?: false - super.onPrepareOptionsMenu(menu) - } + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.create_calendar).isVisible = model.hasWriteableCollections.value ?: false + } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (super.onOptionsItemSelected(item)) - return true + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (super.onMenuItemSelected(menuItem)) { + return true + } - if (item.itemId == R.id.create_calendar) { - val intent = Intent(requireActivity(), CreateCalendarActivity::class.java) - intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, accountModel.account) - startActivity(intent) - return true + if (menuItem.itemId == R.id.create_calendar) { + val intent = Intent(requireActivity(), CreateCalendarActivity::class.java) + intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, accountModel.account) + startActivity(intent) + return true + } + + return false } + } + + override fun onResume() { + super.onResume() + requireActivity().addMenuProvider(menuProvider) + } - return false + override fun onPause() { + super.onPause() + requireActivity().removeMenuProvider(menuProvider) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsFragment.kt index 0d33eca4f8007a16091184b3524f391f77bcefc1..36e63deae2a2a071bd91a4aa1f1fd6ec7c8ee3e3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsFragment.kt @@ -13,8 +13,8 @@ import android.provider.CalendarContract import android.provider.ContactsContract import android.view.* import android.widget.PopupMenu -import androidx.annotation.AnyThread -import androidx.core.content.ContextCompat +import androidx.annotation.CallSuper +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels @@ -44,7 +44,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint -abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshListener { +abstract class CollectionsFragment : Fragment(), SwipeRefreshLayout.OnRefreshListener { companion object { const val EXTRA_SERVICE_ID = "serviceId" @@ -55,27 +55,28 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList protected val binding get() = _binding!! val accountModel by activityViewModels() - @Inject lateinit var modelFactory: Model.Factory + @Inject + lateinit var modelFactory: Model.Factory protected val model by viewModels { - object: ViewModelProvider.Factory { + object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = + override fun create(modelClass: Class): T = modelFactory.create( accountModel, requireArguments().getLong(EXTRA_SERVICE_ID), - requireArguments().getString(EXTRA_COLLECTION_TYPE) ?: throw IllegalArgumentException("EXTRA_COLLECTION_TYPE required") + requireArguments().getString(EXTRA_COLLECTION_TYPE) + ?: throw IllegalArgumentException("EXTRA_COLLECTION_TYPE required") ) as T } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - } - abstract val noCollectionsStringId: Int - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { _binding = AccountCollectionsBinding.inflate(inflater, container, false) binding.permissionsBtn.setOnClickListener { @@ -88,22 +89,23 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - model.isRefreshing.observe(viewLifecycleOwner, Observer { nowRefreshing -> + model.isRefreshing.observe(viewLifecycleOwner) { nowRefreshing -> binding.swipeRefresh.isRefreshing = nowRefreshing - }) - model.hasWriteableCollections.observe(viewLifecycleOwner, Observer { + } + model.hasWriteableCollections.observe(viewLifecycleOwner) { requireActivity().invalidateOptionsMenu() - }) - model.collectionColors.observe(viewLifecycleOwner, Observer { colors: List -> + } + model.collectionColors.observe(viewLifecycleOwner) { colors: List -> val realColors = colors.filterNotNull() if (realColors.isNotEmpty()) binding.swipeRefresh.setColorSchemeColors(*realColors.toIntArray()) - }) + } binding.swipeRefresh.setOnRefreshListener(this) val updateProgress = Observer { binding.progress.apply { - val isVisible = model.isSyncActive.value == true || model.isSyncPending.value == true + val isVisible = + model.isSyncActive.value == true || model.isSyncPending.value == true if (model.isSyncActive.value == true) { isIndeterminate = true @@ -130,11 +132,11 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList val adapter = createAdapter() binding.list.layoutManager = LinearLayoutManager(requireActivity()) binding.list.adapter = adapter - model.collections.observe(viewLifecycleOwner, Observer { data -> + model.collections.observe(viewLifecycleOwner) { data -> lifecycleScope.launch { adapter.submitData(data) } - }) + } adapter.addLoadStateListener { loadStates -> if (loadStates.refresh is LoadState.NotLoading) { if (adapter.itemCount > 0) { @@ -150,31 +152,6 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList binding.noCollections.setText(noCollectionsStringId) } - override fun onPrepareOptionsMenu(menu: Menu) { - menu.findItem(R.id.showOnlyPersonal).let { showOnlyPersonal -> - accountModel.showOnlyPersonal.value?.let { value -> - showOnlyPersonal.isChecked = value - } - accountModel.showOnlyPersonalWritable.value?.let { writable -> - showOnlyPersonal.isEnabled = writable - } - } - } - - override fun onOptionsItemSelected(item: MenuItem) = - when (item.itemId) { - R.id.refresh -> { - onRefresh() - true - } - R.id.showOnlyPersonal -> { - accountModel.toggleShowOnlyPersonal() - true - } - else -> - false - } - override fun onRefresh() { model.refresh() } @@ -195,25 +172,25 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList protected abstract fun createAdapter(): CollectionAdapter - abstract class CollectionViewHolder( + abstract class CollectionViewHolder( parent: ViewGroup, val binding: T, protected val accountModel: AccountActivity.Model - ): RecyclerView.ViewHolder(binding.root) { + ) : RecyclerView.ViewHolder(binding.root) { abstract fun bindTo(item: Collection) } abstract class CollectionAdapter( protected val accountModel: AccountActivity.Model - ): PagingDataAdapter>(DIFF_CALLBACK) { + ) : PagingDataAdapter>(DIFF_CALLBACK) { companion object { - private val DIFF_CALLBACK = object: DiffUtil.ItemCallback() { + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Collection, newItem: Collection) = - oldItem.id == newItem.id + oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Collection, newItem: Collection) = - oldItem == newItem + oldItem == newItem } } @@ -225,12 +202,46 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList } + abstract inner class CollectionsMenuProvider : MenuProvider { + abstract override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) + + @CallSuper + override fun onPrepareMenu(menu: Menu) { + menu.findItem(R.id.showOnlyPersonal).let { showOnlyPersonal -> + accountModel.showOnlyPersonal.value?.let { value -> + showOnlyPersonal.isChecked = value + } + accountModel.showOnlyPersonalWritable.value?.let { writable -> + showOnlyPersonal.isEnabled = writable + } + } + } + + @CallSuper + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.refresh -> { + onRefresh() + true + } + + R.id.showOnlyPersonal -> { + accountModel.toggleShowOnlyPersonal() + true + } + + else -> + false + } + } + } + class CollectionPopupListener( private val accountModel: AccountActivity.Model, private val item: Collection, private val fragmentManager: FragmentManager, private val forceReadOnly: Boolean = false - ): View.OnClickListener { + ) : View.OnClickListener { override fun onClick(anchor: View) { val popup = PopupMenu(anchor.context, anchor, Gravity.RIGHT) @@ -238,7 +249,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList with(popup.menu.findItem(R.id.force_read_only)) { if (item.type == Collection.TYPE_WEBCAL) - // Webcal collections are always read-only + // Webcal collections are always read-only isVisible = false else { // non-Webcal collection @@ -261,10 +272,13 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList R.id.force_read_only -> { accountModel.toggleReadOnly(item) } + R.id.properties -> CollectionInfoFragment.newInstance(item.id).show(fragmentManager, null) + R.id.delete_collection -> - DeleteCollectionFragment.newInstance(accountModel.account, item.id).show(fragmentManager, null) + DeleteCollectionFragment.newInstance(accountModel.account, item.id) + .show(fragmentManager, null) } true } @@ -280,11 +294,15 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList @Assisted val accountModel: AccountActivity.Model, @Assisted val serviceId: Long, @Assisted val collectionType: String - ): AndroidViewModel(application) { + ) : AndroidViewModel(application) { @AssistedFactory interface Factory { - fun create(accountModel: AccountActivity.Model, serviceId: Long, collectionType: String): Model + fun create( + accountModel: AccountActivity.Model, + serviceId: Long, + collectionType: String + ): Model } // cache task provider @@ -299,10 +317,11 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList pagingSourceFactory = { Logger.log.info("Creating new pager onlyPersonal=$onlyPersonal") if (onlyPersonal) - // show only personal collections - db.collectionDao().pagePersonalByServiceAndType(serviceId, collectionType) + // show only personal collections + db.collectionDao() + .pagePersonalByServiceAndType(serviceId, collectionType) else - // show all collections + // show all collections db.collectionDao().pageByServiceAndType(serviceId, collectionType) } ) @@ -312,22 +331,33 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList } // observe RefreshCollectionsWorker status - val isRefreshing = RefreshCollectionsWorker.isWorkerInState(getApplication(), RefreshCollectionsWorker.workerName(serviceId), WorkInfo.State.RUNNING) + val isRefreshing = RefreshCollectionsWorker.isWorkerInState( + getApplication(), + RefreshCollectionsWorker.workerName(serviceId), + WorkInfo.State.RUNNING + ) // observe SyncWorker state private val authorities = if (collectionType == Collection.TYPE_ADDRESSBOOK) - listOf(getApplication().getString(R.string.address_books_authority), ContactsContract.AUTHORITY) + listOf( + getApplication().getString(R.string.address_books_authority), + ContactsContract.AUTHORITY + ) else listOf(CalendarContract.AUTHORITY, taskProvider?.authority).filterNotNull() - val isSyncActive = SyncWorker.exists(getApplication(), + val isSyncActive = SyncWorker.exists( + getApplication(), listOf(WorkInfo.State.RUNNING), accountModel.account, - authorities) - val isSyncPending = SyncWorker.exists(getApplication(), + authorities + ) + val isSyncPending = SyncWorker.exists( + getApplication(), listOf(WorkInfo.State.ENQUEUED), accountModel.account, - authorities) + authorities + ) // actions diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt index bcb9b5d1c19846383eec7a2e31a2eca0fdcf5c4d..235813ab666c8ecce15c4802226e3935e8b5295d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionFragment.kt @@ -5,7 +5,7 @@ package at.bitfire.davdroid.ui.account import android.accounts.Account -import android.content.Context +import android.app.Application import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -15,20 +15,19 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.* import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.XmlUtils -import at.bitfire.davdroid.util.DavUtils -import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.ui.ExceptionInfoFragment +import at.bitfire.davdroid.util.DavUtils import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -39,7 +38,7 @@ import java.util.logging.Level import javax.inject.Inject @AndroidEntryPoint -class CreateCollectionFragment: DialogFragment() { +class CreateCollectionFragment : DialogFragment() { companion object { const val ARG_ACCOUNT = "account" @@ -58,26 +57,43 @@ class CreateCollectionFragment: DialogFragment() { const val ARG_SUPPORTS_VJOURNAL = "supportsVJOURNAL" } - @Inject lateinit var modelFactory: Model.Factory + @Inject + lateinit var modelFactory: Model.Factory val model by viewModels() { object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { val args = requireArguments() - val account: Account = args.getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required") - val serviceType = args.getString(ARG_SERVICE_TYPE) ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required") + val account: Account = args.getParcelable(ARG_ACCOUNT) + ?: throw IllegalArgumentException("ARG_ACCOUNT required") + val serviceType = args.getString(ARG_SERVICE_TYPE) + ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required") val collection = Collection( - type = args.getString(ARG_TYPE) ?: throw IllegalArgumentException("ARG_TYPE required"), - url = (args.getString(ARG_URL) ?: throw IllegalArgumentException("ARG_URL required")).toHttpUrl(), + type = args.getString(ARG_TYPE) + ?: throw IllegalArgumentException("ARG_TYPE required"), + url = (args.getString(ARG_URL) + ?: throw IllegalArgumentException("ARG_URL required")).toHttpUrl(), displayName = args.getString(ARG_DISPLAY_NAME), description = args.getString(ARG_DESCRIPTION), color = args.ifDefined(ARG_COLOR) { it.getInt(ARG_COLOR) }, timezone = args.getString(ARG_TIMEZONE), - supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { it.getBoolean(ARG_SUPPORTS_VEVENT) }, - supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { it.getBoolean(ARG_SUPPORTS_VTODO) }, - supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { it.getBoolean(ARG_SUPPORTS_VJOURNAL) }, + supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { + it.getBoolean( + ARG_SUPPORTS_VEVENT + ) + }, + supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { + it.getBoolean( + ARG_SUPPORTS_VTODO + ) + }, + supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { + it.getBoolean( + ARG_SUPPORTS_VJOURNAL + ) + }, sync = true /* by default, sync collections which just have been created */ ) @@ -97,19 +113,23 @@ class CreateCollectionFragment: DialogFragment() { else { dismiss() parentFragmentManager.beginTransaction() - .add(ExceptionInfoFragment.newInstance(exception, model.account), null) - .commit() + .add(ExceptionInfoFragment.newInstance(exception, model.account), null) + .commit() } }) } - private fun Bundle.ifDefined(name: String, getter: (Bundle) -> T): T? = - if (containsKey(name)) - getter(this) - else - null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + private fun Bundle.ifDefined(name: String, getter: (Bundle) -> T): T? = + if (containsKey(name)) + getter(this) + else + null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { val v = inflater.inflate(R.layout.create_collection, container, false) isCancelable = false return v @@ -117,13 +137,13 @@ class CreateCollectionFragment: DialogFragment() { class Model @AssistedInject constructor( - @ApplicationContext val context: Context, + application: Application, val db: AppDatabase, @Assisted val account: Account, @Assisted val serviceType: String, @Assisted val collection: Collection - ): ViewModel() { - + ) : AndroidViewModel(application) { + @AssistedFactory interface Factory { fun create(account: Account, serviceType: String, collection: Collection): Model @@ -133,32 +153,35 @@ class CreateCollectionFragment: DialogFragment() { fun createCollection(): LiveData { viewModelScope.launch(Dispatchers.IO + NonCancellable) { - val accountSettings = AccountSettings(context, account) - HttpClient.Builder(context, accountSettings) - .setForeground(true) - .build().use { httpClient -> - try { - val dav = DavResource(httpClient.okHttpClient, collection.url) - - // create collection on remote server - dav.mkCol(generateXml()) {} - - // no HTTP error -> create collection locally - db.serviceDao().getByAccountAndType(account.name, serviceType)?.let { service -> - collection.serviceId = service.id - db.collectionDao().insert(collection) - - // trigger service detection (because the collection may have other properties than the ones we have inserted) - RefreshCollectionsWorker.refreshCollections(context, service.id) + HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account)) + .setForeground(true) + .build().use { httpClient -> + try { + val dav = DavResource(httpClient.okHttpClient, collection.url) + + // create collection on remote server + dav.mkCol(generateXml()) {} + + // no HTTP error -> create collection locally + db.serviceDao().getByAccountAndType(account.name, serviceType) + ?.let { service -> + collection.serviceId = service.id + db.collectionDao().insert(collection) + + // trigger service detection (because the collection may have other properties than the ones we have inserted) + RefreshCollectionsWorker.refreshCollections( + getApplication(), + service.id + ) + } + + // post success + result.postValue(null) + } catch (e: Exception) { + // post error + result.postValue(e) } - - // post success - result.postValue(null) - } catch (e: Exception) { - // post error - result.postValue(e) } - } } return result } @@ -250,7 +273,7 @@ class CreateCollectionFragment: DialogFragment() { endTag(XmlUtils.NS_WEBDAV, "mkcol") endDocument() } - } catch(e: IOException) { + } catch (e: IOException) { Logger.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt index d37fe0abbf87309759aa7ac3ba938b4ec5f11ae5..c01b1f4dc07c24f9edabe9be833f283d46cbf952 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionFragment.kt @@ -5,7 +5,7 @@ package at.bitfire.davdroid.ui.account import android.accounts.Account -import android.content.Context +import android.app.Application import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -14,17 +14,16 @@ import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels import androidx.lifecycle.* import at.bitfire.dav4jvm.DavResource -import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.databinding.DeleteCollectionBinding import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.ui.ExceptionInfoFragment import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -88,11 +87,11 @@ class DeleteCollectionFragment: DialogFragment() { class Model @AssistedInject constructor( - @ApplicationContext val context: Context, + application: Application, val db: AppDatabase, @Assisted var account: Account, @Assisted val collectionId: Long - ): ViewModel() { + ): AndroidViewModel(application) { @AssistedFactory interface Factory { @@ -114,9 +113,7 @@ class DeleteCollectionFragment: DialogFragment() { viewModelScope.launch(Dispatchers.IO + NonCancellable) { val collectionInfo = collectionInfo ?: return@launch - val accountSettings = AccountSettings(context, account) - - HttpClient.Builder(context, accountSettings) + HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account)) .setForeground(true) .build().use { httpClient -> try { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountFragment.kt index 297b27e28e3b18790e35a6156923dc8f5fb6aa22..12c959bc2898772a5891084815687835ae0c5459 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/RenameAccountFragment.kt @@ -36,7 +36,6 @@ import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker import at.bitfire.davdroid.syncadapter.SyncWorker -import at.bitfire.davdroid.util.closeCompat import at.bitfire.ical4android.TaskProvider import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint @@ -212,7 +211,7 @@ class RenameAccountFragment: DialogFragment() { addressBook.mainAccount = Account(newName, oldAccount.type) } } finally { - provider.closeCompat() + provider.close() } } } catch (e: Exception) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/SettingsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/SettingsActivity.kt index 4de4b1895a5c27c4e0517aa4be34f7a41e7518f8..aaf960f15c6023312f640b6424e047116af0de55 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/SettingsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/SettingsActivity.kt @@ -20,6 +20,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.app.TaskStackBuilder import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -229,7 +230,6 @@ class SettingsActivity: AppCompatActivity() { model.ignoreVpns.observe(viewLifecycleOwner) { ignoreVpns -> it.isEnabled = true it.isChecked = ignoreVpns - it.isVisible = Build.VERSION.SDK_INT >= 23 it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, prefValue -> model.updateIgnoreVpns(prefValue as Boolean) false @@ -446,10 +446,10 @@ class SettingsActivity: AppCompatActivity() { class Model @AssistedInject constructor( - val application: Application, + application: Application, val settings: SettingsManager, @Assisted val account: Account - ): ViewModel(), SettingsManager.OnChangeListener { + ): AndroidViewModel(application), SettingsManager.OnChangeListener { @AssistedFactory interface Factory { @@ -508,7 +508,9 @@ class SettingsActivity: AppCompatActivity() { fun reload() { val accountSettings = accountSettings ?: return - syncIntervalContacts.postValue(accountSettings.getSyncInterval(application.getString(R.string.address_books_authority))) + syncIntervalContacts.postValue( + accountSettings.getSyncInterval(getApplication().getString(R.string.address_books_authority)) + ) syncIntervalCalendars.postValue(accountSettings.getSyncInterval(CalendarContract.AUTHORITY)) syncIntervalTasks.postValue(tasksProvider?.let { accountSettings.getSyncInterval(it.authority) }) @@ -591,7 +593,10 @@ class SettingsActivity: AppCompatActivity() { accountSettings?.setGroupMethod(groupMethod) reload() - resync(application.getString(R.string.address_books_authority), fullResync = true) + resync( + authority = getApplication().getString(R.string.address_books_authority), + fullResync = true + ) } /** @@ -618,7 +623,7 @@ class SettingsActivity: AppCompatActivity() { */ private fun resync(authority: String, fullResync: Boolean) { val resync = if (fullResync) SyncWorker.FULL_RESYNC else SyncWorker.RESYNC - SyncWorker.enqueue(application, account, authority, resync) + SyncWorker.enqueue(getApplication(), account, authority, resync) } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WebcalFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WebcalFragment.kt index 107674389b206c9cfb2bb7b4a4cfde96f13e3d8e..50fb65350973756a8c59656bea3d355894cdaf14 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WebcalFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WebcalFragment.kt @@ -5,6 +5,7 @@ package at.bitfire.davdroid.ui.account import android.Manifest +import android.app.Application import android.content.ContentProviderClient import android.content.Context import android.content.Intent @@ -22,19 +23,17 @@ import androidx.lifecycle.* import androidx.room.Transaction import at.bitfire.dav4jvm.UrlUtils import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.davdroid.R -import at.bitfire.davdroid.util.closeCompat import at.bitfire.davdroid.databinding.AccountCaldavItemBinding import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.util.PermissionUtils import com.google.android.material.snackbar.Snackbar import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import okhttp3.HttpUrl @@ -51,7 +50,7 @@ class WebcalFragment: CollectionsFragment() { override val noCollectionsStringId = R.string.account_no_webcals @Inject lateinit var webcalModelFactory: WebcalModel.Factory - val webcalModel by viewModels() { + private val webcalModel by viewModels { object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class) = @@ -61,6 +60,17 @@ class WebcalFragment: CollectionsFragment() { } } + private val menuProvider = object : CollectionsMenuProvider() { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.caldav_actions, menu) + } + + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.create_calendar).isVisible = false + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -69,12 +79,14 @@ class WebcalFragment: CollectionsFragment() { }) } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = - inflater.inflate(R.menu.caldav_actions, menu) + override fun onResume() { + super.onResume() + requireActivity().addMenuProvider(menuProvider) + } - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.create_calendar).isVisible = false + override fun onPause() { + super.onPause() + requireActivity().removeMenuProvider(menuProvider) } @@ -168,16 +180,18 @@ class WebcalFragment: CollectionsFragment() { class WebcalModel @AssistedInject constructor( - @ApplicationContext context: Context, + application: Application, val db: AppDatabase, @Assisted val serviceId: Long - ): ViewModel() { + ): AndroidViewModel(application) { @AssistedFactory interface Factory { fun create(serviceId: Long): WebcalModel } + val context: Context get() = getApplication() + private val resolver = context.contentResolver private var calendarPermission = false @@ -204,7 +218,7 @@ class WebcalFragment: CollectionsFragment() { } fun disconnect() { - value?.closeCompat() + value?.close() value = null } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt index b9d8bd3ba9c94479edbf8d4a0eff939f936fcea8..16530fcad561ad6f808a61f72606421e97b0545a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/WifiPermissionsActivity.kt @@ -21,15 +21,15 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.app.TaskStackBuilder import androidx.core.content.ContextCompat -import androidx.core.content.ContextCompat.getSystemService +import androidx.core.content.getSystemService import androidx.core.location.LocationManagerCompat import androidx.core.text.HtmlCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.ActivityWifiPermissionsBinding import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.util.PermissionUtils class WifiPermissionsActivity: AppCompatActivity() { @@ -148,7 +148,7 @@ class WifiPermissionsActivity: AppCompatActivity() { // Android 9+: location service if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - getSystemService(getApplication(), LocationManager::class.java)?.let { locationManager -> + getApplication().getSystemService()?.let { locationManager -> val locationEnabled = LocationManagerCompat.isLocationEnabled(locationManager) isLocationEnabled.value = locationEnabled needLocationEnabled.value = locationEnabled diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt index c578b2c6f380fce44375d73cdc479e92c487d9f0..761e1f45e057119c374a39f609926c890430d33e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/BatteryOptimizationsFragment.kt @@ -5,6 +5,7 @@ package at.bitfire.davdroid.ui.intro import android.annotation.SuppressLint +import android.app.Application import android.content.Context import android.content.Intent import android.net.Uri @@ -14,12 +15,13 @@ import android.os.PowerManager import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContract import androidx.core.content.getSystemService import androidx.databinding.ObservableBoolean import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import at.bitfire.davdroid.App import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R @@ -34,7 +36,6 @@ import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.components.ActivityComponent import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.multibindings.IntoSet import org.apache.commons.text.WordUtils import java.util.* @@ -43,26 +44,24 @@ import javax.inject.Inject @AndroidEntryPoint class BatteryOptimizationsFragment: Fragment() { - companion object { - const val REQUEST_IGNORE_BATTERY_OPTIMIZATIONS = 0 - } - val model by viewModels() + private val ignoreBatteryOptimizationsResultLauncher = + registerForActivityResult(IgnoreBatteryOptimizationsContract) { + model.checkWhitelisted() + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val binding = IntroBatteryOptimizationsBinding.inflate(inflater, container, false) binding.lifecycleOwner = viewLifecycleOwner binding.model = model - model.shouldBeWhitelisted.observe(viewLifecycleOwner, { shouldBeWhitelisted -> + model.shouldBeWhitelisted.observe(viewLifecycleOwner) { shouldBeWhitelisted -> @SuppressLint("BatteryLife") - if (shouldBeWhitelisted && !model.isWhitelisted.value!! && Build.VERSION.SDK_INT >= 23) - startActivityForResult(Intent( - android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - Uri.parse("package:" + BuildConfig.APPLICATION_ID) - ), REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) - }) + if (shouldBeWhitelisted && !model.isWhitelisted.value!!) + ignoreBatteryOptimizationsResultLauncher.launch(BuildConfig.APPLICATION_ID) + } binding.batteryText.text = getString(R.string.intro_battery_text, getString(R.string.app_name)) binding.autostartHeading.text = getString(R.string.intro_autostart_title, WordUtils.capitalize(Build.MANUFACTURER)) @@ -78,12 +77,6 @@ class BatteryOptimizationsFragment: Fragment() { return binding.root } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) - model.checkWhitelisted() - } - override fun onResume() { super.onResume() model.checkWhitelisted() @@ -92,9 +85,9 @@ class BatteryOptimizationsFragment: Fragment() { @HiltViewModel class Model @Inject constructor( - @ApplicationContext val context: Context, + application: Application, val settings: SettingsManager - ): ViewModel() { + ): AndroidViewModel(application) { companion object { @@ -135,11 +128,7 @@ class BatteryOptimizationsFragment: Fragment() { (evilManufacturers.contains(Build.MANUFACTURER.lowercase(Locale.ROOT)) || BuildConfig.DEBUG) fun isWhitelisted(context: Context) = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val powerManager = context.getSystemService()!! - powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) - } else - true + context.getSystemService()!!.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) } val shouldBeWhitelisted = MutableLiveData() @@ -167,7 +156,7 @@ class BatteryOptimizationsFragment: Fragment() { } fun checkWhitelisted() { - val whitelisted = isWhitelisted(context) + val whitelisted = isWhitelisted(getApplication()) isWhitelisted.value = whitelisted shouldBeWhitelisted.value = whitelisted @@ -179,6 +168,21 @@ class BatteryOptimizationsFragment: Fragment() { } + @SuppressLint("BatteryLife") + object IgnoreBatteryOptimizationsContract: ActivityResultContract() { + override fun createIntent(context: Context, input: String): Intent { + return Intent( + android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:$input") + ) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Unit? { + return null + } + } + + @Module @InstallIn(ActivityComponent::class) abstract class BatteryOptimizationsFragmentModule { @@ -191,19 +195,19 @@ class BatteryOptimizationsFragment: Fragment() { ): IntroFragmentFactory { override fun getOrder(context: Context) = - // show fragment when: - // 1. DAVx5 is not whitelisted yet and "don't show anymore" has not been clicked, and/or - // 2a. evil manufacturer AND - // 2b. "don't show anymore" has not been clicked - if ( - (!Model.isWhitelisted(context) && settingsManager.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) != false) || - (Model.manufacturerWarning && settingsManager.getBooleanOrNull(HINT_AUTOSTART_PERMISSION) != false) - ) - 100 - else - IntroFragmentFactory.DONT_SHOW + // show fragment when: + // 1. DAVx5 is not whitelisted yet and "don't show anymore" has not been clicked, and/or + // 2a. evil manufacturer AND + // 2b. "don't show anymore" has not been clicked + if ( + (!Model.isWhitelisted(context) && settingsManager.getBooleanOrNull(HINT_BATTERY_OPTIMIZATIONS) != false) || + (Model.manufacturerWarning && settingsManager.getBooleanOrNull(HINT_AUTOSTART_PERMISSION) != false) + ) + 100 + else + IntroFragmentFactory.DONT_SHOW override fun create() = BatteryOptimizationsFragment() } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt index b4cae9640613c805f6cae8c33cabcaa63a1f4d82..626f9f4b0f8688cc980fd0886bbf4d0c0e1543c0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt @@ -5,7 +5,12 @@ package at.bitfire.davdroid.ui.intro import android.app.Activity +import android.content.Context +import android.content.Intent import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.addCallback +import androidx.annotation.WorkerThread import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import at.bitfire.davdroid.R @@ -29,12 +34,11 @@ class IntroActivity: AppIntro2() { companion object { + @WorkerThread fun shouldShowIntroActivity(activity: Activity): Boolean { val factories = EntryPointAccessors.fromActivity(activity, IntroActivityEntryPoint::class.java).introFragmentFactories() return factories.any { - val order = it.getOrder(activity) - Logger.log.fine("Found intro fragment factory ${it::class.java} with order $order") - order > 0 + it.getOrder(activity) > 0 } } @@ -42,24 +46,38 @@ class IntroActivity: AppIntro2() { private var currentSlide = 0 - @Inject lateinit var introFragmentFactories: Set<@JvmSuppressWildcards IntroFragmentFactory> - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val factoriesWithOrder = introFragmentFactories - .associateBy { it.getOrder(this) } - .filterKeys { it != IntroFragmentFactory.DONT_SHOW } + val factories = EntryPointAccessors.fromActivity(this, IntroActivityEntryPoint::class.java).introFragmentFactories() + for (factory in factories) + Logger.log.fine("Found intro fragment factory ${factory::class.java} with order ${factory.getOrder(this)}") + + val factoriesWithOrder = factories + .associateWith { it.getOrder(this) } + .filterValues { it != IntroFragmentFactory.DONT_SHOW } - val anyPositiveOrder = factoriesWithOrder.keys.any { it > 0 } + val anyPositiveOrder = factoriesWithOrder.values.any { it > 0 } if (anyPositiveOrder) { - for ((_, factory) in factoriesWithOrder.toSortedMap()) + val factoriesSortedByOrder = factoriesWithOrder + .toList() + .sortedBy { (_, v) -> v } // sort by value (= getOrder()) + for ((factory, _) in factoriesSortedByOrder) addSlide(factory.create()) } setBarColor(ResourcesCompat.getColor(resources, R.color.accentColor, null)) isSkipButtonEnabled = false + + onBackPressedDispatcher.addCallback(this) { + if (currentSlide == 0) { + setResult(Activity.RESULT_CANCELED) + finish() + } else { + goToPreviousSlide() + } + } } override fun onPageSelected(position: Int) { @@ -67,16 +85,23 @@ class IntroActivity: AppIntro2() { currentSlide = position } - override fun onBackPressed() { - if (currentSlide == 0) - setResult(Activity.RESULT_CANCELED) - super.onBackPressed() - } - override fun onDonePressed(currentFragment: Fragment?) { super.onDonePressed(currentFragment) setResult(Activity.RESULT_OK) finish() } + + /** + * For launching the [IntroActivity]. Result is `true` when the user cancelled the intro. + */ + object Contract: ActivityResultContract() { + override fun createIntent(context: Context, input: Unit?): Intent = + Intent(context, IntroActivity::class.java) + + override fun parseResult(resultCode: Int, intent: Intent?): Boolean { + return resultCode == Activity.RESULT_CANCELED + } + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt index 3e6493ff3881448730cd91e96c8b404a0435272c..3488f84deba7988f463e12614c8716f8797d1433 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroFragmentFactory.kt @@ -22,7 +22,7 @@ interface IntroFragmentFactory { * @return Order with which an instance of this fragment type shall be created and shown. Possible values: * * * <0: only show the fragment when there is at least one other fragment with positive order (lower numbers are shown first) - * * 0: don't show the fragment + * * [DONT_SHOW] (0): don't show the fragment * * ≥0: show the fragment (lower numbers are shown first) */ fun getOrder(context: Context): Int diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt index 30f01de01d61b5a1546ce6b44f2d1e14be88fc04..0204826904357d9248add3d98b91e0ba2777accb 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/OpenSourceFragment.kt @@ -21,7 +21,6 @@ import at.bitfire.davdroid.ui.UiUtils import at.bitfire.davdroid.ui.intro.OpenSourceFragment.Model.Companion.SETTING_NEXT_DONATION_POPUP import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @AndroidEntryPoint @@ -48,7 +47,6 @@ class OpenSourceFragment: Fragment() { @HiltViewModel class Model @Inject constructor( - @ApplicationContext val context: Context, val settings: SettingsManager ): ViewModel() { @@ -76,7 +74,7 @@ class OpenSourceFragment: Fragment() { override fun getOrder(context: Context) = if (System.currentTimeMillis() > (settingsManager.getLongOrNull(SETTING_NEXT_DONATION_POPUP) ?: 0)) - 100 + 500 else 0 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt index 0588aefce700c6c1e80c34b90bf76177609d138a..98df793fd3383a5ed2047ab80f706f82e217aefc 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/PermissionsIntroFragment.kt @@ -27,7 +27,10 @@ class PermissionsIntroFragment : Fragment() { override fun getOrder(context: Context): Int { // show PermissionsFragment as intro fragment when no permissions are granted - val permissions = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS + TaskProvider.PERMISSIONS_OPENTASKS + val permissions = CONTACT_PERMISSIONS + CALENDAR_PERMISSIONS + + TaskProvider.PERMISSIONS_JTX + + TaskProvider.PERMISSIONS_OPENTASKS + + TaskProvider.PERMISSIONS_TASKS_ORG return if (PermissionUtils.haveAnyPermission(context, permissions)) IntroFragmentFactory.DONT_SHOW else diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/TasksIntroFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/TasksIntroFragment.kt index b92801fdf2e9f82d8acc3e02721b22f092e304f9..9a2916a9d708e018b9860d838c4393e060d4760c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/TasksIntroFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/TasksIntroFragment.kt @@ -5,7 +5,6 @@ package at.bitfire.davdroid.ui.intro import android.content.Context -import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -30,10 +29,6 @@ class TasksIntroFragment : Fragment() { ): IntroFragmentFactory { override fun getOrder(context: Context): Int { - // On Android <6, OpenTasks must be installed before DAVx5, so this fragment is not useful. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) - return IntroFragmentFactory.DONT_SHOW - return if (!TaskUtils.isAvailable(context) && settingsManager.getBooleanOrNull(TasksFragment.Model.HINT_OPENTASKS_NOT_INSTALLED) != false) 10 else @@ -44,4 +39,4 @@ class TasksIntroFragment : Fragment() { } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt index 37bcb67b2845b5e6e18f7b731e3d9da133b7730e..29543cd067b1a5cfe29f94c78a344c8a555adc09 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt @@ -8,6 +8,7 @@ import android.accounts.Account import android.accounts.AccountAuthenticatorResponse import android.accounts.AccountManager import android.app.Activity +import android.app.Application import android.content.ComponentName import android.content.ContentResolver import android.content.Context @@ -24,10 +25,10 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.bitfire.davdroid.Constants import at.bitfire.davdroid.InvalidAccountException @@ -50,7 +51,6 @@ import at.bitfire.vcard4android.GroupMethod import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -112,11 +112,11 @@ class AccountDetailsFragment : Fragment() { v.createAccount.visibility = View.GONE model.createAccount( - requireActivity(), - name, - loginModel.credentials, - config, - GroupMethod.valueOf(groupMethodName) + requireActivity(), + name, + loginModel.credentials, + config, + GroupMethod.valueOf(groupMethodName) ).observe(viewLifecycleOwner, Observer { success -> if (success) { // close Create account activity @@ -133,6 +133,7 @@ class AccountDetailsFragment : Fragment() { val forcedGroupMethod = settings.getString(AccountSettings.KEY_CONTACT_GROUP_METHOD)?.let { GroupMethod.valueOf(it) } if (forcedGroupMethod != null) { + // contact group type forced by settings v.contactGroupMethod.isEnabled = false for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) { if (method == forcedGroupMethod.name) { @@ -140,8 +141,17 @@ class AccountDetailsFragment : Fragment() { break } } - } else + } else { + // contact group type selectable v.contactGroupMethod.isEnabled = true + for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) { + // take suggestion from detection process into account + if (method == loginModel.suggestedGroupMethod.name) { + v.contactGroupMethod.setSelection(i) + break + } + } + } val providedAccountType = requireActivity().intent.getStringExtra(LoginActivity.ACCOUNT_TYPE) if ((providedAccountType != getString(R.string.account_type)) && (providedAccountType in AccountUtils.getMainAccountTypes(requireContext()))) { @@ -229,15 +239,17 @@ class AccountDetailsFragment : Fragment() { @HiltViewModel class Model @Inject constructor( - @ApplicationContext val context: Context, + application: Application, val db: AppDatabase, val settingsManager: SettingsManager - ) : ViewModel() { + ) : AndroidViewModel(application) { val name = MutableLiveData() val nameError = MutableLiveData() val showApostropheWarning = MutableLiveData(false) + val context: Context get() = getApplication() + fun validateAccountName(s: Editable) { showApostropheWarning.value = s.toString().contains('\'') nameError.value = null diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt index af6530dbf1087ec065366817253ed3de91276344..b76cebe6cba66ba2eb8dd4c8794a8a211992cc8b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt @@ -71,10 +71,11 @@ class DefaultLoginCredentialsFragment : Fragment() { v.login.setOnClickListener { _ -> if (validate()) { val nextFragment = - if (model.loginGoogle.value == true) - GoogleLoginFragment() - else - DetectConfigurationFragment() + when { + model.loginGoogle.value == true -> GoogleLoginFragment() + model.loginNextcloud.value == true -> NextcloudLoginFlowFragment() + else -> DetectConfigurationFragment() + } parentFragmentManager.beginTransaction() .replace(android.R.id.content, nextFragment, null) @@ -204,7 +205,8 @@ class DefaultLoginCredentialsFragment : Fragment() { } } - model.loginGoogle.value == true -> { + // some login methods don't require further input → always valid + model.loginGoogle.value == true || model.loginNextcloud.value == true -> { valid = true } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt index 30c9ed2d169fe304e96c6921250b2648076b9b12..5edb235be81c505adc3213655182e3c59cdc7d82 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsModel.kt @@ -29,6 +29,7 @@ class DefaultLoginCredentialsModel(app: Application): AndroidViewModel(app) { val loginWithUrlAndUsername = MutableLiveData(false) val loginAdvanced = MutableLiveData(false) val loginGoogle = MutableLiveData(false) + val loginNextcloud = MutableLiveData(false) val baseUrl = MutableLiveData() val baseUrlError = MutableLiveData() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLoginFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLoginFragment.kt index 8ebe158545ce0cbe56268af9b265fb1d0e70da1d..6ef6e097182ba92726b5ec4a011675ce909c0478 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLoginFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLoginFragment.kt @@ -17,16 +17,26 @@ import android.view.ViewGroup import android.widget.TextView import androidx.activity.result.contract.ActivityResultContract import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* +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.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -34,6 +44,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.tooling.preview.Preview @@ -58,7 +69,14 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import net.openid.appauth.* +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenResponse import org.apache.commons.lang3.StringUtils import java.net.URI import java.util.logging.Level @@ -236,31 +254,52 @@ fun GoogleLogin( } val email = rememberSaveable { mutableStateOf(defaultEmail ?: "") } - val emailError = remember { mutableStateOf(false) } + val userClientId = rememberSaveable { mutableStateOf("") } + + fun login() { + // append @gmail.com, if necessary + val loginEmail = email.value.let { + if (it.contains('@')) + it + else + it + "@gmail.com" + } + + val clientId = StringUtils.trimToNull(userClientId.value.trim()) + onLogin(loginEmail, clientId) + } OutlinedTextField( email.value, singleLine = true, onValueChange = { email.value = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Go + ), + keyboardActions = KeyboardActions( + onGo = { login() } + ), label = { Text(stringResource(R.string.login_google_account)) }, - isError = emailError.value, placeholder = { Text("example@gmail.com") }, modifier = Modifier .fillMaxWidth() .padding(top = 8.dp) ) - val userClientId = rememberSaveable { mutableStateOf("") } - val userClientIdError = remember { mutableStateOf(false) } OutlinedTextField( userClientId.value, singleLine = true, onValueChange = { clientId -> userClientId.value = clientId }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Go + ), + keyboardActions = KeyboardActions( + onGo = { login() } + ), label = { Text(stringResource(R.string.login_google_client_id)) }, - isError = userClientIdError.value, placeholder = { Text("[...].apps.googleusercontent.com") }, modifier = Modifier .fillMaxWidth() @@ -268,15 +307,7 @@ fun GoogleLogin( ) Button( - onClick = { - val validEmail = email.value.contains('@') - emailError.value = !validEmail - - if (validEmail) { - val clientId = StringUtils.trimToNull(userClientId.value.trim()) - onLogin(email.value, clientId) - } - }, + onClick = { login() }, modifier = Modifier .padding(top = 8.dp) .wrapContentSize(), diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt index df981373c778b678a3590d15218754be983a0313..e9f290428a14bbbc06668f8e14f76890ff1dbc87 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt @@ -5,7 +5,6 @@ package at.bitfire.davdroid.ui.setup import android.os.Bundle -import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import at.bitfire.davdroid.log.Logger @@ -17,7 +16,7 @@ import javax.inject.Inject * Fields for server/user data can be pre-filled with extras in the Intent. */ @AndroidEntryPoint -class LoginActivity: AppCompatActivity() { +class LoginActivity : AppCompatActivity() { companion object { @@ -79,13 +78,4 @@ class LoginActivity: AppCompatActivity() { } } } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - - return false - } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginModel.kt index 6a02e740a4bb8c86ef61ecd2d1bab862e7fc2d4d..90e23a0f68e59e6c53af6a7ad77c6b26e3819c7f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginModel.kt @@ -7,6 +7,7 @@ package at.bitfire.davdroid.ui.setup import androidx.lifecycle.ViewModel import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.servicedetection.DavResourceFinder +import at.bitfire.vcard4android.GroupMethod import java.net.URI class LoginModel: ViewModel() { @@ -17,9 +18,10 @@ class LoginModel: ViewModel() { var configuration: DavResourceFinder.Configuration? = null - /** - * Account name that should be used as default account name when no email addresses have been found. - */ + /** account name that should be used as default account name when no email addresses have been found */ var suggestedAccountName: String? = null -} + /** group method that should be pre-selectedbr */ + var suggestedGroupMethod: GroupMethod = GroupMethod.GROUP_VCARDS + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt index 3e9a25403d0bc810a10b9525027b466c8f26d997..4b2d3c7218d584750e4c95988b5975b3c3b5e346 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginFlowFragment.kt @@ -7,16 +7,38 @@ package at.bitfire.davdroid.ui.setup import android.annotation.SuppressLint import android.app.Application import android.content.Intent -import android.net.Uri import android.os.Bundle import android.provider.Browser import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.fragment.app.Fragment @@ -24,14 +46,16 @@ import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.davdroid.R import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.network.HttpClient -import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.UiUtils.haveCustomTabs +import at.bitfire.vcard4android.GroupMethod +import com.google.accompanist.themeadapter.material.MdcTheme import com.google.android.material.snackbar.Snackbar import dagger.Binds import dagger.Module @@ -39,11 +63,11 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntKey import dagger.multibindings.IntoMap -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody @@ -51,14 +75,15 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.net.HttpURLConnection import java.net.URI +import java.util.logging.Level import javax.inject.Inject class NextcloudLoginFlowFragment: Fragment() { companion object { - const val LOGIN_FLOW_V1_PATH = "/index.php/login/flow" - const val LOGIN_FLOW_V2_PATH = "/index.php/login/v2" + const val LOGIN_FLOW_V1_PATH = "index.php/login/flow" + val LOGIN_FLOW_V2_PATH = "index.php/login/v2" /** Set this to 1 to indicate that Login Flow shall be used. */ const val EXTRA_LOGIN_FLOW = "loginFlow" @@ -66,31 +91,47 @@ class NextcloudLoginFlowFragment: Fragment() { /** Path to DAV endpoint (e.g. `/remote.php/dav`). Will be appended to the * server URL returned by Login Flow without further processing. */ const val EXTRA_DAV_PATH = "davPath" - - const val REQUEST_BROWSER = 0 } val loginModel by activityViewModels() - val loginFlowModel by viewModels() + val model by viewModels() + + val checkResultCallback = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) + model.checkResult(davPath) + } @SuppressLint("SetJavaScriptEnabled") override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val view = View(requireActivity()) - - val entryUrl = requireActivity().intent.data ?: throw IllegalArgumentException("Intent data must be set to Login Flow URL") - Logger.log.info("Using Login Flow entry point: $entryUrl") + val entryUrl = requireActivity().intent.data?.toString()?.toHttpUrlOrNull() + + val view = ComposeView(requireActivity()).apply { + setContent { + MdcTheme { + NextcloudLoginComposable( + onStart = { url -> + model.start(url) + }, + entryUrl = entryUrl, + inProgress = model.inProgress.observeAsState(false), + error = model.error.observeAsState() + ) + } + } + } - loginFlowModel.loginUrl.observe(viewLifecycleOwner) { loginUrl -> + model.loginUrl.observe(viewLifecycleOwner) { loginUrl -> if (loginUrl == null) return@observe val loginUri = loginUrl.toUri() // reset URL so that the browser isn't shown another time - loginFlowModel.loginUrl.value = null + model.loginUrl.value = null if (haveCustomTabs(requireActivity())) { // Custom Tabs are available + @Suppress("DEPRECATION") val browser = CustomTabsIntent.Builder() .setToolbarColor(resources.getColor(R.color.primaryColor)) .build() @@ -99,70 +140,58 @@ class NextcloudLoginFlowFragment: Fragment() { Browser.EXTRA_HEADERS, bundleOf("Accept-Language" to Locale.current.toLanguageTag()) ) - startActivityForResult(browser.intent, REQUEST_BROWSER, browser.startAnimationBundle) - + checkResultCallback.launch(browser.intent) } else { // fallback: launch normal browser val browser = Intent(Intent.ACTION_VIEW, loginUri) browser.addCategory(Intent.CATEGORY_BROWSABLE) if (browser.resolveActivity(requireActivity().packageManager) != null) - startActivityForResult(browser, REQUEST_BROWSER) + checkResultCallback.launch(browser) else Snackbar.make(view, getString(R.string.install_browser), Snackbar.LENGTH_INDEFINITE).show() } } - loginFlowModel.error.observe(viewLifecycleOwner) { exception -> - Snackbar.make(requireView(), exception.toString(), Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.exception_show_details) { - val intent = DebugInfoActivity.IntentBuilder(requireActivity()) - .withCause(exception) - .build() - startActivity(intent) - } - .show() - } + model.loginData.observe(viewLifecycleOwner) { loginData -> + if (loginData == null) + return@observe + val (baseUri, credentials) = loginData - loginFlowModel.loginData.observe(viewLifecycleOwner) { (baseUri, credentials) -> // continue to next fragment loginModel.baseURI = baseUri loginModel.credentials = credentials + loginModel.suggestedGroupMethod = GroupMethod.CATEGORIES parentFragmentManager.beginTransaction() .replace(android.R.id.content, DetectConfigurationFragment(), null) .addToBackStack(null) .commit() + + // reset loginData so that we can go back + model.loginData.value = null } - // start Login Flow - loginFlowModel.setUrl(entryUrl) + if (savedInstanceState == null && entryUrl != null) + model.start(entryUrl) return view } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode != REQUEST_BROWSER) - return - - val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) - loginFlowModel.checkResult(davPath) - } - /** * Implements Login Flow v2. * * @see https://docs.nextcloud.com/server/20/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 */ - class LoginFlowModel(app: Application): AndroidViewModel(app) { - - val error = MutableLiveData() + class Model(app: Application): AndroidViewModel(app) { val loginUrl = MutableLiveData() + val error = MutableLiveData() val httpClient by lazy { HttpClient.Builder(getApplication()) - .setForeground(true) - .build() + .setForeground(true) + .build() } + val inProgress = MutableLiveData(false) var pollUrl: HttpUrl? = null var token: String? = null @@ -174,20 +203,30 @@ class NextcloudLoginFlowFragment: Fragment() { } + /** + * Starts the Login Flow. + * + * @param entryUrl entryURL: either a Login Flow path (ending with [LOGIN_FLOW_V1_PATH] or [LOGIN_FLOW_V2_PATH]), + * or another URL which is treated as Nextcloud root URL. In this case, [LOGIN_FLOW_V2_PATH] is appended. + */ @UiThread - fun setUrl(entryUri: Uri) { - val entryUrl = entryUri.toString() - val v2Url = - if (entryUrl.endsWith(LOGIN_FLOW_V1_PATH)) - // got Login Flow v1 URL, rewrite to v2 - entryUrl.removeSuffix(LOGIN_FLOW_V1_PATH) + LOGIN_FLOW_V2_PATH - else - entryUrl + fun start(entryUrl: HttpUrl) { + inProgress.value = true + error.value = null + + var entryUrlStr = entryUrl.toString() + if (entryUrlStr.endsWith(LOGIN_FLOW_V1_PATH)) + // got Login Flow v1 URL, rewrite to v2 + entryUrlStr = entryUrlStr.removeSuffix(LOGIN_FLOW_V1_PATH) + + val v2Url = entryUrlStr.toHttpUrl().newBuilder() + .addPathSegments(LOGIN_FLOW_V2_PATH) + .build() // send POST request and process JSON reply - CoroutineScope(Dispatchers.IO).launch { + viewModelScope.launch(Dispatchers.IO) { try { - val json = postForJson(v2Url.toHttpUrl(), "".toRequestBody()) + val json = postForJson(v2Url, "".toRequestBody()) // login URL loginUrl.postValue(json.getString("login")) @@ -198,7 +237,10 @@ class NextcloudLoginFlowFragment: Fragment() { token = poll.getString("token") } } catch (e: Exception) { - error.postValue(e) + Logger.log.log(Level.WARNING, "Couldn't obtain login URL", e) + error.postValue(getApplication().getString(R.string.login_nextcloud_login_flow_no_login_url)) + } finally { + inProgress.postValue(false) } } } @@ -208,7 +250,7 @@ class NextcloudLoginFlowFragment: Fragment() { val pollUrl = pollUrl ?: return val token = token ?: return - CoroutineScope(Dispatchers.IO).launch { + viewModelScope.launch(Dispatchers.IO) { try { val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType())) val serverUrl = json.getString("server") @@ -221,11 +263,12 @@ class NextcloudLoginFlowFragment: Fragment() { URI.create(serverUrl) loginData.postValue(Pair( - baseUri, - Credentials(loginName, appPassword) + baseUri, + Credentials(loginName, appPassword) )) } catch (e: Exception) { - error.postValue(e) + Logger.log.log(Level.WARNING, "Polling login URL failed", e) + error.postValue(getApplication().getString(R.string.login_nextcloud_login_flow_no_login_data)) } } } @@ -259,7 +302,7 @@ class NextcloudLoginFlowFragment: Fragment() { class Factory @Inject constructor(): LoginCredentialsFragmentFactory { override fun getFragment(intent: Intent) = - if (intent.hasExtra(EXTRA_LOGIN_FLOW)) + if (intent.hasExtra(EXTRA_LOGIN_FLOW) && intent.data != null) NextcloudLoginFlowFragment() else null @@ -275,4 +318,124 @@ class NextcloudLoginFlowFragment: Fragment() { abstract fun factory(impl: Factory): LoginCredentialsFragmentFactory } +} + + +@Composable +fun NextcloudLoginComposable( + entryUrl: HttpUrl?, + inProgress: State, + error: State, + onStart: (HttpUrl) -> Unit +) { + Column { + if (inProgress.value) + LinearProgressIndicator( + color = MaterialTheme.colors.secondary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + Column(modifier = Modifier.padding(8.dp)) { + Text( + stringResource(R.string.login_nextcloud_login_with_nextcloud), + style = MaterialTheme.typography.h5 + ) + NextcloudLoginFlowComposable( + providedEntryUrl = entryUrl, + inProgress = inProgress, + error = error, + onStart = onStart + ) + } + } +} + + +@Composable +fun NextcloudLoginFlowComposable( + providedEntryUrl: HttpUrl?, + inProgress: State, + error: State, + onStart: ((HttpUrl) -> Unit) +) { + Column { + Text( + stringResource(R.string.login_nextcloud_login_flow), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(top = 16.dp) + ) + Text( + stringResource(R.string.login_nextcloud_login_flow_text), + modifier = Modifier.padding(vertical = 8.dp) + ) + + val entryUrlStr = remember { mutableStateOf(providedEntryUrl?.toString() ?: "") } + val entryUrl = remember { mutableStateOf(providedEntryUrl) } + OutlinedTextField(entryUrlStr.value, + onValueChange = { newUrlStr -> + entryUrlStr.value = newUrlStr + + entryUrl.value = try { + val withScheme = + if (!newUrlStr.startsWith("http://", true) && !newUrlStr.startsWith("https://", true)) + "https://$newUrlStr" + else + newUrlStr + withScheme.toHttpUrl() + } catch (e: IllegalArgumentException) { + null + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + readOnly = inProgress.value, + label = { + Text(stringResource(R.string.login_nextcloud_login_flow_server_address)) + }, + placeholder = { + Text("cloud.example.com") + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go + ), + keyboardActions = KeyboardActions( + onGo = { + entryUrl.value?.let(onStart) + } + ), + singleLine = true + ) + + Button( + onClick = { + entryUrl.value?.let(onStart) + }, + enabled = entryUrl.value != null && !inProgress.value + ) { + Text(stringResource(R.string.login_nextcloud_login_flow_sign_in)) + } + + error.value?.let { msg -> + Text( + msg, + color = MaterialTheme.colors.error, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } +} + +@Composable +@Preview +fun NextcloudLoginFlowComposable_PreviewWithError() { + NextcloudLoginFlowComposable( + providedEntryUrl = null, + inProgress = remember { mutableStateOf(true) }, + error = remember { mutableStateOf("Something wrong happened") }, + onStart = { } + ) } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt index 2cd8a9f15e1528b151c2784cce4c442d150fa306..e535979b1bd9926ab2ddb85c4a95c813fa16d336 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt @@ -4,34 +4,30 @@ package at.bitfire.davdroid.ui.webdav +import android.app.Application import android.content.Context import android.os.Bundle -import android.view.Menu -import android.view.MenuItem import android.view.View import androidx.activity.viewModels import androidx.annotation.WorkerThread import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.UrlUtils -import at.bitfire.davdroid.App -import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.R import at.bitfire.davdroid.databinding.ActivityAddWebdavMountBinding import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.db.WebDavMount import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.ui.UiUtils +import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.webdav.CredentialsStore import at.bitfire.davdroid.webdav.DavDocumentsProvider import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import okhttp3.HttpUrl @@ -43,7 +39,7 @@ import java.util.logging.Level import javax.inject.Inject @AndroidEntryPoint -class AddWebdavMountActivity: AppCompatActivity() { +class AddWebdavMountActivity : AppCompatActivity() { lateinit var binding: ActivityAddWebdavMountBinding val model by viewModels() @@ -132,9 +128,9 @@ class AddWebdavMountActivity: AppCompatActivity() { @HiltViewModel class Model @Inject constructor( - @ApplicationContext val context: Context, + application: Application, val db: AppDatabase - ) : ViewModel() { + ) : AndroidViewModel(application) { val displayName = MutableLiveData() val displayNameError = MutableLiveData() @@ -145,6 +141,8 @@ class AddWebdavMountActivity: AppCompatActivity() { val error = MutableLiveData() + val context: Context get() = getApplication() + @WorkerThread fun addMount(mount: WebDavMount, credentials: Credentials?): Boolean { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt index cfae0603ec796722665a535c092b78c64862218c..854a71337f4b90f26acbe906576e1762f9165f6e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt @@ -5,6 +5,7 @@ package at.bitfire.davdroid.ui.webdav import android.app.AlertDialog +import android.app.Application import android.content.Context import android.content.Intent import android.os.Bundle @@ -15,6 +16,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.text.HtmlCompat +import androidx.core.view.MenuProvider import androidx.lifecycle.* import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager @@ -168,9 +170,11 @@ class WebdavMountsActivity: AppCompatActivity() { @HiltViewModel class Model @Inject constructor( - @ApplicationContext val context: Context, + application: Application, val db: AppDatabase - ): ViewModel() { + ): AndroidViewModel(application) { + + val context: Context get() = getApplication() val authority = context.getString(R.string.webdav_authority) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/CompatUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/CompatUtils.kt index 1ff99ba9423922e8c29fa59617acc192feafaaa3..4b9889ae8f4627dfb22978d7b7661ff317663d88 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/CompatUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/CompatUtils.kt @@ -6,8 +6,6 @@ package at.bitfire.davdroid.util import android.accounts.Account import android.accounts.AccountManager -import android.content.ContentProviderClient -import android.os.Build import at.bitfire.davdroid.log.Logger /** @@ -26,12 +24,4 @@ fun AccountManager.setAndVerifyUserData(account: Account, key: String, value: St Thread.sleep(100) } Logger.log.warning("AccountManager failed to set $account user data $key := $value") -} - -@Suppress("DEPRECATION") -fun ContentProviderClient.closeCompat() { - if (Build.VERSION.SDK_INT >= 24) - close() - else - release() } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt index 8b436e07e81b97b6fa62409625af5a59ce7e1744..136087245fcac68fa2c652164ac6464479be44c9 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt @@ -151,6 +151,30 @@ object DavUtils { // extension methods + /** + * Returns parent URL (parent folder). Always with trailing slash + */ + fun HttpUrl.parent(): HttpUrl { + if (pathSegments.size == 1 && pathSegments[0] == "") + // already root URL + return this + + val builder = newBuilder() + + if (pathSegments[pathSegments.lastIndex] == "") { + // URL ends with a slash ("/some/thing/" -> ["some","thing",""]), remove two segments ("" at lastIndex and "thing" at lastIndex - 1) + builder.removePathSegment(pathSegments.lastIndex) + builder.removePathSegment(pathSegments.lastIndex - 1) + } else + // URL doesn't end with a slash ("/some/thing" -> ["some","thing"]), remove one segment ("thing" at lastIndex) + builder.removePathSegment(pathSegments.lastIndex) + + // append trailing slash + builder.addPathSegment("") + + return builder.build() + } + /** * Compares MIME type and subtype of two MediaTypes. Does _not_ compare parameters * like `charset` or `version`. diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt index 433267260864bd7b3c7440fe0ee07dfd8d037bc3..d23070485f84e28a917428b417fdc241a2ff0023 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/PermissionUtils.kt @@ -15,6 +15,7 @@ import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import androidx.core.location.LocationManagerCompat import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R @@ -61,7 +62,7 @@ object PermissionUtils { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) true // Android <9 doesn't require active location services else - ContextCompat.getSystemService(context, LocationManager::class.java)?.let { locationManager -> + context.getSystemService()?.let { locationManager -> LocationManagerCompat.isLocationEnabled(locationManager) } ?: /* location feature not available on this device */ false diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt index eb9bd74dade9ad3cec8e75e7265e8d78a75e774b..f2cc9ece2b65430daa38c82ae5959718f23d6e3f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt @@ -473,6 +473,7 @@ class DavDocumentsProvider: DocumentsProvider() { } Logger.log.info("Received file info: $fileInfo") + // RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient return if ( Build.VERSION.SDK_INT >= 26 && // openProxyFileDescriptor exists since Android 8.0 readAccess && // WebDAV doesn't support random write access natively @@ -483,7 +484,7 @@ class DavDocumentsProvider: DocumentsProvider() { val accessToken = credentialsStore.getCredentials(doc.mountId)?.authState?.accessToken val accessor = RandomAccessCallback.Wrapper(ourContext, client, url, doc.mimeType, fileInfo, signal) - storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.callback!!.workerHandler) + storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler) } else { val accessToken = credentialsStore.getCredentials(doc.mountId)?.authState?.accessToken diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt index dc6ee69e487010c5b7d1078d5d3a71183b901ff4..30eb9e380ec14a122ed9b37f7f20a1a3e56a201a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt @@ -15,7 +15,7 @@ import android.system.ErrnoException import android.system.OsConstants import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.HttpUtils import at.bitfire.dav4jvm.exception.DavException @@ -26,16 +26,32 @@ import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.ui.NotificationUtils import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible import at.bitfire.davdroid.util.DavUtils +import at.bitfire.davdroid.webdav.RandomAccessCallback.Wrapper.Companion.TIMEOUT_INTERVAL import at.bitfire.davdroid.webdav.cache.MemoryCache import at.bitfire.davdroid.webdav.cache.SegmentedCache import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.MediaType import org.apache.commons.io.FileUtils +import ru.nsk.kstatemachine.DefaultState +import ru.nsk.kstatemachine.Event +import ru.nsk.kstatemachine.FinalState +import ru.nsk.kstatemachine.StateMachine +import ru.nsk.kstatemachine.addFinalState +import ru.nsk.kstatemachine.addInitialState +import ru.nsk.kstatemachine.createStdLibStateMachine +import ru.nsk.kstatemachine.onEntry +import ru.nsk.kstatemachine.onExit +import ru.nsk.kstatemachine.onFinished +import ru.nsk.kstatemachine.processEventBlocking +import ru.nsk.kstatemachine.transition import java.io.InterruptedIOException import java.lang.ref.WeakReference import java.net.HttpURLConnection +import java.util.Timer +import java.util.TimerTask import java.util.logging.Level +import kotlin.concurrent.schedule typealias MemorySegmentCache = MemoryCache> @@ -63,7 +79,7 @@ class RandomAccessCallback private constructor( Logger.log.info("Creating memory cache") - val maxHeapSizeMB = ContextCompat.getSystemService(context, ActivityManager::class.java)!!.memoryClass + val maxHeapSizeMB = context.getSystemService()!!.memoryClass val cacheSize = maxHeapSizeMB * FileUtils.ONE_MB.toInt() / 2 val newCache = MemorySegmentCache(cacheSize) @@ -89,16 +105,11 @@ class RandomAccessCallback private constructor( .setOngoing(true) val notificationTag = url.toString() - private val workerThread = HandlerThread(javaClass.simpleName).apply { start() } - val workerHandler: Handler = Handler(workerThread.looper) - val memoryCache = getMemoryCache(context) val cache = SegmentedCache(PAGE_SIZE, this, memoryCache) - override fun onFsync() { - Logger.log.fine("onFsync") - } + override fun onFsync() { /* not used */ } override fun onGetSize(): Long { Logger.log.fine("onGetFileSize $url") @@ -140,9 +151,6 @@ class RandomAccessCallback private constructor( } override fun onRelease() { - workerThread.quit() - httpClient.close() - notificationManager.cancel(notificationTag, NotificationUtils.NOTIFY_WEBDAV_ACCESS) } @@ -204,35 +212,140 @@ class RandomAccessCallback private constructor( * a [Map]), but is not unregistered anymore. So it stays in the memory until the whole mount * is unloaded. See https://issuetracker.google.com/issues/208788568 * - * Use this wrapper to ensure that all memory is released as soon as [onRelease] is called. + * Use this wrapper to + * + * - ensure that all memory is released as soon as [onRelease] is called, + * - provide timeout functionality: [RandomAccessCallback] will be closed when not + * used for more than [TIMEOUT_INTERVAL] ms and re-created when necessary. + * + * @param httpClient HTTP client – [Wrapper] is responsible to close it */ class Wrapper( - context: Context, - httpClient: HttpClient, - url: HttpUrl, - mimeType: MediaType?, - headResponse: HeadResponse, - cancellationSignal: CancellationSignal?, - accessToken: String? = null + val context: Context, + val httpClient: HttpClient, + val url: HttpUrl, + val mimeType: MediaType?, + val headResponse: HeadResponse, + val cancellationSignal: CancellationSignal? ): ProxyFileDescriptorCallback() { - var callback: RandomAccessCallback? = RandomAccessCallback(context, httpClient, url, mimeType, headResponse, cancellationSignal) + companion object { + val TIMEOUT_INTERVAL = 15000L + } + + sealed class Events { + object Transfer : Event + object NowIdle : Event + object GoStandby : Event + object Close : Event + } + sealed class States : DefaultState() { + object Active: States() { + object Transferring: States() + object Idle: States() + } + object Standby: States() + object Closed: States(), FinalState + } + val machine = createStdLibStateMachine { + addInitialState(States.Active) { + onEntry { + _callback = RandomAccessCallback(context, httpClient, url, mimeType, headResponse, cancellationSignal) + } + onExit { + _callback?.onRelease() + _callback = null + } + + transition(targetState = States.Standby) + transition(targetState = States.Closed) + + // active has two nested states: transferring (I/O running) and idle (starts timeout timer) + addInitialState(States.Active.Idle) { + val timer: Timer = Timer(true) + var timeout: TimerTask? = null + + onEntry { + timeout = timer.schedule(TIMEOUT_INTERVAL) { + machine.processEventBlocking(Events.GoStandby) + } + } + onExit { + timeout?.cancel() + timeout = null + } + onFinished { + timer.cancel() + } + + transition(targetState = States.Active.Transferring) + } + + addState(States.Active.Transferring) { + transition(targetState = States.Active.Idle) + } + } + + addState(States.Standby) { + transition(targetState = States.Active.Transferring) + transition(targetState = States.Active.Idle) + transition(targetState = States.Closed) + } + + addFinalState(States.Closed) + onFinished { + shutdown() + } + + logger = StateMachine.Logger { message -> + Logger.log.fine(message()) + } + } + + private val workerThread = HandlerThread(javaClass.simpleName).apply { start() } + val workerHandler: Handler = Handler(workerThread.looper) + + private var _callback: RandomAccessCallback? = null + + fun requireCallback(block: (callback: RandomAccessCallback) -> T): T { + machine.processEventBlocking(Events.Transfer) + try { + return block(_callback ?: throw IllegalStateException()) + } finally { + machine.processEventBlocking(Events.NowIdle) + } + } + + + /// states /// - override fun onFsync() = - callback?.onFsync() ?: throw IllegalStateException("Must not be called after onRelease()") + @Synchronized + private fun shutdown() { + httpClient.close() + workerThread.quit() + } + + + /// delegating implementation of ProxyFileDescriptorCallback /// + + @Synchronized + override fun onFsync() { /* not used */ } + @Synchronized override fun onGetSize() = - callback?.onGetSize() ?: throw IllegalStateException("Must not be called after onRelease()") + requireCallback { it.onGetSize() } + @Synchronized override fun onRead(offset: Long, size: Int, data: ByteArray) = - callback?.onRead(offset, size, data) ?: throw IllegalStateException("Must not be called after onRelease()") + requireCallback { it.onRead(offset, size, data) } + @Synchronized override fun onWrite(offset: Long, size: Int, data: ByteArray) = - callback?.onWrite(offset, size, data) ?: throw IllegalStateException("Must not be called after onRelease()") + requireCallback { it.onWrite(offset, size, data) } + @Synchronized override fun onRelease() { - callback?.onRelease() - callback = null + machine.processEventBlocking(Events.Close) } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt index 7b311a5c2cb2580e059ff4b37f098e0e0f7f5e26..006b739d18fddd0785e8b474a60bb85f823914d2 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt @@ -28,6 +28,9 @@ import java.io.IOException import java.util.logging.Level import kotlin.concurrent.thread +/** + * @param client HTTP client– [StreamingFileDescriptor] is responsible to close it + */ class StreamingFileDescriptor( val context: Context, val client: HttpClient, @@ -74,6 +77,8 @@ class StreamingFileDescriptor( } catch (e: Exception) { Logger.log.log(Level.INFO, "Couldn't serve file (not necessesarily an error)", e) writeFd.closeWithError(e.message) + } finally { + client.close() } try { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/ThumbnailCache.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/ThumbnailCache.kt index cb8f4c1396c8ab3253a2973999c9dc85fbdd1364..d456aaeebcfbc45c68105338d5598139019bb555 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/ThumbnailCache.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/ThumbnailCache.kt @@ -9,14 +9,15 @@ import android.graphics.Point import android.os.Build import android.os.storage.StorageManager import androidx.annotation.WorkerThread -import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import at.bitfire.davdroid.db.WebDavDocument import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.webdav.cache.CacheUtils import at.bitfire.davdroid.webdav.cache.DiskCache import org.apache.commons.io.FileUtils import java.io.File -import java.util.* +import java.util.LinkedList +import java.util.UUID @WorkerThread class ThumbnailCache(context: Context) { @@ -24,7 +25,7 @@ class ThumbnailCache(context: Context) { val cache: DiskCache init { - val storageManager = ContextCompat.getSystemService(context, StorageManager::class.java)!! + val storageManager = context.getSystemService()!! val cacheDir = File(context.cacheDir, "webdav/thumbnail") val maxBytes = if (Build.VERSION.SDK_INT >= 26) storageManager.getCacheQuotaBytes(storageManager.getUuidForPath(cacheDir)) / 2 diff --git a/app/src/main/res/drawable/ic_account_circle_white.xml b/app/src/main/res/drawable/ic_account_circle_white.xml index 77b0bb9543b65f4caf899dba807c56403a9b917d..653c07eaad336749c885335338a6f399d25f93ba 100644 --- a/app/src/main/res/drawable/ic_account_circle_white.xml +++ b/app/src/main/res/drawable/ic_account_circle_white.xml @@ -1,5 +1,9 @@ - - + + diff --git a/app/src/main/res/drawable/ic_cloud_off.xml b/app/src/main/res/drawable/ic_cloud_off.xml index e795748c53531d4774acee5ea8d34a67455c80be..ae499bcd2cbf66a317c7b1db9528f20d4c8fe007 100644 --- a/app/src/main/res/drawable/ic_cloud_off.xml +++ b/app/src/main/res/drawable/ic_cloud_off.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_datasaver_on.xml b/app/src/main/res/drawable/ic_datasaver_on.xml index 1b55f9611c398961b6f60bdad1303273786c5b2f..59f500cc8e0fa3d27b552a34d70321a6221d0044 100644 --- a/app/src/main/res/drawable/ic_datasaver_on.xml +++ b/app/src/main/res/drawable/ic_datasaver_on.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml index 07f2e7d2b1f2e0a724f1dd7f9db6e895cb893384..993f0249b2eb807ac8e32dbfdd7fe98298dfe38a 100644 --- a/app/src/main/res/drawable/ic_done.xml +++ b/app/src/main/res/drawable/ic_done.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_error.xml b/app/src/main/res/drawable/ic_error.xml index a375cc26bf1f3090439b0845bf6edabc3c39b0c4..4712db09867ecda476e489c8f9b9712d45f225ce 100644 --- a/app/src/main/res/drawable/ic_error.xml +++ b/app/src/main/res/drawable/ic_error.xml @@ -12,6 +12,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_folder_refresh_outline.xml b/app/src/main/res/drawable/ic_folder_refresh_outline.xml index b72d56709c8d218aa1fd75db199da0cbb99de539..0610614426982f1976666cf1a3c6600928505f8e 100644 --- a/app/src/main/res/drawable/ic_folder_refresh_outline.xml +++ b/app/src/main/res/drawable/ic_folder_refresh_outline.xml @@ -1 +1,10 @@ - \ No newline at end of file + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notifications.xml b/app/src/main/res/drawable/ic_notifications.xml index 1bdb070b0018f3af27de5ee02d8e5ca432d33a4a..cac29078b3e6b76b7810d03dbe97b5ce4aedc65a 100644 --- a/app/src/main/res/drawable/ic_notifications.xml +++ b/app/src/main/res/drawable/ic_notifications.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_notifications_off.xml b/app/src/main/res/drawable/ic_notifications_off.xml index daa24d19b5f368d712f6c9e257bab4ea5dc66137..dc5cdff5cc31e101fb592b6f9e8e7269fe0bebcf 100644 --- a/app/src/main/res/drawable/ic_notifications_off.xml +++ b/app/src/main/res/drawable/ic_notifications_off.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_remove.xml b/app/src/main/res/drawable/ic_remove.xml index 8561a3390aa0545cb9b32fc5100ff5fd289cc5e5..868b390be4129b8546518fc95cc1e3cee670c8d1 100644 --- a/app/src/main/res/drawable/ic_remove.xml +++ b/app/src/main/res/drawable/ic_remove.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_sd_card_notify.xml b/app/src/main/res/drawable/ic_sd_card_notify.xml index 5e55c985dc3eb2739637beef247a0ab3714adac4..c0481aef5920c3efe559fe355b85c1f16e82bb68 100644 --- a/app/src/main/res/drawable/ic_sd_card_notify.xml +++ b/app/src/main/res/drawable/ic_sd_card_notify.xml @@ -6,7 +6,7 @@ diff --git a/app/src/main/res/drawable/ic_storage.xml b/app/src/main/res/drawable/ic_storage.xml index 9e28a8a14b53d791de4a9cdf960c52809e1ad162..e8fd649375a48b95d530eb1d7875c808412d22b0 100644 --- a/app/src/main/res/drawable/ic_storage.xml +++ b/app/src/main/res/drawable/ic_storage.xml @@ -1,6 +1,10 @@ - - + android:viewportWidth="24" + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_sync_action.xml b/app/src/main/res/drawable/ic_sync_action.xml index 6b9cd3cf273227e7b72447c66c840ff920a03863..5c2c18643a52f62d3ecb6b16a5923d32a91d1eb9 100644 --- a/app/src/main/res/drawable/ic_sync_action.xml +++ b/app/src/main/res/drawable/ic_sync_action.xml @@ -1,6 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_sync_problem_notify.xml b/app/src/main/res/drawable/ic_sync_problem_notify.xml index d3c1906efe5d96bb788486cbf6bd5e5485146fb1..2af03580aaf6511fc6935f1c60a85722d6d6d2b9 100644 --- a/app/src/main/res/drawable/ic_sync_problem_notify.xml +++ b/app/src/main/res/drawable/ic_sync_problem_notify.xml @@ -6,7 +6,7 @@ diff --git a/app/src/main/res/drawable/ic_sync_shortcut.xml b/app/src/main/res/drawable/ic_sync_shortcut.xml index 6873205461c08d044dfa76fde84b38bee7032d90..ab8d98c0abb59b92a325ba27fdf30bd2f50c03f6 100644 --- a/app/src/main/res/drawable/ic_sync_shortcut.xml +++ b/app/src/main/res/drawable/ic_sync_shortcut.xml @@ -4,6 +4,6 @@ android:viewportHeight="24.0" android:viewportWidth="24.0"> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_warning_notify.xml b/app/src/main/res/drawable/ic_warning_notify.xml index f6ee92b30a60670dbe8752f1f037739cd2f8175a..0b592e5e57b32beb5073245963f9f290fc195aaa 100644 --- a/app/src/main/res/drawable/ic_warning_notify.xml +++ b/app/src/main/res/drawable/ic_warning_notify.xml @@ -6,7 +6,7 @@ diff --git a/app/src/main/res/drawable/intro_logo_background.xml b/app/src/main/res/drawable/intro_logo_background.xml index b84f013b92d529a26cd84367351a3d9f06f57c55..882b1331a9a1c58a09f7e62fcfee3086df647648 100644 --- a/app/src/main/res/drawable/intro_logo_background.xml +++ b/app/src/main/res/drawable/intro_logo_background.xml @@ -1,6 +1,11 @@ - - + + diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 8845b6c7e3d5972c200904b41313e45cd343920b..b34925fd2f02c0f7bdbcc5f8ae62ce2d376dee5c 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -46,7 +46,6 @@ android:layout_height="wrap_content" android:layout_gravity="top|end" android:layout_margin="@dimen/fab_margin" - app:backgroundTint="@android:color/white" app:tint="@color/grey900" app:srcCompat="@drawable/ic_folder_refresh_outline" app:layout_anchor="@id/sync" diff --git a/app/src/main/res/layout/login_credentials_fragment.xml b/app/src/main/res/layout/login_credentials_fragment.xml index f2571b422435cc3daf6c2ab350c1a24c78c2bda3..c4f29f91ce590c16cc484e39f73a66566ae9c797 100644 --- a/app/src/main/res/layout/login_credentials_fragment.xml +++ b/app/src/main/res/layout/login_credentials_fragment.xml @@ -325,17 +325,32 @@ + + + + diff --git a/app/src/main/res/menu/activity_about.xml b/app/src/main/res/menu/activity_about.xml index 74d68b07e6c5690d544cdcde28cf5b763785b9d2..9a6fa95300df9f9480dfe936825a4b0b8a29709e 100644 --- a/app/src/main/res/menu/activity_about.xml +++ b/app/src/main/res/menu/activity_about.xml @@ -3,9 +3,9 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + app:showAsAction="ifRoom" /> \ No newline at end of file diff --git a/app/src/main/res/menu/activity_account.xml b/app/src/main/res/menu/activity_account.xml index 3ad391ccbdc92ec7870b7107842acbe17c090d71..c64e8cfd54cfa04757229837603810091a106a12 100644 --- a/app/src/main/res/menu/activity_account.xml +++ b/app/src/main/res/menu/activity_account.xml @@ -7,17 +7,14 @@ diff --git a/app/src/main/res/menu/activity_accounts.xml b/app/src/main/res/menu/activity_accounts.xml index f173669d3fb09dad9ad5470acfb75a6737dee031..05a0244f5a51443dcab2ff89e8f6e53202240a92 100644 --- a/app/src/main/res/menu/activity_accounts.xml +++ b/app/src/main/res/menu/activity_accounts.xml @@ -5,7 +5,6 @@ \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 49a0cb4f9871558e107b45ebd6278afc1b695177..b37579cd849f19a76e85e48f71b51a4a28c38244 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -505,4 +505,14 @@ Anmeldefehler (die maximalen Versuche wurden erreicht) Konten verwalten VPN zählt als Internetverbindung + Nextcloud + Mit Nextcloud anmelden + Anmeldevorgang + Dadurch wird der Nextcloud-Anmeldevorgang in einem Webbrowser gestartet. + Nextcloud-Serveradresse + Anmeldung + Login-URL konnte nicht abgerufen werden + Anmeldedaten konnten nicht abgerufen werden + VPN erfordert zugrundeliegendes Internet + VPN ohne zugrundeliegende überprüfte Internetverbindung reicht für Synchronisierung nicht aus (empfohlen) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8c788d7cd5ed6c8104365021434f544d8b766c51..7e78b05fa2b2d9c10573c3f2b0f8c4ad0d5a9edc 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -488,4 +488,13 @@ Point de montage WebDAV %s trop vieux + Google Contacts / Agenda + Merci de vous référer à la page \"Testé avec Google\" pour des informations à jour. + Vous risquez de rencontrer des avertissements inattendus et/ou vous devez créer votre propre ID client. + Compte Google + Se connecter avec Google + N\'a pas pu obtenir le code d\'autorisation + nom d\'utilisateur + Dernièrement synchronisé : + Jamais synchronisé diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 5f9af1998f8e4347f20bff67d35b08d6d5c4ae07..2424efec49f14533b5f5dfdcc6e6d57e924e3104 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -476,4 +476,12 @@ Conectividade VPN Requerida conexión non-VPN (recomendado) VPN conta como conexión a internet + Nextcloud + Acceder con Nextcloud + Acceso Asistido + Accederás usando Nextcloud nun navegador Web. + Enderezo do servidor Nextcloud + Acceder + Non se obtivo o URL de acceso + Non se obtiveron os datos de acceso diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 491969fb481acc58e500d4d36606ba255e72fe92..472d2d3d293d2e07fe2c27c51c3114ec514c7607 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -435,4 +435,12 @@ アカウントマネージャー: 接続セキュリティ アカウントマネージャーは、未知の証明書を検出しました。それを信頼しますか? + Nextcloud + Nextcloud でログイン + ログインフロー + ウェブブラウザーでのログインフローが開始されます。 + Nextcloud サーバーアドレス + サインイン + ログイン URL を入手できませんでした + ログイン情報を入手できませんでした diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 24f40d44089cf0c5e9ea50e56630252a8337c0e8..21f26686c8661a96c618f4429394334655f28d81 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -502,4 +502,12 @@ VPN-подключение Подключение без VPN (рекомендуется) VPN считается подключением к интернету + Nextcloud + Войти через Nextcloud + Процесс авторизации + Это запустит процесс авторизации в Nextcloud в браузере. + Адрес сервера Nextcloud + Войти + Не удалось получить URL для авторизации + Не удалось получить данные для авторизации diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8588fa9ca15d89cfcb27b7f1b60f3b52140eb5aa..cc397f498d068466b12f50781e4e83621f23a075 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -268,6 +268,14 @@ 隐私政策 。]]> Google API 服务用户数据政策,包括有限使用的要求。]]> 无法获得身份验证码 + Nextcloud + 用 Nextcloud 登录 + 登录流程 + 这会在网页浏览器中开启 Nextcloud 登录流程 + Nextcloud 服务器地址 + 登录 + 无法获取登录 URL + 无法获得登陆数据 正在配置 正在与服务器通信,请稍等… 找不到 CalDAV 或 CardDAV 服务。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86d9dfe1cf83e1003cebc677bd1fd7d7af0854ab..2f0de73467f5bceeec749e8b747d9f01bc84fb65 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -322,6 +322,14 @@ Privacy policy for details.]]> Google API Services User Data Policy, including the Limited Use requirements.]]> Couldn\'t obtain authorization code + Nextcloud + Login with Nextcloud + Login Flow + This will start the Nextcloud Login Flow in a Web browser. + Nextcloud server address + Sign in + Couldn\'t obtain login URL + Couldn\'t obtain login data Add account Please wait, adding account… diff --git a/app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt index 0a64cab91e6f04c4edfc87837cfc6980375530ab..fada8753819a91e435ac2635c36e729814c38c79 100644 --- a/app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt +++ b/app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt @@ -5,8 +5,11 @@ package at.bitfire.davdroid import at.bitfire.davdroid.util.DavUtils +import at.bitfire.davdroid.util.DavUtils.parent import okhttp3.HttpUrl.Companion.toHttpUrl -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.xbill.DNS.DClass import org.xbill.DNS.Name @@ -31,6 +34,21 @@ class DavUtilsTest { assertEquals("file.html", DavUtils.lastSegmentOfUrl((exampleURL + "dir/file.html").toHttpUrl())) } + @Test + fun testParent() { + // with trailing slash + assertEquals("http://example.com/1/2/".toHttpUrl(), "http://example.com/1/2/3/".toHttpUrl().parent()) + assertEquals("http://example.com/1/".toHttpUrl(), "http://example.com/1/2/".toHttpUrl().parent()) + assertEquals("http://example.com/".toHttpUrl(), "http://example.com/1/".toHttpUrl().parent()) + assertEquals("http://example.com/".toHttpUrl(), "http://example.com/".toHttpUrl().parent()) + + // without trailing slash + assertEquals("http://example.com/1/2/".toHttpUrl(), "http://example.com/1/2/3".toHttpUrl().parent()) + assertEquals("http://example.com/1/".toHttpUrl(), "http://example.com/1/2".toHttpUrl().parent()) + assertEquals("http://example.com/".toHttpUrl(), "http://example.com/1".toHttpUrl().parent()) + assertEquals("http://example.com/".toHttpUrl(), "http://example.com".toHttpUrl().parent()) + } + @Test fun testSelectSRVRecord() { assertNull(DavUtils.selectSRVRecord(emptyArray())) diff --git a/build.gradle b/build.gradle index d21a5df7b069c1ff1319ceb34bcfca009bb5d897..de2cdb3e97d09377710b39041ba2c066b3f61803 100644 --- a/build.gradle +++ b/build.gradle @@ -10,16 +10,16 @@ buildscript { hilt: '2.48.1', kotlin: '1.9.10', // keep in sync with * app/build.gradle composeOptions.kotlinCompilerExtensionVersion // * com.google.devtools.ksp at the end of this file - okhttp: '4.11.0', + okhttp: '4.12.0', room: '2.5.2', - workManager: '2.8.1', - // latest Apache Commons versions that don't require Java 8 (Android 7) - commonsCollections: '4.2', - commonsLang: '3.8.1', - commonsText: '1.3', + workManager: '2.9.0-rc01', + // Apache Commons versions + commonsCollections: '4.4', + commonsLang: '3.13.0', + commonsText: '1.10.0', // own libraries cert4android: '2bb3898', - dav4jvm: 'da94a8b', + dav4jvm: '1ed89c1', ical4android: '1.1.2', vcard4android: '1.1.2' ]