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",
)
}