Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file:
<dependency>
<groupId>io.appwrite</groupId>
<artifactId>sdk-for-android</artifactId>
<version>29.0.0</version>
<version>29.1.0-rc.0</version>
</dependency>
</dependencies>
```
Expand Down Expand Up @@ -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 `<meta-data android:name="io.appwrite.push.notification_icon" android:resource="@drawable/..." />`
in your `<application>`. To handle these messages in code, subclass `PushReceiver` and declare
it with the `io.appwrite.push.MESSAGE` action (see its docs).
Expand Down
12 changes: 7 additions & 5 deletions library/src/main/java/io/appwrite/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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]
*/
Expand All @@ -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,
Expand All @@ -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())
Expand All @@ -585,7 +587,7 @@ class Client @JvmOverloads constructor(
data
)
return call(
method = "POST",
method = method,
path,
headers,
params,
Expand Down Expand Up @@ -656,7 +658,7 @@ class Client @JvmOverloads constructor(
)

val chunkResult = call(
method = "POST",
method = method,
path,
chunkHeaders,
chunkParams,
Expand Down
1 change: 1 addition & 0 deletions library/src/main/java/io/appwrite/services/Avatars.kt
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ class Avatars(client: Client) : Service(client) {
paramName = paramName,
idParamName = idParamName,
onProgress = onProgress,
method = "PUT",
)
}

Expand Down
60 changes: 55 additions & 5 deletions library/src/main/java/io/appwrite/services/PushBackground.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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<Bitmap?> { connection.inputStream.use { BitmapFactory.decodeStream(it) } }
Comment thread
hansi-codes[bot] marked this conversation as resolved.
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) }
Expand Down
1 change: 1 addition & 0 deletions library/src/main/java/io/appwrite/services/Storage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ class Storage(client: Client) : Service(client) {
paramName = paramName,
idParamName = idParamName,
onProgress = onProgress,
method = "POST",
)
}

Expand Down
Loading