diff --git a/docs/advanced.md b/docs/advanced.md index 79a2970..14a1909 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -58,9 +58,11 @@ Notify add(Action( // The icon corresponding to the action. R.drawable.ic_app_icon, - // The text corresponding to the action -- this is what shows . + // The text corresponding to the action -- this is what shows below the + // notification. "Clear", - // Swap this PendingIntent for whatever Intent is to be processed when the action is clicked. + // Swap this PendingIntent for whatever Intent is to be processed when the action + // is clicked. PendingIntent.getService(context, 0, Intent(context, MyNotificationService::class.java) @@ -84,21 +86,69 @@ Notify title = "New dessert menu" text = "The Cheesecake Factory has a new dessert for you to try!" } - // Define the notification as being stackable. This block should be the same for all notifications which - // are to be grouped together. + // Define the notification as being stackable. This block should be the same for all + // notifications which are to be grouped together. .stackable { // this: Payload.Stackable - // In particular, this key should be the same. The properties of this stackable notification as - // taken from the latest stackable notification's stackable block. + // In particular, this key should be the same. The properties of this stackable + // notification as taken from the latest stackable notification's stackable block. key = "test_key" - // This is the summary of this notification as it appears when it is as part of a stacked notification. This - // String value is what is shown as a single line in the stacked notification. + // This is the summary of this notification as it appears when it is as part of a + // stacked notification. This String value is what is shown as a single line in the + // stacked notification. summaryContent = "test summary content" - // The number of notifications with the same key is passed as the 'count' argument. We happen not to - // use it, but it is there if needed. + // The number of notifications with the same key is passed as the 'count' argument. We + // happen not to use it, but it is there if needed. summaryTitle = { count -> "Summary title" } - // ... here as well, but we instead choose to use to to update the summary for when the notification - // is collapsed. + // ... here as well, but we instead choose to use to to update the summary for when the + // notification is collapsed. summaryDescription = { count -> count.toString() + " new notifications." } } .show() -``` \ No newline at end of file +``` + + +#### BUBBLE NOTIFICATIONS + +With the release of Android 10, Notify now also supports [Notification Bubbles](https://developer.android.com/guide/topics/ui/bubbles) on devices with the `Notification Bubbles` enabled through the Developer Settings. This new form of notification allows an application to display rich content from an activity at a glance to a user. + +Begin by first creating an activity and adding the following permissions to that activity within your `AndroidManifest.xml`: + +```xml + + + + + android:allowEmbedded="true" + android:documentLaunchMode="always" + android:resizeableActivity="true" /> + +``` + +Then you can target that activity from the notification. + +```kotlin +Notify.with(context) + .content { // this: Payload.Content.Default + title = "New dessert menu" + text = "The Cheesecake Factory has a new dessert for you to try!" + } + // Define the Notification as supporting a Bubble format. This style can be applied to any + // notification. + .bubblize { // this: Payload.Bubble + // Configure the target Intent for the Notification to launch when it is expanded. + val target = Intent(context, BubbleActivity::class.java) + // Provide a PendingIntent to launch the above target once the Bubble is expanded. + val bubbleIntent = PendingIntent.getActivity(context, 0, target, 0) + + // Set the image for the Bubble, this uses the IconCompat class to build the icon being + // shown within the bubble. + bubbleIcon = IconCompat.createWithResource(context, R.drawable.ic_app_icon) + // Set the activity that is being shown when the Bubble is expanded to the PendingIntent + // created above. + targetActivity = bubbleIntent + } + .show() +``` diff --git a/docs/assets/types/progress.png b/docs/assets/types/progress.png new file mode 100644 index 0000000..67ee1d7 Binary files /dev/null and b/docs/assets/types/progress.png differ diff --git a/docs/types.md b/docs/types.md index 7d33df4..4ad37d4 100644 --- a/docs/types.md +++ b/docs/types.md @@ -117,4 +117,28 @@ Notify ) } .show() -``` \ No newline at end of file +``` + +#### PROGRESS NOTIFICATION + +![Progress notification](./assets/types/progress.png) + +Progress notification is useful when you need to display information about the detail of a process such as uploading a file to a server, or some calculation that takes time and you want to keep the user informed. You can ser `showProgress` true to display it, and if you need determinate progress you can set `enablePercentage` true and specify `progressPercent` to your current value + +```Kotlin +Notify + .with(context) + .asBigText { + title = "Uploading files" + expandedText = "The files are being uploaded!" + bigText = "Daft Punk - Get Lucky.flac is uploading to server /music/favorites" + } + .progress { + showProgress = true + + //For determinate progress + //enablePercentage = true + //progressPercent = 27 + } + .show() +``` diff --git a/gradle/configuration.gradle b/gradle/configuration.gradle index 83f549d..c79f238 100644 --- a/gradle/configuration.gradle +++ b/gradle/configuration.gradle @@ -7,11 +7,11 @@ def versions = [ - libCode: 12, - libName: '1.3.0', + libCode: 13, + libName: '1.4.0', kotlin: '1.3.11', - core: '1.0.1', + core: '1.2.0-alpha04', appcompat: '1.0.2', jacoco: '0.8.2', @@ -19,8 +19,8 @@ def versions = [ ] def build = [ - compileSdk: 28, - targetSdk: 28, + compileSdk: 29, + targetSdk: 29, minSdk: 19, jacocoAgentVersion: versions.jacoco, @@ -40,7 +40,7 @@ def dependencies = [ reflect: "org.jetbrains.kotlin:kotlin-reflect:${versions.kotlin}" ], androidx: [ - core: "androidx.core:core:${versions.core}", + core: "androidx.core:core-ktx:${versions.core}", appcompat: "androidx.appcompat:appcompat:${versions.appcompat}" ] ] @@ -48,7 +48,7 @@ def dependencies = [ def testDependencies = [ instrumentationRunner: 'androidx.test.runner.AndroidJUnitRunner', junit: 'junit:junit:4.12', - robolectric: 'org.robolectric:robolectric:4.0' + robolectric: 'org.robolectric:robolectric:4.3' ] ext.config = [ diff --git a/library/build.gradle b/library/build.gradle index f746bb3..6ef5d16 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -57,6 +57,11 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + // For Kotlin projects + kotlinOptions { + freeCompilerArgs += ['-module-name', "io.karn.notify"] + jvmTarget = "1.8" + } lintOptions.abortOnError false diff --git a/library/src/main/java/io/karn/notify/Notify.kt b/library/src/main/java/io/karn/notify/Notify.kt index 8c76d8e..c314b3b 100644 --- a/library/src/main/java/io/karn/notify/Notify.kt +++ b/library/src/main/java/io/karn/notify/Notify.kt @@ -108,8 +108,22 @@ class Notify internal constructor(internal var context: Context) { /** * Cancel an existing notification with a particular id. */ + @Deprecated(message = "NotificationManager might not have been initialized and can throw a NullPointerException -- provide a context.", + replaceWith = ReplaceWith("Notify.cancelNotification(context, id)")) + @Throws(NullPointerException::class) fun cancelNotification(id: Int) { - return NotificationInterop.cancelNotification(Notify.defaultConfig.notificationManager!!, id) + return NotificationInterop.cancelNotification(defaultConfig.notificationManager!!, id) + } + + /** + * Cancel an existing notification with a particular id. + */ + fun cancelNotification(context: Context, id: Int) { + if (defaultConfig.notificationManager == null) { + defaultConfig.notificationManager = context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + return NotificationInterop.cancelNotification(defaultConfig.notificationManager!!, id) } } @@ -145,6 +159,6 @@ class Notify internal constructor(internal var context: Context) { * this returned integer to make updates or to cancel the notification. */ internal fun show(id: Int?, builder: NotificationCompat.Builder): Int { - return NotificationInterop.showNotification(Notify.defaultConfig.notificationManager!!, id, builder) + return NotificationInterop.showNotification(defaultConfig.notificationManager!!, id, builder) } } diff --git a/library/src/main/java/io/karn/notify/NotifyCreator.kt b/library/src/main/java/io/karn/notify/NotifyCreator.kt index 4e16a3d..abfd856 100644 --- a/library/src/main/java/io/karn/notify/NotifyCreator.kt +++ b/library/src/main/java/io/karn/notify/NotifyCreator.kt @@ -24,11 +24,14 @@ package io.karn.notify +import android.annotation.TargetApi +import android.os.Build import androidx.core.app.NotificationCompat import io.karn.notify.entities.Payload import io.karn.notify.internal.RawNotification import io.karn.notify.internal.utils.Action import io.karn.notify.internal.utils.Errors +import io.karn.notify.internal.utils.Experimental import io.karn.notify.internal.utils.NotifyScopeMarker /** @@ -42,7 +45,9 @@ class NotifyCreator internal constructor(private val notify: Notify) { private var header = Notify.defaultConfig.defaultHeader.copy() private var content: Payload.Content = Payload.Content.Default() private var actions: ArrayList? = null + private var bubblize: Payload.Bubble? = null private var stackable: Payload.Stackable? = null + private var progress: Payload.Progress = Payload.Progress() /** * Scoped function for modifying the Metadata of a notification, such as click intents, @@ -63,8 +68,7 @@ class NotifyCreator internal constructor(private val notify: Notify) { */ fun alerting(key: String, init: Payload.Alerts.() -> Unit): NotifyCreator { // Clone object and assign the key. - this.alerts = this.alerts.copy(channelKey = key) - this.alerts.init() + this.alerts = this.alerts.copy(channelKey = key).also(init) return this } @@ -78,12 +82,16 @@ class NotifyCreator internal constructor(private val notify: Notify) { return this } + fun progress(init: Payload.Progress.() -> Unit): NotifyCreator { + this.progress.init() + return this + } + /** * Scoped function for modifying the content of a 'Default' notification. */ fun content(init: Payload.Content.Default.() -> Unit): NotifyCreator { - this.content = Payload.Content.Default() - (this.content as Payload.Content.Default).init() + this.content = Payload.Content.Default().also(init) return this } @@ -91,8 +99,7 @@ class NotifyCreator internal constructor(private val notify: Notify) { * Scoped function for modifying the content of a 'TextList' notification. */ fun asTextList(init: Payload.Content.TextList.() -> Unit): NotifyCreator { - this.content = Payload.Content.TextList() - (this.content as Payload.Content.TextList).init() + this.content = Payload.Content.TextList().also(init) return this } @@ -100,8 +107,7 @@ class NotifyCreator internal constructor(private val notify: Notify) { * Scoped function for modifying the content of a 'BigText' notification. */ fun asBigText(init: Payload.Content.BigText.() -> Unit): NotifyCreator { - this.content = Payload.Content.BigText() - (this.content as Payload.Content.BigText).init() + this.content = Payload.Content.BigText().also(init) return this } @@ -109,8 +115,7 @@ class NotifyCreator internal constructor(private val notify: Notify) { * Scoped function for modifying the content of a 'BigPicture' notification. */ fun asBigPicture(init: Payload.Content.BigPicture.() -> Unit): NotifyCreator { - this.content = Payload.Content.BigPicture() - (this.content as Payload.Content.BigPicture).init() + this.content = Payload.Content.BigPicture().also(init) return this } @@ -118,8 +123,7 @@ class NotifyCreator internal constructor(private val notify: Notify) { * Scoped function for modifying the content of a 'Message' notification. */ fun asMessage(init: Payload.Content.Message.() -> Unit): NotifyCreator { - this.content = Payload.Content.Message() - (this.content as Payload.Content.Message).init() + this.content = Payload.Content.Message().also(init) return this } @@ -128,8 +132,46 @@ class NotifyCreator internal constructor(private val notify: Notify) { * relies on adding standard notification Action objects. */ fun actions(init: ArrayList.() -> Unit): NotifyCreator { - this.actions = ArrayList() - (this.actions as ArrayList).init() + this.actions = ArrayList().also(init) + return this + } + + /** + * Scoped function for modifying the behaviour of 'Bubble' notifications. The transformation + * relies on the 'bubbleIcon' and 'targetActivity' values which are used to create the Bubble. + * + * Note that Bubbles have very specific restrictions in terms of when they can be shown to the + * user. In particular, at least one of the following conditions must be met before the + * notification is shown. + * - The notification uses MessagingStyle, and has a Person added. + * - The notification is from a call to Service.startForeground, has a category of + * CATEGORY_CALL, and has a Person added. + * - The app is in the foreground when the notification is sent. + * + * In addition, the 'Bubbles' flag has to be enabled from the Android Developer Options in the + * Settings of the Device for the notifications to be shown as Bubbles. + * + * Finally, the 'targetActivity' should also have the following attributes to correctly show a + * Bubble notification. + * + * android:documentLaunchMode="always" + * android:resizeableActivity="true" + * android:screenOrientation="portrait" + * + */ + @Experimental + @TargetApi(Build.VERSION_CODES.Q) + fun bubblize(init: Payload.Bubble.() -> Unit): NotifyCreator { + this.bubblize = Payload.Bubble().also(init) + + this.bubblize!! + .takeUnless { it.bubbleIcon == null } + ?: throw IllegalArgumentException(Errors.INVALID_BUBBLE_ICON_ERROR) + + this.bubblize!! + .takeUnless { it.targetActivity == null } + ?: throw IllegalArgumentException(Errors.INVALID_BUBBLE_TARGET_ACTIVITY_ERROR) + return this } @@ -138,14 +180,11 @@ class NotifyCreator internal constructor(private val notify: Notify) { * relies on the 'summaryText' of a stackable notification. */ fun stackable(init: Payload.Stackable.() -> Unit): NotifyCreator { - this.stackable = Payload.Stackable() - (this.stackable as Payload.Stackable).init() + this.stackable = Payload.Stackable().also(init) - this.stackable - ?.takeIf { it.key.isNullOrEmpty() } - ?.apply { - throw IllegalArgumentException(Errors.INVALID_STACK_KEY_ERROR) - } + this.stackable!! + .takeUnless { it.key.isNullOrEmpty() } + ?: throw IllegalArgumentException(Errors.INVALID_STACK_KEY_ERROR) return this } @@ -155,12 +194,12 @@ class NotifyCreator internal constructor(private val notify: Notify) { * transformations (if any) from the {@see NotifyCreator} builder object. */ fun asBuilder(): NotificationCompat.Builder { - return notify.asBuilder(RawNotification(meta, alerts, header, content, stackable, actions)) + return notify.asBuilder(RawNotification(meta, alerts, header, content, bubblize, stackable, actions, progress)) } /** - * Delegate a {@see Notification.Builder} object to the Notify NotificationInterop class which - * builds and displays the notification. + * Delegate a {@see Notification.Builder} object to the NotificationInterop class which builds + * and displays the notification. * * This is a terminal operation. * @@ -170,10 +209,27 @@ class NotifyCreator internal constructor(private val notify: Notify) { * @return An integer corresponding to the ID of the system notification. Any updates should use * this returned integer to make updates or to cancel the notification. */ - fun show(id: Int? = null): Int { + @Deprecated(message = "Removed optional argument to alleviate confusion on ID that is used to create notification", + replaceWith = ReplaceWith( + "Notify.show()", + "io.karn.notify.Notify")) + fun show(id: Int?): Int { return notify.show(id, asBuilder()) } + /** + * Delegate a @see{ Notification.Builder} object to the NotificationInterop class which builds + * and displays the notification. + * + * This is a terminal operation. + * + * @return An integer corresponding to the ID of the system notification. Any updates should use + * this returned integer to make updates or to cancel the notification. + */ + fun show(): Int { + return notify.show(null, asBuilder()) + } + /** * Cancel an existing notification given an ID. * @@ -181,8 +237,14 @@ class NotifyCreator internal constructor(private val notify: Notify) { * which provides the correct encapsulation of the this `cancel` function. */ @Deprecated(message = "Exposes function under the incorrect API -- NotifyCreator is reserved strictly for notification construction.", - replaceWith = ReplaceWith("Notify.cancelNotification(id)", "io.karn.notify.Notify")) + replaceWith = ReplaceWith( + "Notify.cancelNotification(context, id)", + "android.content.Context", "io.karn.notify.Notify")) + @Throws(NullPointerException::class) fun cancel(id: Int) { + // This should be safe to call from here because the Notify.with(context) function call + // would have initialized the NotificationManager object. In any case, the function has been + // annotated as one which can throw a NullPointerException. return Notify.cancelNotification(id) } } diff --git a/library/src/main/java/io/karn/notify/entities/NotifyConfig.kt b/library/src/main/java/io/karn/notify/entities/NotifyConfig.kt index 41d1d9e..15c4092 100644 --- a/library/src/main/java/io/karn/notify/entities/NotifyConfig.kt +++ b/library/src/main/java/io/karn/notify/entities/NotifyConfig.kt @@ -39,6 +39,10 @@ data class NotifyConfig( * and notification color.) */ internal var defaultHeader: Payload.Header = Payload.Header(), + /** + * Specifies the default configuration of a progress (e.g the default progress type) + */ + internal var defaultProgress: Payload.Progress = Payload.Progress(), /** * Specifies the default alerting configuration for notifications. */ @@ -55,4 +59,9 @@ data class NotifyConfig( defaultAlerting.init() return this } + + fun progress(init: Payload.Progress.() -> Unit): NotifyConfig { + defaultProgress.init() + return this + } } diff --git a/library/src/main/java/io/karn/notify/entities/Payload.kt b/library/src/main/java/io/karn/notify/entities/Payload.kt index bace5ae..ebc12e8 100644 --- a/library/src/main/java/io/karn/notify/entities/Payload.kt +++ b/library/src/main/java/io/karn/notify/entities/Payload.kt @@ -24,13 +24,16 @@ package io.karn.notify.entities +import android.annotation.TargetApi import android.app.PendingIntent import android.graphics.Bitmap import android.media.RingtoneManager import android.net.Uri +import android.os.Build import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat import io.karn.notify.Notify import io.karn.notify.R import io.karn.notify.internal.utils.Action @@ -64,6 +67,10 @@ sealed class Payload { * notification as required. */ var category: String? = null, + /** + * A string value by which the system with decide how to group messages. + */ + var group: String? = null, /** * Set whether or not this notification is only relevant to the current device. */ @@ -140,7 +147,12 @@ sealed class Payload { * A custom notification sound if any. This is only set on notifications with importance * that is at least [Notify.IMPORTANCE_NORMAL] or higher. */ - var sound: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + var sound: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), + /** + * A Boolean that indicates whether a notification channel + */ + @TargetApi(Build.VERSION_CODES.O) + var showBadge: Boolean = true ) /** @@ -166,6 +178,30 @@ sealed class Payload { var showTimestamp: Boolean = true ) + /** + * Contains configuration that is specific to the progress of a notification, inder + */ + class Progress constructor( + + /** + * The default false for a indeterminate horizontal progress in notification. + * If this is true the notification show horizontal progress with exact value + */ + var enablePercentage: Boolean = false, + + /* + * The value of progress percent + * */ + var progressPercent: Int = 0, + + /** + * The default false for simple notiffication + * If this is true the notification show progress + */ + var showProgress: Boolean = false + + ) + /** * Deterministic property assignment for a notification type. */ @@ -272,6 +308,49 @@ sealed class Payload { ) : Content(), SupportsLargeIcon } + /** + * Contains configuration for Android Q Bubbles which are a native implementation of the + * chatheads functionality pioneered by Facebook. The documentation around Bubbles describes + * them as follows: + * "Bubbles let users easily multi-task from anywhere on their device. They are designed to be + * an alternative to using SYSTEM_ALERT_WINDOW." + * + * Bubbles | Android Developers + * + * Note that you can only have a total of five Bubbles being shown at any time. + */ + data class Bubble( + /** + * A pending intent which contains a reference to the Activity that is being created + * once the bubble has been created. + */ + var targetActivity: PendingIntent? = null, + /** + * A pending intent which is to be fired when the Bubble is dismissed/closed. + */ + var clearIntent: PendingIntent? = null, + /** + * A configuration which defines the height of the container which holds the Activity + * that is being show. + */ + var desiredHeight: Int = 600, + /** + * The icon which will be used by the bubble. + */ + var bubbleIcon: IconCompat? = null, + /** + * Flag to auto-expand the Bubble to create and display the Activity defined by the + * PendingIntent. This flag has no effect when the app is in the background. + */ + var autoExpand: Boolean = false, + /** + * Flag to hide the initial notification in the notification shade which the + * notification is shown from the foreground. This flag has no effect when the app is in + * the background and the initial notification is shown regardless. + */ + var suppressInitialNotification: Boolean = false + ) + /** * Contains configuration specific to the manual stacking behaviour of a notification. * Manual stacking occurs for all notifications with the same key, additionally the summary diff --git a/library/src/main/java/io/karn/notify/internal/NotificationChannelInterop.kt b/library/src/main/java/io/karn/notify/internal/NotificationChannelInterop.kt index 38d5eed..3f049fc 100644 --- a/library/src/main/java/io/karn/notify/internal/NotificationChannelInterop.kt +++ b/library/src/main/java/io/karn/notify/internal/NotificationChannelInterop.kt @@ -71,6 +71,8 @@ internal object NotificationChannelInterop { setSound(it, android.media.AudioAttributes.Builder().build()) } + setShowBadge(alerting.showBadge) + Unit } diff --git a/library/src/main/java/io/karn/notify/internal/NotificationInterop.kt b/library/src/main/java/io/karn/notify/internal/NotificationInterop.kt index 9d52c15..bd0c0fc 100644 --- a/library/src/main/java/io/karn/notify/internal/NotificationInterop.kt +++ b/library/src/main/java/io/karn/notify/internal/NotificationInterop.kt @@ -105,9 +105,7 @@ internal object NotificationInterop { .setContentText(Utils.getAsSecondaryFormattedText(payload.stackable.summaryDescription?.invoke(lines.size))) // Attach the stack click handler. .setContentIntent(payload.stackable.clickIntent) - .extend( - NotifyExtender().setStacked(true) - ) + .extend(NotifyExtender().setStacked(true)) // Clear the current set of actions and re-apply the stackable actions. builder.mActions.clear() @@ -139,6 +137,8 @@ internal object NotificationInterop { // The category of the notification which allows android to prioritize the // notification as required. .setCategory(payload.meta.category) + // Set the key by which this notification will be grouped. + .setGroup(payload.meta.group) // Set whether or not this notification is only relevant to the current device. .setLocalOnly(payload.meta.localOnly) // Set whether this notification is sticky. @@ -146,6 +146,11 @@ internal object NotificationInterop { // The duration of time after which the notification is automatically dismissed. .setTimeoutAfter(payload.meta.timeout) + if (payload.progress.showProgress) { + if (payload.progress.enablePercentage) builder.setProgress(100,payload.progress.progressPercent,false) + else builder.setProgress(0,0,true) + } + // Add contacts if any -- will help display prominently if possible. payload.meta.contacts.takeIf { it.isNotEmpty() }?.forEach { builder.addPerson(it) @@ -207,6 +212,19 @@ internal object NotificationInterop { } } + payload.bubblize + ?.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q } + ?.also { + builder.bubbleMetadata = NotificationCompat.BubbleMetadata.Builder() + .setDesiredHeight(it.desiredHeight) + .setIntent(it.targetActivity!!) + .setIcon(it.bubbleIcon!!) + .setAutoExpandBubble(it.autoExpand) + .setSuppressNotification(it.suppressInitialNotification) + .setDeleteIntent(it.clearIntent) + .build() + } + var style: NotificationCompat.Style? = null payload.stackable?.let { diff --git a/library/src/main/java/io/karn/notify/internal/RawNotification.kt b/library/src/main/java/io/karn/notify/internal/RawNotification.kt index 7fc6144..eb196b9 100644 --- a/library/src/main/java/io/karn/notify/internal/RawNotification.kt +++ b/library/src/main/java/io/karn/notify/internal/RawNotification.kt @@ -32,6 +32,8 @@ internal data class RawNotification( internal val alerting: Payload.Alerts, internal val header: Payload.Header, internal val content: Payload.Content, + internal val bubblize: Payload.Bubble?, internal val stackable: Payload.Stackable?, - internal val actions: ArrayList? + internal val actions: ArrayList?, + internal val progress: Payload.Progress ) diff --git a/library/src/main/java/io/karn/notify/internal/utils/Annotations.kt b/library/src/main/java/io/karn/notify/internal/utils/Annotations.kt index 411a409..619ed14 100644 --- a/library/src/main/java/io/karn/notify/internal/utils/Annotations.kt +++ b/library/src/main/java/io/karn/notify/internal/utils/Annotations.kt @@ -27,6 +27,11 @@ package io.karn.notify.internal.utils import androidx.annotation.IntDef import io.karn.notify.Notify +/** + * Denotes features which are considered experimental and are subject to change without notice. + */ +annotation class Experimental + @DslMarker annotation class NotifyScopeMarker diff --git a/library/src/main/java/io/karn/notify/internal/utils/Errors.kt b/library/src/main/java/io/karn/notify/internal/utils/Errors.kt index f42dea2..b3ba05a 100644 --- a/library/src/main/java/io/karn/notify/internal/utils/Errors.kt +++ b/library/src/main/java/io/karn/notify/internal/utils/Errors.kt @@ -26,4 +26,6 @@ package io.karn.notify.internal.utils internal object Errors { const val INVALID_STACK_KEY_ERROR = "Invalid stack key provided." + const val INVALID_BUBBLE_ICON_ERROR = "Invalid bubble icon provided." + const val INVALID_BUBBLE_TARGET_ACTIVITY_ERROR = "Invalid target activity provided." } diff --git a/library/src/main/java/io/karn/notify/internal/utils/Utils.kt b/library/src/main/java/io/karn/notify/internal/utils/Utils.kt index 62b341c..e10e990 100644 --- a/library/src/main/java/io/karn/notify/internal/utils/Utils.kt +++ b/library/src/main/java/io/karn/notify/internal/utils/Utils.kt @@ -25,11 +25,11 @@ package io.karn.notify.internal.utils import android.text.Html -import java.util.* +import java.util.Random internal object Utils { fun getRandomInt(): Int { - return Random().nextInt(Int.MAX_VALUE - 100) + 100 + return Random(System.currentTimeMillis()).nextInt() } fun getAsSecondaryFormattedText(str: String?): CharSequence? { diff --git a/library/src/test/java/io/karn/notify/NotifyAlertingTest.kt b/library/src/test/java/io/karn/notify/NotifyAlertingTest.kt index 6354e39..d9acf0e 100644 --- a/library/src/test/java/io/karn/notify/NotifyAlertingTest.kt +++ b/library/src/test/java/io/karn/notify/NotifyAlertingTest.kt @@ -27,6 +27,7 @@ package io.karn.notify import android.graphics.Color import android.media.RingtoneManager import android.os.Build +import android.provider.Settings import androidx.core.app.NotificationCompat import org.junit.After import org.junit.Assert @@ -87,6 +88,8 @@ class NotifyAlertingTest : NotifyTestBase() { Assert.assertEquals(testAlerting.channelImportance + 3, shadowChannel.importance) // Assert.assertEquals(testLightColor, shadowChannel.lightColor) Assert.assertNull(shadowChannel.vibrationPattern) + Assert.assertEquals(Settings.System.DEFAULT_NOTIFICATION_URI, shadowChannel.sound) + Assert.assertTrue(shadowChannel.canShowBadge()) Assert.assertEquals(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), shadowChannel.sound) } @@ -112,6 +115,7 @@ class NotifyAlertingTest : NotifyTestBase() { lightColor = testLightColor vibrationPattern = testVibrationPattern sound = testSound + // 'group' not available in N } .content { title = "New dessert menu" @@ -151,6 +155,7 @@ class NotifyAlertingTest : NotifyTestBase() { lightColor = testLightColor vibrationPattern = testVibrationPattern sound = testSound + showBadge = false } .content { title = "New dessert menu" @@ -167,5 +172,6 @@ class NotifyAlertingTest : NotifyTestBase() { // Assert.assertEquals(testLightColor, shadowChannel.lightColor) Assert.assertEquals(testVibrationPattern, shadowChannel.vibrationPattern.toList()) Assert.assertEquals(testSound, shadowChannel.sound) + Assert.assertFalse(shadowChannel.canShowBadge()) } } diff --git a/library/src/test/java/io/karn/notify/NotifyBubblizeTest.kt b/library/src/test/java/io/karn/notify/NotifyBubblizeTest.kt new file mode 100644 index 0000000..2ee830c --- /dev/null +++ b/library/src/test/java/io/karn/notify/NotifyBubblizeTest.kt @@ -0,0 +1,106 @@ +/* + * MIT License + * + * Copyright (c) 2018 Karn Saheb + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.karn.notify + +import android.app.PendingIntent +import android.content.Intent +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat +import io.karn.notify.internal.NotificationInterop +import io.karn.notify.internal.NotifyExtender +import io.karn.notify.internal.utils.Action +import io.karn.notify.internal.utils.Errors +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NotifyBubblizeTest : NotifyTestBase() { + + @Test + fun defaultBubblizeTest() { + val testTitle = "New dessert menu" + val testText = "The Cheesecake Factory has a new dessert for you to try!" + + val notification = Notify.with(this.context) + .content { + title = testTitle + text = testText + } + .asBuilder() + .build() + + Assert.assertEquals(testTitle, notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)) + Assert.assertEquals(testText, notification.extras.getCharSequence(NotificationCompat.EXTRA_TEXT)) + } + + @Test + fun invalidRequiredArgsTest() { + val testTitle = "New dessert menu" + val testText = "The Cheesecake Factory has a new dessert for you to try!" + + var exceptionThrown: IllegalArgumentException? = null + + try { + Notify.with(this.context) + .content { + title = testTitle + text = testText + } + .bubblize { + // bubbleIcon + targetActivity = PendingIntent.getActivity(this@NotifyBubblizeTest.context, 0, Intent(Settings.ACTION_SETTINGS), 0) + } + .asBuilder() + .build() + } catch (e: IllegalArgumentException) { + exceptionThrown = e + } + + Assert.assertNotNull(exceptionThrown) + Assert.assertEquals(Errors.INVALID_BUBBLE_ICON_ERROR, exceptionThrown?.message) + + try { + Notify.with(this.context) + .content { + title = testTitle + text = testText + } + .bubblize { + bubbleIcon = IconCompat.createWithResource(this@NotifyBubblizeTest.context, R.drawable.ic_app_icon) + // targetActivity + } + .asBuilder() + .build() + } catch (e: IllegalArgumentException) { + exceptionThrown = e + } + + Assert.assertNotNull(exceptionThrown) + Assert.assertEquals(Errors.INVALID_BUBBLE_TARGET_ACTIVITY_ERROR, exceptionThrown?.message) + } +} diff --git a/library/src/test/java/io/karn/notify/NotifyContentTest.kt b/library/src/test/java/io/karn/notify/NotifyContentTest.kt index f2f7198..22ec4e1 100644 --- a/library/src/test/java/io/karn/notify/NotifyContentTest.kt +++ b/library/src/test/java/io/karn/notify/NotifyContentTest.kt @@ -129,7 +129,7 @@ class NotifyContentTest { Assert.assertEquals(testTitle, notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE).toString()) Assert.assertEquals(testText, notification.extras.getCharSequence(NotificationCompat.EXTRA_TEXT).toString()) - Assert.assertEquals(testLines, notification.extras.getCharSequenceArray(NotificationCompat.EXTRA_TEXT_LINES).toList()) + Assert.assertEquals(testLines, notification.extras.getCharSequenceArray(NotificationCompat.EXTRA_TEXT_LINES)?.toList()) } @Test @@ -185,10 +185,10 @@ class NotifyContentTest { Assert.assertEquals(testCollapsedText, notification.extras.getCharSequence(NotificationCompat.EXTRA_SUMMARY_TEXT)) // Assert.assertEquals(context.resources.getDrawable(testLargeIconResID, context.theme), notification.getLargeIcon().loadDrawable(this.context)) - val actualIcon: Icon = notification.extras.getParcelable(NotificationCompat.EXTRA_LARGE_ICON) + val actualIcon: Icon? = notification.extras.getParcelable(NotificationCompat.EXTRA_LARGE_ICON) Assert.assertNotNull(actualIcon) - val actualImage: Bitmap = notification.extras.getParcelable(NotificationCompat.EXTRA_PICTURE) + val actualImage: Bitmap? = notification.extras.getParcelable(NotificationCompat.EXTRA_PICTURE) Assert.assertNotNull(actualImage) Assert.assertEquals(testImage, actualImage) @@ -227,8 +227,9 @@ class NotifyContentTest { Assert.assertEquals(testUserDisplayName, notification.extras.getCharSequence(NotificationCompat.EXTRA_SELF_DISPLAY_NAME)) Assert.assertEquals(testConversationTitle, notification.extras.getCharSequence(NotificationCompat.EXTRA_CONVERSATION_TITLE)) - val actualMessages = getMessagesFromBundleArray(notification.extras.getParcelableArray(NotificationCompat.EXTRA_MESSAGES)) - Assert.assertEquals(testMessages.size, actualMessages.size) + val actualMessages = notification.extras.getParcelableArray(NotificationCompat.EXTRA_MESSAGES)?.let { getMessagesFromBundleArray(it) } + Assert.assertNotNull(actualMessages) + Assert.assertEquals(testMessages.size, actualMessages!!.size) actualMessages.forEach { message -> testMessages[actualMessages.indexOf(message)].let { diff --git a/library/src/test/java/io/karn/notify/NotifyMetaTest.kt b/library/src/test/java/io/karn/notify/NotifyMetaTest.kt index 8dbf9cd..47bf711 100644 --- a/library/src/test/java/io/karn/notify/NotifyMetaTest.kt +++ b/library/src/test/java/io/karn/notify/NotifyMetaTest.kt @@ -60,6 +60,7 @@ class NotifyMetaTest : NotifyTestBase() { val testCancelOnClick = false val testCategory = NotificationCompat.CATEGORY_STATUS + val testGroup = "test_group" val testTimeout = 5000L val notification = Notify.with(this.context) @@ -68,6 +69,7 @@ class NotifyMetaTest : NotifyTestBase() { clearIntent = testClearIntent cancelOnClick = testCancelOnClick category = testCategory + group = testGroup timeout = testTimeout people { add("mailto:hello@test.com") @@ -84,6 +86,7 @@ class NotifyMetaTest : NotifyTestBase() { Assert.assertEquals(testClearIntent, notification.deleteIntent) Assert.assertEquals(testCancelOnClick, (notification.flags and NotificationCompat.FLAG_AUTO_CANCEL) != 0) Assert.assertEquals(testCategory, notification.category) + Assert.assertEquals(testGroup, notification.group) Assert.assertEquals(testTimeout, notification.timeoutAfter) Assert.assertEquals(1, notification.extras.getStringArrayList(Notification.EXTRA_PEOPLE_LIST)?.size ?: 0) diff --git a/library/src/test/java/io/karn/notify/NotifyStackableTest.kt b/library/src/test/java/io/karn/notify/NotifyStackableTest.kt index 0dfe9ca..7f00648 100644 --- a/library/src/test/java/io/karn/notify/NotifyStackableTest.kt +++ b/library/src/test/java/io/karn/notify/NotifyStackableTest.kt @@ -176,14 +176,14 @@ class NotifyStackableTest : NotifyTestBase() { .build() // Assert.assertEquals("android.app.Notification\$InboxStyle", notification.extras.getCharSequence(NotificationCompat.EXTRA_TEMPLATE).toString()) - Assert.assertEquals("3" + testSummaryTitle, notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)) + Assert.assertEquals("3$testSummaryTitle", notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)) Assert.assertEquals(testSummaryText, notification.extras.getCharSequence(NotificationCompat.EXTRA_TEXT).toString()) Assert.assertEquals(testKey, NotifyExtender.getKey(notification.extras)) Assert.assertEquals(testClickIntent, notification.contentIntent) Assert.assertEquals(testSummaryContent, NotifyExtender.getExtensions(notification.extras).getCharSequence(NotifyExtender.SUMMARY_CONTENT)) Assert.assertEquals( - Arrays.asList(testSummaryContent, testSummaryContent, testSummaryContent), - notification.extras.getCharSequenceArray(NotificationCompat.EXTRA_TEXT_LINES).toList()) + listOf(testSummaryContent, testSummaryContent, testSummaryContent), + notification.extras.getCharSequenceArray(NotificationCompat.EXTRA_TEXT_LINES)?.toList()) Assert.assertEquals(1, notification.actions.size) Assert.assertEquals(testActionText, notification.actions.first().title) Assert.assertEquals(testActionIntent, notification.actions.first().actionIntent) diff --git a/library/src/test/java/io/karn/notify/NotifyTest.kt b/library/src/test/java/io/karn/notify/NotifyTest.kt index 5b5720d..45d4505 100644 --- a/library/src/test/java/io/karn/notify/NotifyTest.kt +++ b/library/src/test/java/io/karn/notify/NotifyTest.kt @@ -125,7 +125,7 @@ class NotifyTest : NotifyTestBase() { Assert.assertEquals(1, NotificationInterop.getActiveNotifications(shadowNotificationManager).size) - Notify.cancelNotification(notificationId) + Notify.cancelNotification(context, notificationId) Assert.assertEquals(0, NotificationInterop.getActiveNotifications(shadowNotificationManager).size) } diff --git a/library/src/test/resources/robolectric.properties b/library/src/test/resources/robolectric.properties new file mode 100644 index 0000000..4bc073d --- /dev/null +++ b/library/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +# Target SDK 28. Robolectric doesn't currently support Android Q. +sdk=28 diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 2547d4c..b25b6e2 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -26,14 +26,11 @@ - - + + + + diff --git a/sample/src/main/java/presentation/BubbleActivity.kt b/sample/src/main/java/presentation/BubbleActivity.kt new file mode 100644 index 0000000..507cfdf --- /dev/null +++ b/sample/src/main/java/presentation/BubbleActivity.kt @@ -0,0 +1,37 @@ +/* + * MIT License + * + * Copyright (c) 2018 Karn Saheb + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package presentation + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import io.karn.notify.sample.R + +class BubbleActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_bubble) + } +} diff --git a/sample/src/main/java/presentation/MainActivity.kt b/sample/src/main/java/presentation/MainActivity.kt index 955eef5..73b773b 100644 --- a/sample/src/main/java/presentation/MainActivity.kt +++ b/sample/src/main/java/presentation/MainActivity.kt @@ -24,15 +24,20 @@ package presentation +import android.app.PendingIntent +import android.content.Intent import android.graphics.BitmapFactory import android.graphics.Color +import android.os.Build import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat import io.karn.notify.Notify import io.karn.notify.sample.R -import java.util.* +import java.util.Arrays class MainActivity : AppCompatActivity() { @@ -51,8 +56,7 @@ class MainActivity : AppCompatActivity() { } fun notifyDefault(view: View) { - Notify - .with(this) + Notify.with(this) .content { title = "New dessert menu" text = "The Cheesecake Factory has a new dessert for you to try!" @@ -67,8 +71,7 @@ class MainActivity : AppCompatActivity() { } fun notifyTextList(view: View) { - Notify - .with(this) + Notify.with(this) .asTextList { lines = Arrays.asList("New! Fresh Strawberry Cheesecake.", "New! Salted Caramel Cheesecake.", @@ -81,8 +84,7 @@ class MainActivity : AppCompatActivity() { } fun notifyBigText(view: View) { - Notify - .with(this) + Notify.with(this) .asBigText { title = "Chocolate brownie sundae" text = "Try our newest dessert option!" @@ -95,8 +97,7 @@ class MainActivity : AppCompatActivity() { } fun notifyBigPicture(view: View) { - Notify - .with(this) + Notify.with(this) .asBigPicture { title = "Chocolate brownie sundae" text = "Get a look at this amazing dessert!" @@ -107,8 +108,7 @@ class MainActivity : AppCompatActivity() { } fun notifyMessage(view: View) { - Notify - .with(this) + Notify.with(this) .asMessage { userDisplayName = "Karn" conversationTitle = "Sundae chat" @@ -126,4 +126,57 @@ class MainActivity : AppCompatActivity() { } .show() } + + fun notifyBubble(view: View) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Toast.makeText(this, "Notification Bubbles are only supported on a device running Android Q or later.", Toast.LENGTH_SHORT).show() + return + } + + Notify.with(this) + .content { + title = "New dessert menu" + text = "The Cheesecake Factory has a new dessert for you to try!" + } + .bubblize { + // Create bubble intent + val target = Intent(this@MainActivity, BubbleActivity::class.java) + val bubbleIntent = PendingIntent.getActivity(this@MainActivity, 0, target, 0 /* flags */) + + bubbleIcon = IconCompat.createWithResource(this@MainActivity, R.drawable.ic_app_icon) + targetActivity = bubbleIntent + suppressInitialNotification = true + } + .show() + } + + fun notifyIndeterminateProgress(view: View) { + + Notify.with(this) + .asBigText { + title = "Uploading files" + expandedText = "The files are being uploaded!" + bigText = "Daft Punk - Get Lucky.flac is uploading to server /music/favorites" + } + .progress { + showProgress = true + } + .show() + } + + fun notifyDeterminateProgress(view: View) { + + Notify.with(this) + .asBigText { + title = "Bitcoin payment processing" + expandedText = "Your payment was sent to the Bitcoin network" + bigText = "Your payment #0489 is being confirmed 2/4" + } + .progress { + showProgress = true + enablePercentage = true + progressPercent = 30 + } + .show() + } } diff --git a/sample/src/main/res/layout/activity_bubble.xml b/sample/src/main/res/layout/activity_bubble.xml new file mode 100644 index 0000000..675201a --- /dev/null +++ b/sample/src/main/res/layout/activity_bubble.xml @@ -0,0 +1,46 @@ + + + + + + + + + diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml index 9815a2c..5c3355e 100644 --- a/sample/src/main/res/layout/activity_main.xml +++ b/sample/src/main/res/layout/activity_main.xml @@ -72,4 +72,25 @@ android:layout_height="wrap_content" android:onClick="notifyMessage" android:text="Notify messages!" /> + + + + + +