diff --git a/CHANGELOG.md b/CHANGELOG.md index bef1683..ccff23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## 29.1.0-rc.0 + +* Added: optional `method` param on `Client.chunkedUpload()` to set the upload HTTP method +* Added: background push notifications render the server `notification` title, body, and image +* Updated: `Avatars.updatePhoto()` uploads via `PUT` +* Fixed: an exactly-chunk-sized file uploads in a single request + ## 29.0.0 * Breaking: minimum Android SDK raised from 23 to 24 (Android 7.0) diff --git a/README.md b/README.md index aeffd88..bb53402 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ repositories { Next, add the dependency to your project's `build.gradle(.kts)` file: ```groovy -implementation("io.appwrite:sdk-for-android:29.0.0") +implementation("io.appwrite:sdk-for-android:29.1.0-rc.0") ``` ### Maven @@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file: io.appwrite sdk-for-android - 29.0.0 + 29.1.0-rc.0 ``` @@ -78,7 +78,10 @@ app every 15 to 60 seconds to reconnect, and the broker replays what was sent in (`client.setSession(...)`), not a short-lived JWT. When no in-app callback is listening, each message is posted as a notification that opens -your launch activity with `Push.EXTRA_TOPIC` and `Push.EXTRA_PAYLOAD` in its extras. Set its +your launch activity with `Push.EXTRA_TOPIC` and `Push.EXTRA_PAYLOAD` in its extras. It shows the +title, body and image sent with `createPush`, falling back to the subscription's `title` and the +raw payload for other messages. On Android 13 and later it needs the `POST_NOTIFICATIONS` +permission: call `Push.requestNotificationPermission(activity)` before subscribing. Set its icon with `` in your ``. To handle these messages in code, subclass `PushReceiver` and declare it with the `io.appwrite.push.MESSAGE` action (see its docs). diff --git a/library/src/main/java/io/appwrite/Client.kt b/library/src/main/java/io/appwrite/Client.kt index c364e83..1715950 100644 --- a/library/src/main/java/io/appwrite/Client.kt +++ b/library/src/main/java/io/appwrite/Client.kt @@ -101,7 +101,7 @@ class Client @JvmOverloads constructor( "x-sdk-name" to "Android", "x-sdk-platform" to "client", "x-sdk-language" to "android", - "x-sdk-version" to "29.0.0", + "x-sdk-version" to "29.1.0-rc.0", "x-appwrite-response-format" to "2.3.0" ) config = mutableMapOf() @@ -538,6 +538,7 @@ class Client @JvmOverloads constructor( * @param path * @param headers * @param params + * @param method HTTP method the upload endpoint is declared with; resuming by upload ID always uses GET * * @return [T] */ @@ -551,10 +552,11 @@ class Client @JvmOverloads constructor( paramName: String, idParamName: String? = null, onProgress: ((UploadProgress) -> Unit)? = null, + method: String = "POST", ): T { if (params[paramName] == null) { return call( - method = "POST", + method = method, path, headers, params, @@ -573,7 +575,7 @@ class Client @JvmOverloads constructor( else -> throw UnsupportedOperationException() } - if (size < CHUNK_SIZE || responseType == String::class.java) { + if (size <= CHUNK_SIZE || responseType == String::class.java) { val data = when (input.sourceType) { "file", "path" -> File(input.path).asRequestBody() "bytes" -> (input.data as ByteArray).toRequestBody(input.mimeType.toMediaType()) @@ -585,7 +587,7 @@ class Client @JvmOverloads constructor( data ) return call( - method = "POST", + method = method, path, headers, params, @@ -656,7 +658,7 @@ class Client @JvmOverloads constructor( ) val chunkResult = call( - method = "POST", + method = method, path, chunkHeaders, chunkParams, diff --git a/library/src/main/java/io/appwrite/services/Avatars.kt b/library/src/main/java/io/appwrite/services/Avatars.kt index 3b11d16..2223223 100644 --- a/library/src/main/java/io/appwrite/services/Avatars.kt +++ b/library/src/main/java/io/appwrite/services/Avatars.kt @@ -325,6 +325,7 @@ class Avatars(client: Client) : Service(client) { paramName = paramName, idParamName = idParamName, onProgress = onProgress, + method = "PUT", ) } diff --git a/library/src/main/java/io/appwrite/services/PushBackground.kt b/library/src/main/java/io/appwrite/services/PushBackground.kt index 5251734..a550a55 100644 --- a/library/src/main/java/io/appwrite/services/PushBackground.kt +++ b/library/src/main/java/io/appwrite/services/PushBackground.kt @@ -10,6 +10,8 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.ConnectivityManager import android.net.Network import android.os.Build @@ -30,6 +32,8 @@ import org.json.JSONArray import org.json.JSONObject import java.io.File import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL import java.util.UUID import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.ExecutionException @@ -41,6 +45,9 @@ import java.util.concurrent.atomic.AtomicInteger private const val LOG_TAG = "AppwritePush" +/** What a message's `notification` block asks a background notification to show; [present] is false without one. */ +internal data class PushNotificationContent(val present: Boolean, val title: String?, val body: String?, val image: String?) + /** A background subscription saved across restarts: its filter, QoS choice and notification title. */ internal data class PushEntry( val filter: String, @@ -108,6 +115,7 @@ internal object PushBackground { private const val HEARTBEAT_FAILURES = 3 private const val HEARTBEAT_RESET_MS = 3 * 24 * 60 * 60 * 1_000L private const val REQUEST_TIMEOUT_SECONDS = 10L + private const val IMAGE_TIMEOUT_MS = 5_000 private const val CONNECT_TIMEOUT_SECONDS = 20L // How long a message waits for a listener that acknowledges it itself (the React Native and @@ -647,9 +655,11 @@ internal object PushBackground { try { // The app's PushReceiver gets only messages no live callback received. val handled = matching.isEmpty() && deliverToReceivers(context, message) + val content = notificationContent(message) val titles = matching.filter { it.background }.map { it.title ?: message.topic } + if (handled) emptyList() else entries.map { it.title ?: message.topic } - titles.distinct().forEach { notify(context, message, it) } + // A title the server sent replaces every subscription's, so one notification is posted. + titles.map { content.title ?: it }.distinct().forEach { notify(context, message, it, content) } } finally { releaseWakeLock(wakeLock) } @@ -679,19 +689,29 @@ internal object PushBackground { return handled } - /** Post a notification for [message] that opens the app, with the message in its extras. */ - fun notify(context: Context, message: PushMessage, title: String) { + /** + * Post a notification for [message] that opens the app, with the message in its extras. It shows + * the server's title, body and image from [content], falling back to [title] and the raw payload. + */ + fun notify(context: Context, message: PushMessage, title: String, content: PushNotificationContent = notificationContent(message)) { val manager = NotificationManagerCompat.from(context) if (!manager.areNotificationsEnabled()) { return } createChannels(context) val id = 31 * message.hashCode() + title.hashCode() + val body = content.body ?: message.data.takeIf { !content.present } val builder = NotificationCompat.Builder(context, CHANNEL_ID) - .setContentTitle(title) - .setContentText(message.data) + .setContentTitle(content.title ?: title) .setSmallIcon(notificationIcon(context)) .setAutoCancel(true) + if (body != null) { + builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body)) + } + content.image?.let { loadImage(it) }?.let { image -> + builder.setLargeIcon(image) + .setStyle(NotificationCompat.BigPictureStyle().bigPicture(image).bigLargeIcon(null as Bitmap?).setSummaryText(body)) + } context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch -> launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) .putExtra(EXTRA_TOPIC, message.topic) @@ -707,6 +727,36 @@ internal object PushBackground { } } + /** The server's `notification` block in [message]: nulls when the payload has none or is not JSON. */ + fun notificationContent(message: PushMessage): PushNotificationContent { + val notification = runCatching { JSONObject(message.data) }.getOrNull()?.optJSONObject("notification") + ?: return PushNotificationContent(false, null, null, null) + fun field(name: String) = (notification.opt(name) as? String)?.takeIf { it.isNotEmpty() } + return PushNotificationContent(true, field("title"), field("body"), field("image")) + } + + // Downloads notification images, so a slow one is abandoned without holding up delivery. + private val imageLoader = Executors.newCachedThreadPool { runnable -> + Thread(runnable, "AppwritePushImage").apply { isDaemon = true } + } + + // Download a notification image, or null when it cannot be fetched and decoded within + // IMAGE_TIMEOUT_MS overall. On timeout the connection is closed, which ends a read in progress. + private fun loadImage(url: String): Bitmap? { + val connection = runCatching { URL(url).openConnection() as HttpURLConnection }.getOrNull() ?: return null + connection.connectTimeout = IMAGE_TIMEOUT_MS + connection.readTimeout = IMAGE_TIMEOUT_MS + val download = imageLoader.submit { connection.inputStream.use { BitmapFactory.decodeStream(it) } } + return try { + download.get(IMAGE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) + } catch (e: Exception) { + download.cancel(true) + null + } finally { + connection.disconnect() + } + } + /** The ongoing notification the foreground service shows, on its own quiet channel. */ fun ongoingNotification(context: Context) = NotificationCompat.Builder(context, SERVICE_CHANNEL_ID) .also { createChannels(context) } diff --git a/library/src/main/java/io/appwrite/services/Storage.kt b/library/src/main/java/io/appwrite/services/Storage.kt index 6fe1f96..9f8f45b 100644 --- a/library/src/main/java/io/appwrite/services/Storage.kt +++ b/library/src/main/java/io/appwrite/services/Storage.kt @@ -118,6 +118,7 @@ class Storage(client: Client) : Service(client) { paramName = paramName, idParamName = idParamName, onProgress = onProgress, + method = "POST", ) }