diff --git a/.travis.yml b/.travis.yml index 78486a9b5..c51bb14be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,15 +9,18 @@ android: - tools - platform-tools - tools + - build-tools-26.0.2 - build-tools-27.0.1 + - build-tools-28.0.3 - android-24 - - android-25 - android-26 + - extra - extra-android-m2repository - sys-img-armeabi-v7a-android-24 # Emulator Management: Create, Start and Wait before_script: + - android list targets - echo no | android create avd --force -n test --target android-24 --abi armeabi-v7a - QEMU_AUDIO_DRV=none emulator -avd test -no-window & - android-wait-for-emulator @@ -26,3 +29,6 @@ before_script: script: - android list target - ./gradlew check connectedAndroidTest + +after_script: + - cat /home/travis/build/dmfs/opentasks/opentasks/build/reports/lint-results.xml \ No newline at end of file diff --git a/build.gradle b/build.gradle index 0bb26ada2..93e317a99 100644 --- a/build.gradle +++ b/build.gradle @@ -2,17 +2,19 @@ buildscript { repositories { jcenter() + mavenCentral() google() } dependencies { - classpath 'com.android.tools.build:gradle:3.0.1' + classpath 'com.android.tools.build:gradle:3.2.1' + classpath("com.github.triplet.gradle:play-publisher:2.0.0-rc1") } } def gitVersion = { -> def stdout = new ByteArrayOutputStream() exec { - commandLine 'git', 'describe', '--tags', '--always' + commandLine 'git', 'describe', '--tags', '--always', '--dirty' standardOutput = stdout } return stdout.toString().trim() diff --git a/dependencies.gradle b/dependencies.gradle index 3cb9e2806..670f36adb 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -1,6 +1,6 @@ -def support_lib_version = '25.4.0' -def jems_version = '1.15' -def contentpal_version = '9b087b2' // 9b087b2 -> 2017-12-12 +def support_lib_version = '26.1.0' +def jems_version = '1.18' +def contentpal_version = 'a7fbc62eef' // a7fbc62eef -> 2018-08-19 def support_test_runner_version = '0.5' ext.deps = [ diff --git a/documentation/concepts/notifications/notification-concept.adoc b/documentation/concepts/notifications/notification-concept.adoc index 319a8311a..fe2f59144 100644 --- a/documentation/concepts/notifications/notification-concept.adoc +++ b/documentation/concepts/notifications/notification-concept.adoc @@ -29,9 +29,54 @@ https://developer.android.com/guide/topics/ui/notifiers/notifications[Notificati We want to use this opportunity to rework and improve notification handling in general. -== Triggers +== Definition -At present there are six reasons for the notifications to be created or updated: +By "task notifification" we mean a visual or aural signal to inform the user +about a certain task property or state transition, using Android's notification +framework. + + +== Requirements + +=== Legacy API level support + +A large amount of devices still uses Android API levels < 26 and its crucial to support older +Android versions. + +==== Legacy notification layout + +Notifications may look different on older devices than on newer ones. + +==== Notification settings + +On Android 8+ the system provides the UI to change notification sound, heads up display and +vibration settings. On older Android versions we need to maintain our own settings page. + +==== Foreground service management + +On Android 8+ a foreground service must explicitly enter foreground mode by calling +`startForeground` right after the the service has been started. + +=== Group support + +Certain types of notifications should be grouped if supported by the device. + +TODO: The details still need to be worked out. + +=== Efficiency + +Notifications should not be updated more often than necessary and should reduce database +queries to the bare minimum. Especially since any task update could trigger or update +a notification it's essential to keep the overhead low in order to now slow down provider +updates. + + +== Notification sources + +A notification source means a condition which is based on one or multiple +task properties and which results in a notification when it occurs. + +At present there are six sources for notifications: A task starts:: @@ -68,39 +113,55 @@ A task geo fence entered:: A task may have a location based reminder. If the location is approached a notification should be shown. -== Requirements -=== Legacy API level support +In later versions we may support other sources like state transitions. -A large amount of devices still uses Android API levels < 26 and its crucial to support older -Android versions. +== Notification triggers -==== Legacy notification layout +A notification trigger means an event which results in an update of the currently shown +notifications. -Notifications may look different on older devices than on newer ones. +Currently we have three: -==== Notification settings +A task provider operation:: -On Android 8+ the system provides the UI to change notification sound, heads up display and -vibration settings. On older Android versions we need to maintain our own settings page. + We don't distinguish between UI operations and sync adapter operations. We should + check if a "notification relevant" field has been updated. This way we can avoid + notification updates in case only a field like "description" has been changed. -==== Foreground service management + The following fields are considered notification relevant: -On Android 8+ a foreground service must explicitly enter foreground mode by calling -`startForeground` right after the the service has been started. + * title + * dtstart + * due + * status + * pinned + * extended property for alarm + * extended property for geo fences + * [anything potentially showing in the notification, see open issues **Notification texts**] -=== Group support + In order to save precious resources we only update task notifications which + need to be updated. -Certain types of notifications should be grouped if supported by the device. -TODO: The details still need to be worked out. +The system has booted:: -=== Efficiency + After booting the system no notifications are shown and we have to restore them. + Ideally we also restore start and due notifications which have not been + dismissed yet. + +The app has been updated:: + + When the app is updated, it is first stopped, causing existing notifications to be + removed. In that case we also have to restore the notifications. + +In addition there is one future source planned: + +A geo fence has been crossed:: + + When the app is updated, it is first stopped, causing exiting notifications to be + removed. In that case we also have to restore the notifications. -Notifications should not be updated more often than necessary and should reduce database -queries to the bare minimum. Especially since any task update could trigger or update -a notification it's essential to keep the overhead low in order to now slow down provider -updates. == Notification types @@ -120,7 +181,7 @@ By default, this channel has a HIGH priority to show a heads up notification. Pin board:: This channel contains notifications for pinned tasks. -By default, this channel has a HIGH priority to make a sound but not +By default, this channel has a MEDIUM priority to make a sound but not show a heads up notification. == State diagram diff --git a/gradle.properties b/gradle.properties index b7460b615..7cb4a36c9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -COMPILE_SDK_VERSION=25 +COMPILE_SDK_VERSION=26 BUILD_TOOLS_VERSION=27.0.1 MIN_SDK_VERSION=15 -TARGET_SDK_VERSION=25 +TARGET_SDK_VERSION=26 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 57c7d2d22..23e41a2e1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Thu Nov 08 02:52:41 CET 2018 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip diff --git a/opentasks-contract/build.gradle b/opentasks-contract/build.gradle index f3a3d674c..65ac00f53 100644 --- a/opentasks-contract/build.gradle +++ b/opentasks-contract/build.gradle @@ -2,7 +2,6 @@ apply plugin: 'com.android.library' android { compileSdkVersion COMPILE_SDK_VERSION.toInteger() - buildToolsVersion BUILD_TOOLS_VERSION defaultConfig { minSdkVersion MIN_SDK_VERSION.toInteger() diff --git a/opentasks-provider/build.gradle b/opentasks-provider/build.gradle index a5d4c88c4..7ca1e2da0 100644 --- a/opentasks-provider/build.gradle +++ b/opentasks-provider/build.gradle @@ -2,7 +2,6 @@ apply plugin: 'com.android.library' android { compileSdkVersion COMPILE_SDK_VERSION.toInteger() - buildToolsVersion BUILD_TOOLS_VERSION defaultConfig { minSdkVersion MIN_SDK_VERSION.toInteger() diff --git a/opentasks-provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java b/opentasks-provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java index 60c4442bd..58e306480 100644 --- a/opentasks-provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java +++ b/opentasks-provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java @@ -16,6 +16,7 @@ package org.dmfs.ngrams; +import java.util.Collections; import java.util.HashSet; import java.util.Locale; import java.util.Set; @@ -112,28 +113,15 @@ public NGramGenerator setLocale(Locale locale) * @param data * The String to analyze. * - * @return A {@link Set} containing all N-grams. + * @return The {@link Set} containing the N-grams. */ public Set getNgrams(String data) { - Set result = new HashSet(128); - - return getNgrams(result, data); - } - + if (data == null) + { + return Collections.emptySet(); + } - /** - * Get all N-grams contained in the given String. - * - * @param set - * The set to add all the N-grams to, or null to create a new set. - * @param data - * The String to analyze. - * - * @return The {@link Set} containing the N-grams. - */ - public Set getNgrams(Set set, String data) - { if (mAllLowercase) { data = data.toLowerCase(mLocale); @@ -141,10 +129,7 @@ public Set getNgrams(Set set, String data) String[] words = mReturnNumbers ? SEPARATOR_PATTERN.split(data) : SEPARATOR_PATTERN_NO_NUMBERS.split(data); - if (set == null) - { - set = new HashSet(128); - } + Set set = new HashSet(128); for (String word : words) { diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java index 2022c7fed..f45287e6a 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java @@ -26,6 +26,7 @@ import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.util.Log; @@ -183,6 +184,11 @@ private void sendBroadcast(Context context, String action, Uri uri, DateTime dat intent.putExtra(TaskContract.EXTRA_TASK_TIMEZONE, datetime.getTimeZone().getID()); } intent.putExtra(TaskContract.EXTRA_TASK_TITLE, title); + if (Build.VERSION.SDK_INT >= 26) + { + // for now only notify our own package + intent.setPackage(context.getPackageName()); + } context.sendBroadcast(intent); } }), diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java index a27516c4f..62a659c6e 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java @@ -24,12 +24,16 @@ import org.dmfs.ngrams.NGramGenerator; import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.utils.Chunked; import org.dmfs.tasks.contract.TaskContract; import org.dmfs.tasks.contract.TaskContract.Properties; import org.dmfs.tasks.contract.TaskContract.TaskColumns; import org.dmfs.tasks.contract.TaskContract.Tasks; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; @@ -41,6 +45,11 @@ */ public class FTSDatabaseHelper { + /** + * We search the ngram table in chunks of 500. This should be good enough for an average task but still well below + * the SQLITE expression length limit and the variable count limit. + */ + private final static int NGRAM_SEARCH_CHUNK_SIZE = 500; private final static float SEARCH_RESULTS_MIN_SCORE = 0.33f; @@ -54,6 +63,12 @@ public class FTSDatabaseHelper */ private final static NGramGenerator TETRAGRAM_GENERATOR = new NGramGenerator(4, 3 /* shorter words are fully covered by trigrams */).setAddSpaceInFront( true); + private static final String PROPERTY_NGRAM_SELECTION = String.format("%s = ? AND %s = ? AND %s = ?", FTSContentColumns.TASK_ID, FTSContentColumns.TYPE, + FTSContentColumns.PROPERTY_ID); + private static final String NON_PROPERTY_NGRAM_SELECTION = String.format("%s = ? AND %s = ? AND %s is null", FTSContentColumns.TASK_ID, + FTSContentColumns.TYPE, + FTSContentColumns.PROPERTY_ID); + private static final String[] NGRAM_SYNC_COLUMNS = { "_rowid_", FTSContentColumns.NGRAM_ID }; /** @@ -324,67 +339,89 @@ public static void updatePropertyFTSEntry(SQLiteDatabase db, long taskId, long p /** - * Inserts NGrams into the NGram database. + * Returns the IDs of each of the provided ngrams, creating them in th database if necessary. * * @param db * A writable {@link SQLiteDatabase}. * @param ngrams - * The set of NGrams. + * The NGrams. * * @return The ids of the ngrams in the given set. */ - private static Set insertNGrams(SQLiteDatabase db, Set ngrams) + private static Set ngramIds(SQLiteDatabase db, Set ngrams) { - Set nGramIds = new HashSet(ngrams.size()); - ContentValues values = new ContentValues(1); - for (String ngram : ngrams) + if (ngrams.size() == 0) { - values.put(NGramColumns.TEXT, ngram); - long nGramId = db.insertWithOnConflict(FTS_NGRAM_TABLE, null, values, SQLiteDatabase.CONFLICT_IGNORE); - if (nGramId == -1) + return Collections.emptySet(); + } + + Set missingNgrams = new HashSet<>(ngrams); + Set ngramIds = new HashSet<>(ngrams.size() * 2); + + for (Iterable chunk : new Chunked<>(NGRAM_SEARCH_CHUNK_SIZE, ngrams)) + { + // build selection and arguments for each chunk + // we can't do this in a single query because the length of sql statement and number of arguments is limited. + + StringBuilder selection = new StringBuilder(NGramColumns.TEXT); + selection.append(" in ("); + boolean first = true; + List arguments = new ArrayList<>(NGRAM_SEARCH_CHUNK_SIZE); + for (String ngram : chunk) { - // the docs say insertWithOnConflict returns the existing row id when CONFLICT_IGNORE is specified an the values conflict with an existing - // column, however, that doesn't seem to work reliably, so we when for an error condition and get the row id ourselves - Cursor c = db - .query(FTS_NGRAM_TABLE, new String[] { NGramColumns.NGRAM_ID }, NGramColumns.TEXT + "=?", new String[] { ngram }, null, null, null); - try + if (first) { - if (c.moveToFirst()) - { - nGramId = c.getLong(0); - } + first = false; } - finally + else { - c.close(); + selection.append(","); } + selection.append("?"); + arguments.add(ngram); + } + selection.append(" )"); + try (Cursor c = db.query(FTS_NGRAM_TABLE, new String[] { NGramColumns.NGRAM_ID, NGramColumns.TEXT }, selection.toString(), + arguments.toArray(new String[0]), null, null, null)) + { + while (c.moveToNext()) + { + // remove the ngrams we already have in the table + missingNgrams.remove(c.getString(1)); + // remember its id + ngramIds.add(c.getLong(0)); + } } - nGramIds.add(nGramId); } - return nGramIds; + + ContentValues values = new ContentValues(1); + + // now insert the missing ngrams and store their ids + for (String ngram : missingNgrams) + { + values.put(NGramColumns.TEXT, ngram); + ngramIds.add(db.insert(FTS_NGRAM_TABLE, null, values)); + } + return ngramIds; } private static void updateEntry(SQLiteDatabase db, long taskId, long propertyId, int type, String searchableText) { - // delete existing NGram relations - deleteNGramRelations(db, taskId, propertyId, type); - - if (searchableText != null && searchableText.length() > 0) - { - // generate nGrams - Set propertyNgrams = TRIGRAM_GENERATOR.getNgrams(searchableText); + // generate nGrams + Set propertyNgrams = TRIGRAM_GENERATOR.getNgrams(searchableText); + propertyNgrams.addAll(TETRAGRAM_GENERATOR.getNgrams(searchableText)); - TETRAGRAM_GENERATOR.getNgrams(propertyNgrams, searchableText); + // get an ID for each of the Ngrams. + Set ngramIds = ngramIds(db, propertyNgrams); - // insert ngrams - Set propertyNgramIds = insertNGrams(db, propertyNgrams); + // unlink unused ngrams from the task and get the missing ones we have to link to the tak + Set missing = syncNgrams(db, taskId, propertyId, type, ngramIds); - // insert ngram relations - insertNGramRelations(db, propertyNgramIds, taskId, propertyId, type); - } + // insert ngram relations for all new ngrams + addNgrams(db, missing, taskId, propertyId, type); } @@ -400,7 +437,7 @@ private static void updateEntry(SQLiteDatabase db, long taskId, long propertyId, * @param propertyId * The row id of the property. */ - private static void insertNGramRelations(SQLiteDatabase db, Set ngramIds, long taskId, Long propertyId, int contentType) + private static void addNgrams(SQLiteDatabase db, Set ngramIds, long taskId, Long propertyId, int contentType) { ContentValues values = new ContentValues(4); for (Long ngramId : ngramIds) @@ -416,14 +453,14 @@ private static void insertNGramRelations(SQLiteDatabase db, Set ngramIds, { values.putNull(FTSContentColumns.PROPERTY_ID); } - db.insertWithOnConflict(FTS_CONTENT_TABLE, null, values, SQLiteDatabase.CONFLICT_IGNORE); + db.insert(FTS_CONTENT_TABLE, null, values); } } /** - * Deletes the NGram relations of a task + * Synchronizes the NGram relations of a task * * @param db * The writable {@link SQLiteDatabase}. @@ -433,18 +470,46 @@ private static void insertNGramRelations(SQLiteDatabase db, Set ngramIds, * The property row id, ignored if contentType is not {@link SearchableTypes#PROPERTY}. * @param contentType * The {@link SearchableTypes} type. + * @param ngramsIds + * The set of ngrams ids which should be linked to the task * * @return The number of deleted relations. */ - private static int deleteNGramRelations(SQLiteDatabase db, long taskId, long propertyId, int contentType) + private static Set syncNgrams(SQLiteDatabase db, long taskId, long propertyId, int contentType, Set ngramsIds) { - StringBuilder whereClause = new StringBuilder(FTSContentColumns.TASK_ID).append(" = ").append(taskId); - whereClause.append(" AND ").append(FTSContentColumns.TYPE).append(" = ").append(contentType); - if (contentType == SearchableTypes.PROPERTY) + String selection; + String[] selectionArgs; + if (SearchableTypes.PROPERTY == contentType) + { + selection = PROPERTY_NGRAM_SELECTION; + selectionArgs = new String[] { String.valueOf(taskId), String.valueOf(contentType), String.valueOf(propertyId) }; + } + else + { + selection = NON_PROPERTY_NGRAM_SELECTION; + selectionArgs = new String[] { String.valueOf(taskId), String.valueOf(contentType) }; + } + + // In order to sync the ngrams, we go over each existing ngram and delete ngram relations not in the set of new ngrams + // Then we return the set of ngrams we didn't find + Set missing = new HashSet<>(ngramsIds); + try (Cursor c = db.query(FTS_CONTENT_TABLE, NGRAM_SYNC_COLUMNS, selection, selectionArgs, null, null, null)) { - whereClause.append(" AND ").append(FTSContentColumns.PROPERTY_ID).append(" = ").append(propertyId); + while (c.moveToNext()) + { + Long ngramId = c.getLong(1); + if (!ngramsIds.contains(ngramId)) + { + db.delete(FTS_CONTENT_TABLE, "_rowid_ = ?", new String[] { c.getString(0) }); + } + else + { + // this ngram wasn't missing + missing.remove(ngramId); + } + } } - return db.delete(FTS_CONTENT_TABLE, whereClause.toString(), null); + return missing; } @@ -484,7 +549,7 @@ public static Cursor getTaskSearchCursor(SQLiteDatabase db, String searchString, } Set ngrams = TRIGRAM_GENERATOR.getNgrams(searchString); - TETRAGRAM_GENERATOR.getNgrams(ngrams, searchString); + ngrams.addAll(TETRAGRAM_GENERATOR.getNgrams(searchString)); String[] queryArgs; diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/ProviderOperationsLog.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/ProviderOperationsLog.java deleted file mode 100644 index d04191963..000000000 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/ProviderOperationsLog.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -import android.net.Uri; -import android.os.Bundle; - -import org.dmfs.tasks.contract.TaskContract; - -import java.util.ArrayList; - - -/** - * A log to track all content provider operations. - * - * @author Marten Gajda - */ -public class ProviderOperationsLog -{ - private ArrayList mUris = new ArrayList(16); - - private ArrayList mOperations = new ArrayList(16); - - - /** - * Add an operation on the given {@link Uri} to the log. - * - * @param operation - * The {@link ProviderOperation} that was executed. - * @param uri - * The {@link Uri} that the operation was executed on. - */ - public void log(ProviderOperation operation, Uri uri) - { - synchronized (this) - { - mUris.add(uri); - mOperations.add(operation.ordinal()); - } - } - - - /** - * Adds the operations log to the given {@link Bundle}, creating one if the given bundle is null. - * - * @param bundle - * A {@link Bundle} or null. - * @param clearLog - * true to clear the log afterwards, false to keep it. - * - * @return The {@link Bundle} that was passed or created. - */ - public Bundle toBundle(Bundle bundle, boolean clearLog) - { - if (bundle == null) - { - bundle = new Bundle(2); - } - - synchronized (this) - { - bundle.putParcelableArrayList(TaskContract.EXTRA_OPERATIONS_URIS, mUris); - bundle.putIntegerArrayList(TaskContract.EXTRA_OPERATIONS, mOperations); - if (clearLog) - { - // we can't just clear the ArrayLists, because the Bundle keeps a reference to them - mUris = new ArrayList(16); - mOperations = new ArrayList(16); - } - } - return bundle; - } - - - /** - * Returns a new {@link Bundle} containing the log. - * - * @param clearLog - * true to clear the log afterwards, false to keep it. - * - * @return The {@link Bundle} that was created. - */ - public Bundle toBundle(boolean clearLog) - { - return toBundle(null, clearLog); - } - - - /** - * Returns whether any operations have been logged or not. - * - * @return true if this log is empty, false if it contains any logs of operations. - */ - public boolean isEmpty() - { - return mUris.size() == 0; - } -} \ No newline at end of file diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java index 7df6979d8..21c54ae0c 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java @@ -28,10 +28,14 @@ import android.net.Uri; import org.dmfs.iterables.SingletonIterable; -import org.dmfs.iterables.decorators.Flattened; +import org.dmfs.jems.fragile.Fragile; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.single.Single; +import org.dmfs.provider.tasks.utils.Profiled; import java.util.ArrayList; import java.util.HashSet; +import java.util.Locale; import java.util.Set; @@ -75,7 +79,7 @@ interface TransactionEndTask protected SQLiteContentProvider(Iterable transactionEndTasks) { // append a task to set the transaction to successful - mTransactionEndTasks = new Flattened<>(transactionEndTasks, new SingletonIterable(new SuccessfulTransactionEndTask())); + mTransactionEndTasks = new Joined<>(transactionEndTasks, new SingletonIterable<>(new SuccessfulTransactionEndTask())); } @@ -142,164 +146,175 @@ private boolean applyingBatch() @Override public Uri insert(Uri uri, ContentValues values) { - Uri result; - boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); - boolean applyingBatch = applyingBatch(); - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - if (!applyingBatch) + return new Profiled("Insert").run((Single) () -> { - db.beginTransaction(); - try + Uri result; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + if (!applyingBatch) { - result = insertInTransaction(db, uri, values, callerIsSyncAdapter); - endTransaction(db); + db.beginTransaction(); + try + { + result = insertInTransaction(db, uri, values, callerIsSyncAdapter); + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); } - finally + else { - db.endTransaction(); + result = insertInTransaction(db, uri, values, callerIsSyncAdapter); } - - onEndTransaction(callerIsSyncAdapter); - } - else - { - result = insertInTransaction(db, uri, values, callerIsSyncAdapter); - } - return result; + return result; + }); } @Override public int bulkInsert(Uri uri, ContentValues[] values) { - int numValues = values.length; - boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - db.beginTransaction(); - try - { - for (int i = 0; i < numValues; i++) - { - insertInTransaction(db, uri, values[i], callerIsSyncAdapter); - db.yieldIfContendedSafely(); - } - endTransaction(db); - } - finally - { - db.endTransaction(); - } - - onEndTransaction(callerIsSyncAdapter); - return numValues; - } - - - @Override - public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) - { - int count; - boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); - boolean applyingBatch = applyingBatch(); - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - if (!applyingBatch) + return new Profiled("BulkInsert").run((Single) () -> { + int numValues = values.length; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); db.beginTransaction(); try { - count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); + for (int i = 0; i < numValues; i++) + { + insertInTransaction(db, uri, values[i], callerIsSyncAdapter); + db.yieldIfContendedSafely(); + } endTransaction(db); } finally { db.endTransaction(); } - onEndTransaction(callerIsSyncAdapter); - } - else - { - count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); - } - - return count; + return numValues; + }); } @Override - public int delete(Uri uri, String selection, String[] selectionArgs) + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { - int count; - boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); - boolean applyingBatch = applyingBatch(); - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - if (!applyingBatch) + return new Profiled("Update").run((Single) () -> { - db.beginTransaction(); - try + int count; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + if (!applyingBatch) { - count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); - endTransaction(db); + db.beginTransaction(); + try + { + count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); } - finally + else { - db.endTransaction(); + count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); } - - onEndTransaction(callerIsSyncAdapter); - } - else - { - count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); - } - return count; + return count; + }); } @Override - public ContentProviderResult[] applyBatch(ArrayList operations) throws OperationApplicationException + public int delete(Uri uri, String selection, String[] selectionArgs) { - int ypCount = 0; - int opCount = 0; - boolean callerIsSyncAdapter = false; - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - db.beginTransaction(); - try + return new Profiled("Delete").run((Single) () -> { - mApplyingBatch.set(true); - final int numOperations = operations.size(); - final ContentProviderResult[] results = new ContentProviderResult[numOperations]; - for (int i = 0; i < numOperations; i++) + int count; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + if (!applyingBatch) { - if (++opCount >= MAX_OPERATIONS_PER_YIELD_POINT) + db.beginTransaction(); + try { - throw new OperationApplicationException("Too many content provider operations between yield points. " - + "The maximum number of operations per yield point is " + MAX_OPERATIONS_PER_YIELD_POINT, ypCount); + count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); + endTransaction(db); } - final ContentProviderOperation operation = operations.get(i); - if (!callerIsSyncAdapter && isCallerSyncAdapter(operation.getUri())) + finally { - callerIsSyncAdapter = true; + db.endTransaction(); } - if (i > 0 && operation.isYieldAllowed()) + onEndTransaction(callerIsSyncAdapter); + } + else + { + count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); + } + return count; + }); + } + + + @Override + public ContentProviderResult[] applyBatch(ArrayList operations) throws OperationApplicationException + { + return new Profiled(String.format(Locale.ENGLISH, "Batch of %d operations", operations.size())).run( + (Fragile) () -> { - opCount = 0; - if (db.yieldIfContendedSafely(SLEEP_AFTER_YIELD_DELAY)) + int ypCount = 0; + int opCount = 0; + boolean callerIsSyncAdapter = false; + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { - ypCount++; + mApplyingBatch.set(true); + final int numOperations = operations.size(); + final ContentProviderResult[] results = new ContentProviderResult[numOperations]; + for (int i = 0; i < numOperations; i++) + { + if (++opCount >= MAX_OPERATIONS_PER_YIELD_POINT) + { + throw new OperationApplicationException("Too many content provider operations between yield points. " + + "The maximum number of operations per yield point is " + MAX_OPERATIONS_PER_YIELD_POINT, ypCount); + } + final ContentProviderOperation operation = operations.get(i); + if (!callerIsSyncAdapter && isCallerSyncAdapter(operation.getUri())) + { + callerIsSyncAdapter = true; + } + if (i > 0 && operation.isYieldAllowed()) + { + opCount = 0; + if (db.yieldIfContendedSafely(SLEEP_AFTER_YIELD_DELAY)) + { + ypCount++; + } + } + results[i] = operation.apply(this, results, i); + } + endTransaction(db); + return results; } - } - results[i] = operation.apply(this, results, i); - } - endTransaction(db); - return results; - } - finally - { - mApplyingBatch.set(false); - db.endTransaction(); - onEndTransaction(callerIsSyncAdapter); - } + finally + { + mApplyingBatch.set(false); + db.endTransaction(); + onEndTransaction(callerIsSyncAdapter); + } + }); } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java index 481714174..27e7c8c15 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java @@ -32,9 +32,11 @@ import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.text.TextUtils; +import android.util.Log; import org.dmfs.iterables.EmptyIterable; import org.dmfs.provider.tasks.TaskDatabaseHelper.OnDatabaseOperationListener; @@ -75,8 +77,10 @@ import org.dmfs.tasks.contract.TaskContract.Tasks; import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; /** @@ -115,6 +119,7 @@ public final class TaskProvider extends SQLiteContentProvider implements OnAccou private static final int OPERATIONS = 100000; private final static Set TASK_LIST_SYNC_COLUMNS = new HashSet(Arrays.asList(TaskLists.SYNC_ADAPTER_COLUMNS)); + private static final String TAG = "TaskProvider"; /** * A list of {@link EntityProcessor}s to execute when doing operations on the instances table. @@ -147,9 +152,21 @@ public final class TaskProvider extends SQLiteContentProvider implements OnAccou Handler mAsyncHandler; /** - * An {@link ProviderOperationsLog} to track all changes within a transaction. + * Boolean to track if there are changes within a transaction. */ - private ProviderOperationsLog mOperationsLog = new ProviderOperationsLog(); + private boolean mChanged = false; + + /** + * This is a per transaction/thread flag which indicates whether new lists with an unknown account have been added. + * If this holds true at the end of a transaction a window should be shown to ask the user for access to that account. + */ + private ThreadLocal mStaleListCreated = new ThreadLocal<>(); + + /** + * The currently known accounts. This may be accessed from various threads, hence the AtomicReference. + * By statring with an empty set, we can always guarantee a non-null reference. + */ + private AtomicReference> mAccountCache = new AtomicReference<>(Collections.emptySet()); public TaskProvider() @@ -745,7 +762,7 @@ public int deleteInTransaction(final SQLiteDatabase db, Uri uri, String selectio final ListAdapter list = new CursorContentValuesListAdapter(ListAdapter._ID.getFrom(cursor), cursor, new ContentValues()); mListProcessorChain.delete(db, list, isSyncAdapter); - mOperationsLog.log(ProviderOperation.DELETE, list.uri(mAuthority)); + mChanged = true; count++; } } @@ -787,7 +804,7 @@ public int deleteInTransaction(final SQLiteDatabase db, Uri uri, String selectio mTaskProcessorChain.delete(db, task, isSyncAdapter); - mOperationsLog.log(ProviderOperation.DELETE, task.uri(mAuthority)); + mChanged = true; count++; } } @@ -906,11 +923,19 @@ public Uri insertInTransaction(final SQLiteDatabase db, Uri uri, final ContentVa list.set(ListAdapter.ACCOUNT_TYPE, accountType); mListProcessorChain.insert(db, list, isSyncAdapter); - mOperationsLog.log(ProviderOperation.INSERT, list.uri(mAuthority)); + mChanged = true; rowId = list.id(); result_uri = TaskContract.TaskLists.getContentUri(mAuthority); - + // if the account is unknown we need to ask the user + if (Build.VERSION.SDK_INT >= 26 && + !TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType) && + !mAccountCache.get().contains(new Account(accountName, accountType))) + { + // store the fact that we have an unknown account in this transaction + mStaleListCreated.set(true); + Log.d(TAG, String.format("List with unknown account %s inserted.", new Account(accountName, accountType))); + } break; } case TASKS: @@ -918,7 +943,7 @@ public Uri insertInTransaction(final SQLiteDatabase db, Uri uri, final ContentVa mTaskProcessorChain.insert(db, task, isSyncAdapter); - mOperationsLog.log(ProviderOperation.INSERT, task.uri(mAuthority)); + mChanged = true; rowId = task.id(); result_uri = TaskContract.Tasks.getContentUri(mAuthority); @@ -1044,7 +1069,7 @@ public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentVa final ListAdapter list = new CursorContentValuesListAdapter(listId, cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); mListProcessorChain.update(db, list, isSyncAdapter); - mOperationsLog.log(ProviderOperation.UPDATE, list.uri(mAuthority)); + mChanged = true; count++; } } @@ -1074,7 +1099,7 @@ public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentVa mTaskProcessorChain.update(db, task, isSyncAdapter); if (task.hasUpdates()) { - mOperationsLog.log(ProviderOperation.UPDATE, task.uri(mAuthority)); + mChanged = true; } count++; } @@ -1288,13 +1313,25 @@ protected void onEndTransaction(boolean callerIsSyncAdapter) { super.onEndTransaction(callerIsSyncAdapter); Intent providerChangedIntent = new Intent(Intent.ACTION_PROVIDER_CHANGED, TaskContract.getContentUri(mAuthority)); - if (!mOperationsLog.isEmpty()) + if (mChanged) { updateNotifications(); + mChanged = false; + } + if (Build.VERSION.SDK_INT >= 26) + { + // for now we only notify our own package + // we'll have to figure out how to do this correctly on Android 8+, e.g. how is it done by CalendarProvider and ContactsProvider + providerChangedIntent.setPackage(getContext().getPackageName()); } - // add the change log to the broadcast - providerChangedIntent.putExtras(mOperationsLog.toBundle(true)); getContext().sendBroadcast(providerChangedIntent); + + if (Boolean.TRUE.equals(mStaleListCreated.get())) + { + // notify UI about the stale lists, it's up the UI to deal with this, either by showing a notification or an instant popup. + Intent visbilityRequest = new Intent("org.dmfs.tasks.action.STALE_LIST_BROADCAST").setPackage(getContext().getPackageName()); + getContext().sendBroadcast(visbilityRequest); + } } @@ -1313,6 +1350,8 @@ public void onDatabaseCreated(SQLiteDatabase db) // notify listeners that the database has been created Intent dbInitializedIntent = new Intent(TaskContract.ACTION_DATABASE_INITIALIZED); dbInitializedIntent.setDataAndType(TaskContract.getContentUri(mAuthority), TaskContract.MIMETYPE_AUTHORITY); + // Android SDK 26 doesn't allow us to send implicit broadcasts, this particular brodcast is only for internal use, so just make it explicit by setting our package name + dbInitializedIntent.setPackage(getContext().getPackageName()); getContext().sendBroadcast(dbInitializedIntent); } @@ -1322,14 +1361,7 @@ public void onDatabaseUpdate(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 15) { - mAsyncHandler.post(new Runnable() - { - @Override - public void run() - { - ContentOperation.UPDATE_TIMEZONE.fire(getContext(), null); - } - }); + mAsyncHandler.post(() -> ContentOperation.UPDATE_TIMEZONE.fire(getContext(), null)); } } @@ -1344,6 +1376,8 @@ protected boolean syncToNetwork(Uri uri) @Override public void onAccountsUpdated(Account[] accounts) { + // cache the known accounts so we can check whether we know accounts for which new lists are added + mAccountCache.set(new HashSet<>(Arrays.asList(accounts))); // TODO: we probably can move the cleanup code here and get rid of the Utils class Utils.cleanUpLists(getContext(), getDatabaseHelper().getWritableDatabase(), accounts, mAuthority); } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java index b9db18c50..bdb908973 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java @@ -24,8 +24,9 @@ import org.dmfs.iterables.elementary.Seq; import org.dmfs.iterators.filters.NoneOf; import org.dmfs.jems.iterable.composite.Joined; -import org.dmfs.optional.NullSafe; -import org.dmfs.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; import org.dmfs.provider.tasks.TaskDatabaseHelper; import org.dmfs.provider.tasks.model.ContentValuesInstanceAdapter; import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; @@ -106,9 +107,9 @@ public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, { throw new IllegalArgumentException("Can't add an instance to an override instance"); } - DateTime masterDate = new FirstPresent<>(new Seq<>( + DateTime masterDate = new Backed(new FirstPresent<>(new Seq<>( new NullSafe<>(masterTaskAdapter.valueOf(TaskAdapter.DTSTART)), - new NullSafe<>(masterTaskAdapter.valueOf(TaskAdapter.DUE)))).value(null); + new NullSafe<>(masterTaskAdapter.valueOf(TaskAdapter.DUE)))), () -> null).value(); if (!masterTaskAdapter.isRecurring() && masterDate != null) { // master is not recurring yet, also add its start as an RDATE diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java index 3d78009a6..238e65015 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java @@ -21,9 +21,9 @@ import org.dmfs.iterables.decorators.Sieved; import org.dmfs.iterables.elementary.Seq; -import org.dmfs.optional.First; -import org.dmfs.optional.NullSafe; -import org.dmfs.optional.Optional; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.First; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.provider.tasks.TaskDatabaseHelper; import org.dmfs.provider.tasks.model.InstanceAdapter; import org.dmfs.provider.tasks.model.TaskAdapter; diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java index 52d252ea1..c811bfb4e 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java @@ -22,9 +22,10 @@ import org.dmfs.jems.iterable.composite.Diff; import org.dmfs.jems.iterable.decorators.Mapped; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.jems.pair.Pair; import org.dmfs.jems.single.Single; -import org.dmfs.optional.NullSafe; +import org.dmfs.jems.single.combined.Backed; import org.dmfs.optional.Optional; import org.dmfs.provider.tasks.TaskDatabaseHelper; import org.dmfs.provider.tasks.model.TaskAdapter; @@ -187,7 +188,9 @@ private void updateInstances(SQLiteDatabase db, TaskAdapter taskAdapter, long id { existingInstances.moveToPosition(cursorRow); return (int) (existingInstances.getLong(startIdx) - - new NullSafe<>(newInstanceValues.getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME)).value(0L)); + new Backed<>( + new NullSafe<>(newInstanceValues.getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME)), + 0L).value()); }); // sync the instances table with the new instances diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java index 3c61f1960..5b62b8e50 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java @@ -21,6 +21,7 @@ import org.dmfs.provider.tasks.FTSDatabaseHelper; import org.dmfs.provider.tasks.model.TaskAdapter; import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.utils.Profiled; /** @@ -43,7 +44,7 @@ public Searchable(EntityProcessor delegate) public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) { TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); - FTSDatabaseHelper.updateTaskFTSEntries(db, task); + new Profiled("InsertFTS").run(() -> FTSDatabaseHelper.updateTaskFTSEntries(db, task)); return result; } @@ -52,7 +53,7 @@ public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAda public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) { TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); - FTSDatabaseHelper.updateTaskFTSEntries(db, task); + new Profiled("UpdateFTS").run(() -> FTSDatabaseHelper.updateTaskFTSEntries(db, task)); return result; } @@ -60,6 +61,6 @@ public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAda @Override public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) { - mDelegate.delete(db, entityAdapter, isSyncAdapter); + new Profiled("DeleteFTS").run(() -> mDelegate.delete(db, entityAdapter, isSyncAdapter)); } } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java index 3989e3d80..c9e606c0f 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java @@ -18,9 +18,9 @@ import android.content.ContentValues; +import org.dmfs.jems.optional.Optional; import org.dmfs.jems.single.Single; import org.dmfs.jems.single.decorators.DelegatingSingle; -import org.dmfs.optional.Optional; import org.dmfs.provider.tasks.utils.Zipped; import org.dmfs.rfc5545.DateTime; diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java index 5715d6a01..d39005f60 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java @@ -18,9 +18,9 @@ import android.content.ContentValues; +import org.dmfs.jems.optional.Optional; import org.dmfs.jems.single.Single; import org.dmfs.jems.single.decorators.DelegatingSingle; -import org.dmfs.optional.Optional; import org.dmfs.rfc5545.DateTime; import org.dmfs.tasks.contract.TaskContract; diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java index 9c1b1b575..0552a8774 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java @@ -18,9 +18,10 @@ import android.content.ContentValues; +import org.dmfs.jems.optional.composite.Zipped; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.jems.single.Single; -import org.dmfs.optional.NullSafe; -import org.dmfs.optional.composite.Zipped; +import org.dmfs.jems.single.combined.Backed; import org.dmfs.tasks.contract.TaskContract; @@ -47,11 +48,12 @@ public ContentValues value() ContentValues values = mDelegate.value(); // just store the difference between due and start, if both are present, otherwise store null values.put(TaskContract.Instances.INSTANCE_DURATION, - new Zipped<>( - new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_START)), - new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_DUE)), - (start, due) -> due - start) - .value(null)); + new Backed( + new Zipped<>( + new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_START)), + new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_DUE)), + (start, due) -> due - start), + () -> null).value()); return values; } } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java index 13fe85a30..a4905f3ac 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java @@ -19,11 +19,12 @@ import android.content.ContentValues; import org.dmfs.iterables.elementary.Seq; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.FirstPresent; import org.dmfs.jems.optional.decorators.Mapped; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.jems.single.Single; -import org.dmfs.optional.NullSafe; -import org.dmfs.optional.Optional; -import org.dmfs.optional.adapters.FirstPresent; +import org.dmfs.jems.single.combined.Backed; import org.dmfs.rfc5545.DateTime; import org.dmfs.tasks.contract.TaskContract; @@ -53,12 +54,13 @@ public ContentValues value() { ContentValues values = mDelegate.value(); values.put(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, - new FirstPresent<>( - new Seq<>( - new Mapped<>(DateTime::getTimestamp, mOriginalTime), - new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_START)), - new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_DUE)))) - .value(null)); + new Backed( + new FirstPresent<>( + new Seq<>( + new Mapped<>(DateTime::getTimestamp, mOriginalTime), + new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_START)), + new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_DUE)))), + () -> null).value()); return values; } } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java index 29bfeb99c..5ecb32057 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java @@ -18,9 +18,9 @@ import android.content.ContentValues; +import org.dmfs.jems.optional.Optional; import org.dmfs.jems.single.Single; import org.dmfs.jems.single.decorators.DelegatingSingle; -import org.dmfs.optional.Optional; import org.dmfs.rfc5545.DateTime; import org.dmfs.tasks.contract.TaskContract; diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Chunked.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Chunked.java new file mode 100644 index 000000000..a7a00ebf0 --- /dev/null +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Chunked.java @@ -0,0 +1,51 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import java.util.Iterator; +import java.util.Locale; + + +/** + * An {@link Iterable} decorator which returns the elements of the decorated {@link Iterable} in chunks of a specific size. + * + * @author Marten Gajda + * @deprecated TODO: move to jems + */ +public final class Chunked implements Iterable> +{ + private final int mChunkSize; + private final Iterable mDelegate; + + + public Chunked(int chunkSize, Iterable delegate) + { + if (chunkSize <= 0) + { + throw new IllegalArgumentException(String.format(Locale.ENGLISH, "Chunk size must be >0 but was %s", chunkSize)); + } + mChunkSize = chunkSize; + mDelegate = delegate; + } + + + @Override + public Iterator> iterator() + { + return new ChunkedIterator<>(mChunkSize, mDelegate.iterator()); + } +} diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/ChunkedIterator.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/ChunkedIterator.java new file mode 100644 index 000000000..9caac7569 --- /dev/null +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/ChunkedIterator.java @@ -0,0 +1,68 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.iterators.AbstractBaseIterator; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; + + +/** + * In {@link Iterator} decorator which returns the elements of the decorated {@link Iterator} in chunks of a specific size. + * + * @author Marten Gajda + * @deprecated TODO: Move to jems. + */ +public final class ChunkedIterator extends AbstractBaseIterator> +{ + private final int mChunkSize; + private final Iterator mDelegate; + + + public ChunkedIterator(int chunkSize, Iterator delegate) + { + if (chunkSize <= 0) + { + throw new IllegalArgumentException(String.format(Locale.ENGLISH, "Chunk size must be >0 but was %s", chunkSize)); + } + mChunkSize = chunkSize; + mDelegate = delegate; + } + + + @Override + public boolean hasNext() + { + return mDelegate.hasNext(); + } + + + @Override + public Iterable next() + { + List result = new ArrayList<>(mChunkSize); + int remaining = mChunkSize; + do + { + result.add(mDelegate.next()); + } while (mDelegate.hasNext() && --remaining > 0); + return result; + } +} diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java index 6de439799..29c3f2833 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java @@ -20,11 +20,11 @@ import org.dmfs.iterables.elementary.Seq; import org.dmfs.iterators.SingletonIterator; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.composite.Zipped; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.jems.single.Single; -import org.dmfs.optional.NullSafe; -import org.dmfs.optional.Optional; -import org.dmfs.optional.adapters.FirstPresent; -import org.dmfs.optional.composite.Zipped; import org.dmfs.provider.tasks.model.TaskAdapter; import org.dmfs.provider.tasks.processors.tasks.instancedata.Distant; import org.dmfs.provider.tasks.processors.tasks.instancedata.DueDated; diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java new file mode 100644 index 000000000..a30f3fc63 --- /dev/null +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java @@ -0,0 +1,80 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.util.Log; + +import org.dmfs.jems.fragile.Fragile; +import org.dmfs.jems.single.Single; + +import java.util.Locale; + + +/** + * A simple class to measure the execution time of a given piece of code. + * + * @author Marten Gajda + */ +public final class Profiled +{ + private final String mSubject; + + + public Profiled(String subject) + { + mSubject = subject; + } + + + public void run(Runnable runnable) + { + long start = System.currentTimeMillis(); + runnable.run(); + Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); + } + + + public V run(Single runnable) + { + + long start = System.currentTimeMillis(); + try + { + return runnable.value(); + } + finally + { + Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); + } + } + + + public V run(Fragile runnable) throws E + { + + long start = System.currentTimeMillis(); + try + { + return runnable.value(); + } + finally + { + Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); + } + } + +} diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java index 06a5a3958..80df3f7fe 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java @@ -17,11 +17,11 @@ package org.dmfs.provider.tasks.utils; import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.optional.Optional; import org.dmfs.jems.optional.decorators.Mapped; import org.dmfs.jems.single.Single; import org.dmfs.jems.single.combined.Backed; import org.dmfs.jems.single.decorators.DelegatingSingle; -import org.dmfs.optional.Optional; /** diff --git a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java index 3d1e6bdfc..4d60f5b42 100644 --- a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java +++ b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java @@ -18,7 +18,7 @@ import android.content.ContentValues; -import org.dmfs.optional.Present; +import org.dmfs.jems.optional.elementary.Present; import org.dmfs.provider.tasks.utils.ContentValuesWithLong; import org.dmfs.rfc5545.DateTime; import org.junit.Test; @@ -26,7 +26,7 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import static org.dmfs.optional.Absent.absent; +import static org.dmfs.jems.optional.elementary.Absent.absent; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThat; diff --git a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java index 0ea9b912b..25840e9a2 100644 --- a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java +++ b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java @@ -18,7 +18,7 @@ import android.content.ContentValues; -import org.dmfs.optional.Present; +import org.dmfs.jems.optional.elementary.Present; import org.dmfs.provider.tasks.utils.ContentValuesWithLong; import org.dmfs.rfc5545.DateTime; import org.dmfs.tasks.contract.TaskContract; @@ -29,7 +29,7 @@ import java.util.TimeZone; -import static org.dmfs.optional.Absent.absent; +import static org.dmfs.jems.optional.elementary.Absent.absent; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertThat; diff --git a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java index 2f9a708db..442e2e4d3 100644 --- a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java +++ b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java @@ -18,7 +18,7 @@ import android.content.ContentValues; -import org.dmfs.optional.Present; +import org.dmfs.jems.optional.elementary.Present; import org.dmfs.provider.tasks.utils.ContentValuesWithLong; import org.dmfs.rfc5545.DateTime; import org.dmfs.tasks.contract.TaskContract; diff --git a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java index c4ad220bd..85676d189 100644 --- a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java +++ b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java @@ -18,7 +18,7 @@ import android.content.ContentValues; -import org.dmfs.optional.Present; +import org.dmfs.jems.optional.elementary.Present; import org.dmfs.provider.tasks.utils.ContentValuesWithLong; import org.dmfs.rfc5545.DateTime; import org.dmfs.tasks.contract.TaskContract; diff --git a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ChunkedTest.java b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ChunkedTest.java new file mode 100644 index 000000000..a5400a754 --- /dev/null +++ b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ChunkedTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.iterables.elementary.Seq; +import org.junit.Test; + +import static org.dmfs.jems.hamcrest.matchers.BrokenFragileMatcher.isBroken; +import static org.dmfs.jems.hamcrest.matchers.IterableMatcher.iteratesTo; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +public class ChunkedTest +{ + @Test + public void test() + { + // error case, illegal chnuk size + assertThat(() -> new Chunked<>(-1, new Seq<>(1)), isBroken(IllegalArgumentException.class)); + assertThat(() -> new Chunked<>(0, new Seq<>(1)), isBroken(IllegalArgumentException.class)); + + // edge case chunk size 1 + assertThat(new Chunked<>(1, new Seq<>()), is(emptyIterable())); + assertThat(new Chunked<>(1, new Seq<>(1)), iteratesTo(iteratesTo(1))); + assertThat(new Chunked<>(1, new Seq<>(1, 2, 3)), iteratesTo(iteratesTo(1), iteratesTo(2), iteratesTo(3))); + + // regular case chunk size >1 + assertThat(new Chunked<>(3, new Seq<>()), is(emptyIterable())); + assertThat(new Chunked<>(3, new Seq<>(1)), iteratesTo(iteratesTo(1))); + assertThat(new Chunked<>(3, new Seq<>(1, 2, 3)), iteratesTo(iteratesTo(1, 2, 3))); + assertThat(new Chunked<>(3, new Seq<>(1, 2, 3, 4)), iteratesTo(iteratesTo(1, 2, 3), iteratesTo(4))); + assertThat(new Chunked<>(3, new Seq<>(1, 2, 3, 4, 5, 6)), iteratesTo(iteratesTo(1, 2, 3), iteratesTo(4, 5, 6))); + assertThat(new Chunked<>(3, new Seq<>(1, 2, 3, 4, 5, 6, 7)), iteratesTo(iteratesTo(1, 2, 3), iteratesTo(4, 5, 6), iteratesTo(7))); + } +} \ No newline at end of file diff --git a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java index 1ee7ac873..fef43ca94 100644 --- a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java +++ b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java @@ -17,14 +17,14 @@ package org.dmfs.provider.tasks.utils; import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.optional.elementary.Present; import org.dmfs.jems.single.elementary.ValueSingle; -import org.dmfs.optional.Present; import org.junit.Test; import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; import static org.dmfs.jems.mockito.doubles.TestDoubles.dummy; import static org.dmfs.jems.mockito.doubles.TestDoubles.failingMock; -import static org.dmfs.optional.Absent.absent; +import static org.dmfs.jems.optional.elementary.Absent.absent; import static org.hamcrest.Matchers.sameInstance; import static org.junit.Assert.assertThat; import static org.mockito.Mockito.doReturn; diff --git a/opentasks/build.gradle b/opentasks/build.gradle index 77f848911..c4831bd3f 100644 --- a/opentasks/build.gradle +++ b/opentasks/build.gradle @@ -1,5 +1,7 @@ apply plugin: 'com.android.application' - +if (project.hasProperty('PLAY_STORE_SERVICE_ACCOUNT_CREDENTIALS')) { + apply plugin: 'com.github.triplet.play' +} // commit number is only relevant to the application project def gitCommitNo = { -> def stdout = new ByteArrayOutputStream() @@ -12,7 +14,6 @@ def gitCommitNo = { -> android { compileSdkVersion COMPILE_SDK_VERSION.toInteger() - buildToolsVersion BUILD_TOOLS_VERSION defaultConfig { applicationId "org.dmfs.tasks" minSdkVersion MIN_SDK_VERSION.toInteger() @@ -21,8 +22,21 @@ android { versionName version testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } + if (project.hasProperty("DMFS_RELEASE_KEYSTORE")) { + signingConfigs { + release { + storeFile file(DMFS_RELEASE_KEYSTORE) + storePassword DMFS_RELEASE_KEYSTORE_PASSWORD + keyAlias DMFS_RELEASE_KEY_ALIAS + keyPassword DMFS_RELEASE_KEY_PASSWORD + } + } + } buildTypes { release { + if (project.hasProperty("DMFS_RELEASE_KEYSTORE")) { + signingConfig signingConfigs.release + } minifyEnabled true proguardFiles 'proguard.cfg' } @@ -69,6 +83,7 @@ dependencies { testImplementation deps.junit testImplementation deps.robolectric + testImplementation deps.jems_testing androidTestImplementation(deps.support_test_runner) { exclude group: 'com.android.support', module: 'support-annotations' @@ -76,4 +91,28 @@ dependencies { androidTestImplementation(deps.support_test_rules) { exclude group: 'com.android.support', module: 'support-annotations' } + implementation project(path: ':opentaskspal') } + +if (project.hasProperty('PLAY_STORE_SERVICE_ACCOUNT_CREDENTIALS')) { + play { + serviceAccountCredentials = file(PLAY_STORE_SERVICE_ACCOUNT_CREDENTIALS) + // the track is determined automatically by the version number format + switch (version){ + case ~/^(\d+)(\.\d+)*(-\d+-[\w\d]+)?-dirty$/: + // work in progress goes to the internal track + track = "internal" + break + case ~/^(\d+)(\.\d+)*-\d+-[\w\d]+$/: + // untagged commits go to alpha + track = "alpha" + break + case ~/^(\d+)(\.\d+)*$/: + // tagged commits to go beta, from where they get promoted to releases + track = "beta" + break + default: + throw new IllegalArgumentException("Unrecognized version format") + } + } +} \ No newline at end of file diff --git a/opentasks/src/main/AndroidManifest.xml b/opentasks/src/main/AndroidManifest.xml index 02b61d25f..af827853d 100644 --- a/opentasks/src/main/AndroidManifest.xml +++ b/opentasks/src/main/AndroidManifest.xml @@ -5,7 +5,9 @@ - + @@ -207,7 +209,7 @@ + android:exported="false"> @@ -347,6 +349,13 @@ android:scheme="content"/> + + + + + ( + account -> AccountManager.newChooseAccountIntent(account, new ArrayList(singletonList(account)), null, + description, null, + null, null), + new Mapped, Account>( + this::account, + new Mapped<>(RowSnapshot::values, + new QueryRowSet<>( + new TaskListsView(authority, context.getContentResolver().acquireContentProviderClient(authority)), + new MultiProjection<>(TaskContract.TaskLists.ACCOUNT_NAME, TaskContract.TaskLists.ACCOUNT_TYPE), + new Not(new AnyOf( + new Joined<>(new Seq<>( + new EqArg(TaskContract.TaskLists.ACCOUNT_TYPE, TaskContract.LOCAL_ACCOUNT_TYPE)), + new Mapped<>(AccountEq::new, new Seq<>(accountManager.getAccounts())))))))))) + { + context.startActivity(accountRequestIntent); + } + } + + + private Account account(RowDataSnapshot data) + { + return (new Account( + data.data(TaskContract.TaskLists.ACCOUNT_NAME, s -> s).value(), + data.data(TaskContract.TaskLists.ACCOUNT_TYPE, s -> s).value())); + } +} diff --git a/opentasks/src/main/java/org/dmfs/tasks/TaskListActivity.java b/opentasks/src/main/java/org/dmfs/tasks/TaskListActivity.java index 608b3c615..247fb7ace 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/TaskListActivity.java +++ b/opentasks/src/main/java/org/dmfs/tasks/TaskListActivity.java @@ -25,6 +25,7 @@ import android.os.Build.VERSION; import android.os.Bundle; import android.os.Handler; +import android.provider.Settings; import android.support.annotation.ColorInt; import android.support.annotation.NonNull; import android.support.design.widget.AppBarLayout; @@ -454,9 +455,14 @@ public ExpandableGroupDescriptor getGroupDescriptor(int pageId) private void replaceTaskDetailsFragment(@NonNull Fragment fragment) { - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(0, R.anim.openttasks_fade_exit, 0, 0) - .replace(R.id.task_detail_container, fragment, DETAILS_FRAGMENT_TAG).commit(); + FragmentManager fragmentManager = getSupportFragmentManager(); + // only change state if the state has not been saved yet, otherwise just drop it + if (!fragmentManager.isStateSaved()) + { + fragmentManager.beginTransaction() + .setCustomAnimations(0, R.anim.openttasks_fade_exit, 0, 0) + .replace(R.id.task_detail_container, fragment, DETAILS_FRAGMENT_TAG).commit(); + } } @@ -639,7 +645,18 @@ else if (item.getItemId() == R.id.menu_visible_list) } else if (item.getItemId() == R.id.opentasks_menu_app_settings) { - startActivity(new Intent(this, AppSettingsActivity.class)); + if (VERSION.SDK_INT < 26) + { + startActivity(new Intent(this, AppSettingsActivity.class)); + } + else + { + // for now just open the notification settings, which is all we currently support anyway + Intent intent = new Intent(); + intent.setAction(Settings.ACTION_APP_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName()); + startActivity(intent); + } return true; } else diff --git a/opentasks/src/main/java/org/dmfs/tasks/TaskListFragment.java b/opentasks/src/main/java/org/dmfs/tasks/TaskListFragment.java index 87ef80dc7..853e392ae 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/TaskListFragment.java +++ b/opentasks/src/main/java/org/dmfs/tasks/TaskListFragment.java @@ -503,7 +503,7 @@ private void selectChildView(ExpandableListView expandLV, int groupPosition, int } // TODO For now we get the id of the task, not the instance, once we support recurrence we'll have to change that, use instance URI that time - Uri taskUri = ContentUris.withAppendedId(Tasks.getContentUri(mAuthority), (long) TaskFieldAdapters.TASK_ID.get(cursor)); + Uri taskUri = ContentUris.withAppendedId(Tasks.getContentUri(mAuthority), (long) TaskFieldAdapters.INSTANCE_TASK_ID.get(cursor)); Color taskListColor = new ValueColor(TaskFieldAdapters.LIST_COLOR.get(cursor)); mCallbacks.onItemSelected(taskUri, taskListColor, force, mInstancePosition); } diff --git a/opentasks/src/main/java/org/dmfs/tasks/ViewTaskFragment.java b/opentasks/src/main/java/org/dmfs/tasks/ViewTaskFragment.java index d9958df60..428e60426 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/ViewTaskFragment.java +++ b/opentasks/src/main/java/org/dmfs/tasks/ViewTaskFragment.java @@ -143,7 +143,7 @@ public class ViewTaskFragment extends SupportFragment private Toolbar mToolBar; private View mRootView; - private int mAppBarOffset = 0; + private int mAppBarOffset = 0; private FloatingActionButton mFloatingActionButton; @@ -262,6 +262,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa mRootView = inflater.inflate(R.layout.fragment_task_view_detail, container, false); mContent = (ViewGroup) mRootView.findViewById(R.id.content); + mDetailView = (TaskView) inflater.inflate(R.layout.task_view, mContent, false); + mContent.addView(mDetailView); mAppBar = (AppBarLayout) mRootView.findViewById(R.id.appbar); mToolBar = (Toolbar) mRootView.findViewById(R.id.toolbar); mToolBar.setOnMenuItemClickListener(this); diff --git a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByDueDate.java b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByDueDate.java index ad156233f..64211bd95 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByDueDate.java +++ b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByDueDate.java @@ -24,7 +24,8 @@ import android.widget.BaseExpandableListAdapter; import android.widget.TextView; -import org.dmfs.optional.NullSafe; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; import org.dmfs.tasks.R; import org.dmfs.tasks.contract.TaskContract.Instances; import org.dmfs.tasks.groupings.cursorloaders.TimeRangeCursorFactory; diff --git a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByList.java b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByList.java index 8d0a6dd65..a74e0fd81 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByList.java +++ b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByList.java @@ -26,7 +26,7 @@ import android.widget.BaseExpandableListAdapter; import android.widget.TextView; -import org.dmfs.optional.NullSafe; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.tasks.QuickAddDialogFragment; import org.dmfs.tasks.R; import org.dmfs.tasks.contract.TaskContract.Instances; diff --git a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByPriority.java b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByPriority.java index 5395fe69d..ed8e8bf3c 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByPriority.java +++ b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByPriority.java @@ -26,7 +26,7 @@ import android.widget.BaseExpandableListAdapter; import android.widget.TextView; -import org.dmfs.optional.NullSafe; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.provider.tasks.AuthorityUtil; import org.dmfs.tasks.QuickAddDialogFragment; import org.dmfs.tasks.R; diff --git a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByProgress.java b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByProgress.java index e7f514bee..70ec1ec9b 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByProgress.java +++ b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByProgress.java @@ -24,7 +24,7 @@ import android.widget.BaseExpandableListAdapter; import android.widget.TextView; -import org.dmfs.optional.NullSafe; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.tasks.R; import org.dmfs.tasks.contract.TaskContract.Instances; import org.dmfs.tasks.groupings.cursorloaders.ProgressCursorFactory; diff --git a/opentasks/src/main/java/org/dmfs/tasks/groupings/BySearch.java b/opentasks/src/main/java/org/dmfs/tasks/groupings/BySearch.java index 9e130e181..66b731030 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/groupings/BySearch.java +++ b/opentasks/src/main/java/org/dmfs/tasks/groupings/BySearch.java @@ -30,7 +30,7 @@ import android.widget.TextView; import android.widget.Toast; -import org.dmfs.optional.NullSafe; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.tasks.R; import org.dmfs.tasks.contract.TaskContract.Instances; import org.dmfs.tasks.contract.TaskContract.Tasks; diff --git a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByStartDate.java b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByStartDate.java index bbb84a31f..d323ab74d 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/groupings/ByStartDate.java +++ b/opentasks/src/main/java/org/dmfs/tasks/groupings/ByStartDate.java @@ -26,7 +26,7 @@ import android.widget.ImageView; import android.widget.TextView; -import org.dmfs.optional.NullSafe; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.tasks.R; import org.dmfs.tasks.contract.TaskContract.Instances; import org.dmfs.tasks.groupings.cursorloaders.TimeRangeCursorFactory; diff --git a/opentasks/src/main/java/org/dmfs/tasks/notification/AlarmBroadcastReceiver.java b/opentasks/src/main/java/org/dmfs/tasks/notification/AlarmBroadcastReceiver.java index 3b01ef36e..91612a1ff 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/notification/AlarmBroadcastReceiver.java +++ b/opentasks/src/main/java/org/dmfs/tasks/notification/AlarmBroadcastReceiver.java @@ -22,6 +22,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; +import android.os.Build; import android.preference.PreferenceManager; import org.dmfs.tasks.R; @@ -47,6 +48,7 @@ public void onReceive(Context context, Intent intent) { if (isNotificationEnabled(context)) { + NotificationUpdaterService.createChannels(context); Uri taskUri = intent.getData(); boolean noSignal = intent.getBooleanExtra(NotificationActionUtils.EXTRA_NOTIFICATION_NO_SIGNAL, false); @@ -72,6 +74,7 @@ else if (intent.getAction().equals(TaskContract.ACTION_BROADCAST_TASK_DUE)) { if (isNotificationEnabled(context)) { + NotificationUpdaterService.createChannels(context); Uri taskUri = intent.getData(); boolean noSignal = intent.getBooleanExtra(NotificationActionUtils.EXTRA_NOTIFICATION_NO_SIGNAL, false); @@ -100,6 +103,11 @@ else if (intent.getAction().equals(TaskContract.ACTION_BROADCAST_TASK_DUE)) public boolean isNotificationEnabled(Context context) { + if (Build.VERSION.SDK_INT >= 26) + { + // on Android 8+ we leave this decision to Android and always attempt to show the notification + return true; + } SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); return settings.getBoolean(context.getString(R.string.opentasks_pref_notification_enabled), true); diff --git a/opentasks/src/main/java/org/dmfs/tasks/notification/NotificationActionUtils.java b/opentasks/src/main/java/org/dmfs/tasks/notification/NotificationActionUtils.java index 3eb2105b2..ac7e4a1d2 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/notification/NotificationActionUtils.java +++ b/opentasks/src/main/java/org/dmfs/tasks/notification/NotificationActionUtils.java @@ -92,7 +92,8 @@ public static void sendDueAlarmNotification(Context context, String title, Uri t } // build notification - NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context).setSmallIcon(R.drawable.ic_notification) + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, NotificationUpdaterService.CHANNEL_DUE_DATES).setSmallIcon( + R.drawable.ic_notification) .setContentTitle(title).setContentText(dueString); // color @@ -172,7 +173,8 @@ public static void sendStartNotification(Context context, String title, Uri task NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); // build notification - NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context).setSmallIcon(R.drawable.ic_notification) + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, NotificationUpdaterService.CHANNEL_DUE_DATES).setSmallIcon( + R.drawable.ic_notification) .setContentTitle(title).setContentText(startString); // color @@ -251,7 +253,7 @@ public static PendingIntent getNotificationActionPendingIntent(Context context, */ public static void createUndoNotification(final Context context, NotificationAction action) { - NotificationCompat.Builder builder = new NotificationCompat.Builder(context); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NotificationUpdaterService.CHANNEL_DUE_DATES); builder.setContentTitle(context.getString(action.getActionTextResId() )); diff --git a/opentasks/src/main/java/org/dmfs/tasks/notification/NotificationUpdaterService.java b/opentasks/src/main/java/org/dmfs/tasks/notification/NotificationUpdaterService.java index b8bdcdcb7..53306cc7f 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/notification/NotificationUpdaterService.java +++ b/opentasks/src/main/java/org/dmfs/tasks/notification/NotificationUpdaterService.java @@ -16,9 +16,12 @@ package org.dmfs.tasks.notification; +import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.AlarmManager; import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.ContentProviderOperation; @@ -68,6 +71,9 @@ */ public class NotificationUpdaterService extends Service { + public static final String CHANNEL_PINNED = "org.dmfs.opentasks.PINNED"; + public static final String CHANNEL_DUE_DATES = "org.dmfs.opentasks.DUE_DATES"; + private static final String TAG = "NotificationUpdaterSe"; /** @@ -79,6 +85,8 @@ public class NotificationUpdaterService extends Service private static final int REQUEST_CODE_DELAY = 2; private static final int REQUEST_CODE_UNPIN = 3; + private static final int DUMMY_NOTIFICATION_ID = -10; + // actions public static final String ACTION_PINNED_TASK_DUE = "org.dmfs.tasks.intent.ACTION_PINNED_TASK_DUE"; public static final String ACTION_PINNED_TASK_START = "org.dmfs.tasks.intent.ACTION_PINNED_TASK_START"; @@ -97,11 +105,13 @@ public class NotificationUpdaterService extends Service public static final String EXTRA_TIMEZONE = "org.dmfs.tasks.extras.notification.TIMEZONE"; public static final String EXTRA_ALLDAY = "org.dmfs.tasks.extras.notification.ALLDAY"; - private final NotificationCompat.Builder mBuilder = new Builder(this); + private final NotificationCompat.Builder mBuilder = new Builder(this, NotificationUpdaterService.CHANNEL_DUE_DATES); private PendingIntent mDateChangePendingIntent; ArrayList mTasksToPin; private String mAuthority; + private int mForegroundPinned = -1; + @Override public IBinder onBind(Intent intent) @@ -117,10 +127,50 @@ public NotificationUpdaterService() } + public static void createChannels(Context context) + { + if (VERSION.SDK_INT >= 26) + { + NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationChannel pinnedChannel = new NotificationChannel(CHANNEL_PINNED, context.getString(R.string.opentasks_notification_channel_pinned_tasks), + NotificationManager.IMPORTANCE_DEFAULT); + // pinned Notifications should not get a badge, but they may vibrate + pinnedChannel.setShowBadge(false); + pinnedChannel.enableLights(false); + pinnedChannel.enableVibration(true); + pinnedChannel.setVibrationPattern(new long[] { 0, 100, 100, 100, 0 }); + nm.createNotificationChannel(pinnedChannel); + + NotificationChannel dueDates = new NotificationChannel(NotificationUpdaterService.CHANNEL_DUE_DATES, + context.getString(R.string.opentasks_notification_channel_due_dates), NotificationManager.IMPORTANCE_HIGH); + dueDates.setShowBadge(true); + dueDates.enableLights(true); + dueDates.enableVibration(true); + nm.createNotificationChannel(dueDates); + } + } + + + @SuppressLint("WrongConstant") @Override public void onCreate() { mAuthority = AuthorityUtil.taskAuthority(this); + + if (VERSION.SDK_INT >= 26) + { + createChannels(this); + + // note this notification is to make Android happy, it should never be visible to the user because it's either replaced or + // discarded in onStartCommand + startForeground(DUMMY_NOTIFICATION_ID, new NotificationCompat.Builder(this, NotificationUpdaterService.CHANNEL_PINNED).setTicker("OpenTasks") + .setSmallIcon(R.drawable.ic_24_opentasks) + .setSound(null) + .setVibrate(null) + .setPriority(NotificationCompat.PRIORITY_MIN) + .build()); + } + super.onCreate(); updateNextDayAlarm(); } @@ -129,7 +179,6 @@ public void onCreate() @Override public int onStartCommand(Intent intent, int flags, int startId) { - String intentAction = intent.getAction(); boolean noSignal = intent.getBooleanExtra(NotificationActionUtils.EXTRA_NOTIFICATION_NO_SIGNAL, false); if (intentAction != null) @@ -200,13 +249,39 @@ public int onStartCommand(Intent intent, int flags, int startId) // check if the service needs to kept alive if (mTasksToPin == null || mTasksToPin.isEmpty()) { + if (VERSION.SDK_INT >= 26) + { + this.stopForeground(true); + mForegroundPinned = -1; + } this.stopSelf(); } + else if (VERSION.SDK_INT >= 26 && (mForegroundPinned < 0 || !isPinned(mForegroundPinned))) + { + // on Android 8+ we have to create or change the foreground notification if we didn't do that yet or of the respective task is no longer pinned + mBuilder.setChannelId(CHANNEL_PINNED); + ContentSet task = mTasksToPin.get(0); + startForeground(TaskFieldAdapters.TASK_ID.get(task), makePinNotification(this, mBuilder, task, true, false, false)); + mForegroundPinned = TaskFieldAdapters.TASK_ID.get(task); + } return Service.START_NOT_STICKY; } + private boolean isPinned(long id) + { + for (ContentSet task : mTasksToPin) + { + if (id == TaskFieldAdapters.TASK_ID.get(task)) + { + return true; + } + } + return false; + } + + private void updateNotifications(boolean isReboot, boolean noSignal, boolean withHeadsUpNotification) { // update pinned tasks @@ -225,6 +300,7 @@ private void updatePinnedNotifications(ArrayList tasksToPin, boolean { boolean isAlreadyShown = pinnedTaskUris.contains(taskContentSet.getUri()); Integer taskId = TaskFieldAdapters.TASK_ID.get(taskContentSet); + mBuilder.setChannelId(CHANNEL_PINNED); notificationManager.notify(taskId, makePinNotification(this, mBuilder, taskContentSet, isAlreadyShown && noSignal, isAlreadyShown && noSignal, withHeadsUpNotification)); } @@ -241,12 +317,7 @@ private void updatePinnedNotifications(ArrayList tasksToPin, boolean long taskId = ContentUris.parseId(uri); if (taskId > -1 == !containsTask(tasksToPin, uri)) { - - Integer notificationId = Long.valueOf(ContentUris.parseId(uri)).intValue(); - if (notificationId != null) - { - notificationManager.cancel(notificationId); - } + notificationManager.cancel((int) taskId); } } } @@ -314,6 +385,7 @@ private static Notification makePinNotification(Context context, Builder builder { Resources resources = context.getResources(); + builder.setChannelId(CHANNEL_PINNED); // reset actions builder.mActions = new ArrayList(2); @@ -392,7 +464,11 @@ private static Notification makePinNotification(Context context, Builder builder // unpin action builder.addAction(NotificationUpdaterService.getUnpinAction(context, TaskFieldAdapters.TASK_ID.get(task), task.getUri())); - builder.setDefaults(new Conditional(!noSignal, context).value()); + if (VERSION.SDK_INT < 26) + { + builder.setDefaults(new Conditional(!noSignal, context).value()); + } + builder.setOnlyAlertOnce(noSignal); return builder.build(); } @@ -480,6 +556,11 @@ private void resendNotification(NotificationAction notificationAction) startIntent.putExtra(TaskContract.EXTRA_TASK_TITLE, notificationAction.title()); startIntent.putExtra(TaskContract.EXTRA_TASK_TIMESTAMP, notificationAction.getWhen()); startIntent.putExtra(NotificationActionUtils.EXTRA_NOTIFICATION_NO_SIGNAL, true); + if (Build.VERSION.SDK_INT >= 26) + { + // for now only notify our own package + startIntent.setPackage(getPackageName()); + } sendBroadcast(startIntent); // Due broadcast @@ -489,6 +570,11 @@ private void resendNotification(NotificationAction notificationAction) dueIntent.putExtra(TaskContract.EXTRA_TASK_TITLE, notificationAction.title()); dueIntent.putExtra(TaskContract.EXTRA_TASK_TIMESTAMP, notificationAction.getWhen()); dueIntent.putExtra(NotificationActionUtils.EXTRA_NOTIFICATION_NO_SIGNAL, true); + if (Build.VERSION.SDK_INT >= 26) + { + // for now only notify our own package + dueIntent.setPackage(getPackageName()); + } sendBroadcast(dueIntent); } } @@ -660,14 +746,18 @@ private void delayTask(Uri taskUri, Time dueTime) private void delayedCancelHeadsUpNotification() { Handler handler = new Handler(Looper.getMainLooper()); - final Runnable r = new Runnable() + final Runnable r = () -> { - public void run() + Intent intent = new Intent(getBaseContext(), NotificationUpdaterService.class); + intent.setAction(ACTION_CANCEL_HEADUP_NOTIFICATION); + if (VERSION.SDK_INT < 26) { - Intent intent = new Intent(getBaseContext(), NotificationUpdaterService.class); - intent.setAction(ACTION_CANCEL_HEADUP_NOTIFICATION); startService(intent); } + else + { + startForegroundService(intent); + } }; handler.postDelayed(r, HEAD_UP_NOTIFICATION_DURATION); } diff --git a/opentasks/src/main/java/org/dmfs/tasks/notification/TaskNotificationHandler.java b/opentasks/src/main/java/org/dmfs/tasks/notification/TaskNotificationHandler.java index 82fcaca8c..fb9835fd4 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/notification/TaskNotificationHandler.java +++ b/opentasks/src/main/java/org/dmfs/tasks/notification/TaskNotificationHandler.java @@ -23,6 +23,7 @@ import android.content.SharedPreferences.Editor; import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.preference.PreferenceManager; import org.dmfs.provider.tasks.AuthorityUtil; @@ -99,7 +100,14 @@ private static void startPinnedTaskService(Context context, Uri taskUri, String intent.setData(taskUri); intent.setAction(action); intent.putExtra(NotificationUpdaterService.EXTRA_NEW_PINNED_TASK, task); - context.startService(intent); + if (Build.VERSION.SDK_INT < 26) + { + context.startService(intent); + } + else + { + context.startForegroundService(intent); + } } @@ -191,7 +199,14 @@ public static void sendPinnedTaskStartNotification(Context context, Uri taskUri, intent.setAction(NotificationUpdaterService.ACTION_PINNED_TASK_START); intent.putExtra(NotificationActionUtils.EXTRA_NOTIFICATION_NO_SIGNAL, noSignal); intent.setData(taskUri); - context.startService(intent); + if (Build.VERSION.SDK_INT < 26) + { + context.startService(intent); + } + else + { + context.startForegroundService(intent); + } } @@ -201,7 +216,14 @@ public static void sendPinnedTaskDueNotification(Context context, Uri taskUri, b intent.setAction(NotificationUpdaterService.ACTION_PINNED_TASK_DUE); intent.putExtra(NotificationActionUtils.EXTRA_NOTIFICATION_NO_SIGNAL, noSignal); intent.setData(taskUri); - context.startService(intent); + if (Build.VERSION.SDK_INT < 26) + { + context.startService(intent); + } + else + { + context.startForegroundService(intent); + } } } diff --git a/opentasks/src/main/java/org/dmfs/tasks/utils/BaseActivity.java b/opentasks/src/main/java/org/dmfs/tasks/utils/BaseActivity.java index b31755beb..a2a0c35b9 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/utils/BaseActivity.java +++ b/opentasks/src/main/java/org/dmfs/tasks/utils/BaseActivity.java @@ -18,6 +18,7 @@ import android.Manifest; import android.content.SharedPreferences; +import android.os.Build; import android.os.Bundle; import android.support.v7.app.AppCompatActivity; @@ -87,7 +88,8 @@ protected void onStop() private void requestMissingGetAccountsPermission() { - if (!mGetAccountsPermission.isGranted()) + /* This is only a thing on Android SDK Level <26. The permission has been replaced with per-account visibility. */ + if (Build.VERSION.SDK_INT < 26 && !mGetAccountsPermission.isGranted()) { PermissionRequestDialogFragment.newInstance(mGetAccountsPermission.isRequestable(this)).show(getSupportFragmentManager(), "permission-dialog"); } diff --git a/opentasks/src/main/java/org/dmfs/tasks/utils/Fragile.java b/opentasks/src/main/java/org/dmfs/tasks/utils/Fragile.java deleted file mode 100644 index edb9fb7ad..000000000 --- a/opentasks/src/main/java/org/dmfs/tasks/utils/Fragile.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.tasks.utils; - -import org.dmfs.jems.single.Single; - -import java.util.Iterator; - - -/** - * A Fragile is similar to a {@link Single} but it may throw an {@link Exception} during retrieval of the value. - *

- * It's primary use case is to allow deferring checked Exceptions if they can't be thrown right away. A common example is Iterating elements, {@link - * Iterator#next()} doesn't allow checked Exceptions to be thrown. In this case a {@link Fragile} can be returned to defer any exception to evaluation time. - * - * @author Marten Gajda - * @deprecated use it from jems when available - */ -@Deprecated -public interface Fragile -{ - T value() throws E; -} diff --git a/opentasks/src/main/java/org/dmfs/tasks/utils/RecentlyUsedLists.java b/opentasks/src/main/java/org/dmfs/tasks/utils/RecentlyUsedLists.java index 9b58b388c..85ae3f912 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/utils/RecentlyUsedLists.java +++ b/opentasks/src/main/java/org/dmfs/tasks/utils/RecentlyUsedLists.java @@ -21,8 +21,9 @@ import org.dmfs.iterables.Split; import org.dmfs.iterables.decorators.Fluent; -import org.dmfs.optional.NullSafe; -import org.dmfs.optional.Optional; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; import java.util.ArrayList; import java.util.List; @@ -47,7 +48,7 @@ public class RecentlyUsedLists private static List getList(Context context) { Optional listStrOpt = new NullSafe<>(PreferenceManager.getDefaultSharedPreferences(context).getString(PREFERENCE_KEY, null)); - Log.v(RecentlyUsedLists.class.getSimpleName(), "getList: " + listStrOpt.value("empty")); + Log.v(RecentlyUsedLists.class.getSimpleName(), "getList: " + new Backed<>(listStrOpt, "empty").value()); if (!listStrOpt.isPresent()) { return new ArrayList<>(0); diff --git a/opentasks/src/main/java/org/dmfs/tasks/utils/Unchecked.java b/opentasks/src/main/java/org/dmfs/tasks/utils/Unchecked.java index ef06267e8..1f5370e34 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/utils/Unchecked.java +++ b/opentasks/src/main/java/org/dmfs/tasks/utils/Unchecked.java @@ -16,6 +16,7 @@ package org.dmfs.tasks.utils; +import org.dmfs.jems.fragile.Fragile; import org.dmfs.jems.single.Single; diff --git a/opentasks/src/main/java/org/dmfs/tasks/utils/ValidatingUri.java b/opentasks/src/main/java/org/dmfs/tasks/utils/ValidatingUri.java index ac9d8e051..25f801c24 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/utils/ValidatingUri.java +++ b/opentasks/src/main/java/org/dmfs/tasks/utils/ValidatingUri.java @@ -19,6 +19,8 @@ import android.net.Uri; import android.support.annotation.Nullable; +import org.dmfs.jems.fragile.Fragile; + import java.net.URI; import java.net.URISyntaxException; diff --git a/opentasks/src/main/java/org/dmfs/tasks/utils/permission/utils/ManifestPermissionStrings.java b/opentasks/src/main/java/org/dmfs/tasks/utils/permission/utils/ManifestPermissionStrings.java index a3a41d234..6921a7c8b 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/utils/permission/utils/ManifestPermissionStrings.java +++ b/opentasks/src/main/java/org/dmfs/tasks/utils/permission/utils/ManifestPermissionStrings.java @@ -20,7 +20,7 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; -import org.dmfs.iterators.ArrayIterator; +import org.dmfs.iterators.elementary.Seq; import java.util.Iterator; @@ -47,7 +47,7 @@ public Iterator iterator() try { PackageInfo packageInfo = mAppContext.getPackageManager().getPackageInfo(mAppContext.getPackageName(), PackageManager.GET_PERMISSIONS); - return new ArrayIterator<>(packageInfo.requestedPermissions); + return new Seq<>(packageInfo.requestedPermissions); } catch (PackageManager.NameNotFoundException e) { diff --git a/opentasks/src/main/java/org/dmfs/tasks/widget/ProgressBackgroundView.java b/opentasks/src/main/java/org/dmfs/tasks/widget/ProgressBackgroundView.java index a54337cdc..4c1fd9979 100644 --- a/opentasks/src/main/java/org/dmfs/tasks/widget/ProgressBackgroundView.java +++ b/opentasks/src/main/java/org/dmfs/tasks/widget/ProgressBackgroundView.java @@ -18,7 +18,7 @@ import android.view.View; -import org.dmfs.optional.Optional; +import org.dmfs.jems.optional.Optional; import org.dmfs.tasks.R; diff --git a/opentasks/src/main/res/layout/activity_manage_task_list.xml b/opentasks/src/main/res/layout/activity_manage_task_list.xml index 571b95510..693ef2efc 100644 --- a/opentasks/src/main/res/layout/activity_manage_task_list.xml +++ b/opentasks/src/main/res/layout/activity_manage_task_list.xml @@ -17,8 +17,7 @@ android:paddingEnd="16dp" android:paddingLeft="16dp" android:paddingRight="16dp" - android:paddingStart="16dp" - android:focusable="true"> + android:paddingStart="16dp"> + android:paddingStart="16dp"> + android:orientation="horizontal"> + android:src="@drawable/content_edit"/> + style="@style/task_widget"> diff --git a/opentasks/src/main/res/layout/task_list_group.xml b/opentasks/src/main/res/layout/task_list_group.xml index dd1f66752..a6141657b 100644 --- a/opentasks/src/main/res/layout/task_list_group.xml +++ b/opentasks/src/main/res/layout/task_list_group.xml @@ -97,8 +97,7 @@ android:clickable="true" android:padding="8dp" android:src="@drawable/ic_24_plus_black50" - android:visibility="gone" - android:focusable="true"/> + android:visibility="gone"/> - \ No newline at end of file + diff --git a/opentasks/src/main/res/layout/task_list_group_single_line.xml b/opentasks/src/main/res/layout/task_list_group_single_line.xml index 09d275c85..fdae24376 100644 --- a/opentasks/src/main/res/layout/task_list_group_single_line.xml +++ b/opentasks/src/main/res/layout/task_list_group_single_line.xml @@ -47,8 +47,7 @@ android:clickable="true" android:padding="8dp" android:src="@drawable/ic_24_plus_black50" - android:visibility="gone" - android:focusable="true"/> + android:visibility="gone"/> + android:textAppearance="@style/TextAppearance.AppCompat.Notification.Title.colorFix"/> + android:textAppearance="@style/TextAppearance.AppCompat.Notification.Action"/> \ No newline at end of file diff --git a/opentasks/src/main/res/layout/visible_task_list_item.xml b/opentasks/src/main/res/layout/visible_task_list_item.xml index b2500de12..d32a3795d 100644 --- a/opentasks/src/main/res/layout/visible_task_list_item.xml +++ b/opentasks/src/main/res/layout/visible_task_list_item.xml @@ -37,8 +37,7 @@ android:clickable="true" android:padding="12dp" android:src="@drawable/ic_24_settings_black50" - android:tint="?attr/colorAccent" - android:focusable="true"/> + android:tint="?attr/colorAccent"/> %1$s, %2$s + Angeheftete Aufgaben + Beginn und Fälligkeiten + diff --git a/opentasks/src/main/res/values-v21/styles.xml b/opentasks/src/main/res/values-v21/styles.xml index 8aaca4435..71fb88770 100644 --- a/opentasks/src/main/res/values-v21/styles.xml +++ b/opentasks/src/main/res/values-v21/styles.xml @@ -11,13 +11,13 @@ @drawable/selectable_background_white - -