From 612a267e2e2e023a6e9ca3ea94fcb0a8e4c22fae Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 13 Oct 2026 11:22:09 +0100 Subject: [PATCH 1/2] feat: add analytics events and OAuth2 state for version 29.2.0 --- CHANGELOG.md | 7 ++ README.md | 4 +- .../java/account/create-o-auth-2-session.md | 1 + .../java/account/create-o-auth-2-token.md | 1 + docs/examples/java/analytics/create-event.md | 38 +++++++++ docs/examples/java/teams/create-membership.md | 1 + .../kotlin/account/create-o-auth-2-session.md | 1 + .../kotlin/account/create-o-auth-2-token.md | 1 + .../examples/kotlin/analytics/create-event.md | 27 ++++++ .../kotlin/teams/create-membership.md | 1 + library/src/main/java/io/appwrite/Client.kt | 9 +- .../main/java/io/appwrite/services/Account.kt | 6 ++ .../java/io/appwrite/services/Analytics.kt | 83 +++++++++++++++++++ .../main/java/io/appwrite/services/Avatars.kt | 1 + .../main/java/io/appwrite/services/Teams.kt | 3 + 15 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 docs/examples/java/analytics/create-event.md create mode 100644 docs/examples/kotlin/analytics/create-event.md create mode 100644 library/src/main/java/io/appwrite/services/Analytics.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e32bd1c8..040e3353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## 29.2.0 + +* Added: `analytics.createEvent` in a new `Analytics` service to send tracking events +* Added: optional `state` on `account.createOAuth2Session` and `account.createOAuth2Token`, returned unchanged on the success and failure URLs +* Added: optional `membershipId` on `teams.createMembership` +* Fixed: `avatars.updatePhoto` sends `PUT`, so uploading a profile photo no longer returns 404 + ## 29.1.0 * Added: `account` passkey methods `listPasskeys`, `createPasskey`, `getPasskey`, `updatePasskey`, `deletePasskey` and `updatePasskeyVerification` diff --git a/README.md b/README.md index 535ef1f8..388c6e61 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.1.0") +implementation("io.appwrite:sdk-for-android:29.2.0") ``` ### Maven @@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file: io.appwrite sdk-for-android - 29.1.0 + 29.2.0 ``` diff --git a/docs/examples/java/account/create-o-auth-2-session.md b/docs/examples/java/account/create-o-auth-2-session.md index da547421..f2a07eff 100644 --- a/docs/examples/java/account/create-o-auth-2-session.md +++ b/docs/examples/java/account/create-o-auth-2-session.md @@ -17,6 +17,7 @@ account.createOAuth2Session( "https://example.com", // success (optional) "https://example.com", // failure (optional) List.of(), // scopes (optional) + "", // state (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/account/create-o-auth-2-token.md b/docs/examples/java/account/create-o-auth-2-token.md index ae685850..885c066f 100644 --- a/docs/examples/java/account/create-o-auth-2-token.md +++ b/docs/examples/java/account/create-o-auth-2-token.md @@ -17,6 +17,7 @@ account.createOAuth2Token( "https://example.com", // success (optional) "https://example.com", // failure (optional) List.of(), // scopes (optional) + "", // state (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/analytics/create-event.md b/docs/examples/java/analytics/create-event.md new file mode 100644 index 00000000..e2b8dbe0 --- /dev/null +++ b/docs/examples/java/analytics/create-event.md @@ -0,0 +1,38 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Analytics; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Analytics analytics = new Analytics(client); + +analytics.createEvent( + "", // propertyId + "", // name + "https://example.com", // url + "", // domain (optional) + "", // referrer (optional) + 0L, // screenWidth (optional) + "", // sessionHash (optional) + 0L, // scrollDepth (optional) + 0L, // engagementTime (optional) + List.of(), // props (optional) + "", // userId (optional) + "", // ip (optional) + "", // userAgent (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/teams/create-membership.md b/docs/examples/java/teams/create-membership.md index d7fba7c1..b6d23a6a 100644 --- a/docs/examples/java/teams/create-membership.md +++ b/docs/examples/java/teams/create-membership.md @@ -19,6 +19,7 @@ teams.createMembership( "+12065550100", // phone (optional) "https://example.com", // url (optional) "", // name (optional) + "", // membershipId (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/kotlin/account/create-o-auth-2-session.md b/docs/examples/kotlin/account/create-o-auth-2-session.md index cde941c7..c9b3a92a 100644 --- a/docs/examples/kotlin/account/create-o-auth-2-session.md +++ b/docs/examples/kotlin/account/create-o-auth-2-session.md @@ -15,5 +15,6 @@ account.createOAuth2Session( success = "https://example.com", // (optional) failure = "https://example.com", // (optional) scopes = listOf(), // (optional) + state = "", // (optional) ) ``` diff --git a/docs/examples/kotlin/account/create-o-auth-2-token.md b/docs/examples/kotlin/account/create-o-auth-2-token.md index 5e905649..3d5b0d77 100644 --- a/docs/examples/kotlin/account/create-o-auth-2-token.md +++ b/docs/examples/kotlin/account/create-o-auth-2-token.md @@ -15,5 +15,6 @@ account.createOAuth2Token( success = "https://example.com", // (optional) failure = "https://example.com", // (optional) scopes = listOf(), // (optional) + state = "", // (optional) ) ``` diff --git a/docs/examples/kotlin/analytics/create-event.md b/docs/examples/kotlin/analytics/create-event.md new file mode 100644 index 00000000..820e0bac --- /dev/null +++ b/docs/examples/kotlin/analytics/create-event.md @@ -0,0 +1,27 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Analytics + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val analytics = Analytics(client) + +val result = analytics.createEvent( + propertyId = "", + name = "", + url = "https://example.com", + domain = "", // (optional) + referrer = "", // (optional) + screenWidth = 0, // (optional) + sessionHash = "", // (optional) + scrollDepth = 0, // (optional) + engagementTime = 0, // (optional) + props = listOf(), // (optional) + userId = "", // (optional) + ip = "", // (optional) + userAgent = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/teams/create-membership.md b/docs/examples/kotlin/teams/create-membership.md index 219119af..2f73c039 100644 --- a/docs/examples/kotlin/teams/create-membership.md +++ b/docs/examples/kotlin/teams/create-membership.md @@ -17,5 +17,6 @@ val result = teams.createMembership( phone = "+12065550100", // (optional) url = "https://example.com", // (optional) name = "", // (optional) + membershipId = "", // (optional) ) ``` diff --git a/library/src/main/java/io/appwrite/Client.kt b/library/src/main/java/io/appwrite/Client.kt index 8130478d..32d1c607 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.1.0", + "x-sdk-version" to "29.2.0", "x-appwrite-response-format" to "2.3.0" ) config = mutableMapOf() @@ -551,10 +551,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, @@ -585,7 +586,7 @@ class Client @JvmOverloads constructor( data ) return call( - method = "POST", + method = method, path, headers, params, @@ -656,7 +657,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/Account.kt b/library/src/main/java/io/appwrite/services/Account.kt index 61b6ce91..de1a93c6 100644 --- a/library/src/main/java/io/appwrite/services/Account.kt +++ b/library/src/main/java/io/appwrite/services/Account.kt @@ -1997,6 +1997,7 @@ class Account(client: Client) : Service(client) { * @param success URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param failure URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param scopes A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param state An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. */ @JvmOverloads suspend fun createOAuth2Session( @@ -2005,6 +2006,7 @@ class Account(client: Client) : Service(client) { success: String? = null, failure: String? = null, scopes: List? = null, + state: String? = null, ) { val apiPath = ("/account/sessions/oauth2/{provider}" .replace("{provider}", provider.value) @@ -2013,6 +2015,7 @@ class Account(client: Client) : Service(client) { "success" to success, "failure" to failure, "scopes" to scopes, + "state" to state, "project" to client.config["project"], ) val apiQuery = mutableListOf() @@ -2481,6 +2484,7 @@ class Account(client: Client) : Service(client) { * @param success URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param failure URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param scopes A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param state An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. */ @JvmOverloads suspend fun createOAuth2Token( @@ -2489,6 +2493,7 @@ class Account(client: Client) : Service(client) { success: String? = null, failure: String? = null, scopes: List? = null, + state: String? = null, ) { val apiPath = ("/account/tokens/oauth2/{provider}" .replace("{provider}", provider.value) @@ -2497,6 +2502,7 @@ class Account(client: Client) : Service(client) { "success" to success, "failure" to failure, "scopes" to scopes, + "state" to state, "project" to client.config["project"], ) val apiQuery = mutableListOf() diff --git a/library/src/main/java/io/appwrite/services/Analytics.kt b/library/src/main/java/io/appwrite/services/Analytics.kt new file mode 100644 index 00000000..1949f76b --- /dev/null +++ b/library/src/main/java/io/appwrite/services/Analytics.kt @@ -0,0 +1,83 @@ +package io.appwrite.services + +import android.net.Uri +import io.appwrite.Client +import io.appwrite.Service +import io.appwrite.exceptions.AppwriteException +import io.appwrite.extensions.classOf +import io.appwrite.models.* +import okhttp3.Cookie +import java.io.File + +/** + * + */ +class Analytics(client: Client) : Service(client) { + /** + * Send a tracking event from a browser, native app, or server-side SDK. + * + * @param propertyId Analytics property ID. + * @param name Event name. "pageview" is just a conventional event name; events are not modeled specially. + * @param url Full page URL or screen identifier. + * @param domain Hostname (e.g. example.com). + * @param referrer Referrer URL. + * @param screenWidth Viewport width in CSS pixels. + * @param sessionHash Optional session hash (provided by SDK). + * @param scrollDepth Scroll depth percentage 0-100. + * @param engagementTime Engagement time in seconds, 0-4294967295. + * @param props Custom string properties as a flat key=value list (max 32 entries, alternating key,value). + * @param userId Override user ID. Requires API-key auth with analytics.write scope. + * @param ip Override IP address. Requires API-key auth with analytics.write scope. + * @param userAgent Override user agent. Requires API-key auth with analytics.write scope. + * @return [Any] + */ + @JvmOverloads + suspend fun createEvent( + propertyId: String, + name: String, + url: String, + domain: String? = null, + referrer: String? = null, + screenWidth: Long? = null, + sessionHash: String? = null, + scrollDepth: Long? = null, + engagementTime: Long? = null, + props: List? = null, + userId: String? = null, + ip: String? = null, + userAgent: String? = null, + ): Any { + if (propertyId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"propertyId\"") + } + val apiPath = ("/analytics/properties/{propertyId}/events" + .replace("{propertyId}", propertyId) + ) + val apiParams = mutableMapOf( + "name" to name, + "url" to url, + "domain" to domain, + "referrer" to referrer, + "screenWidth" to screenWidth, + "sessionHash" to sessionHash, + "scrollDepth" to scrollDepth, + "engagementTime" to engagementTime, + "props" to props, + "userId" to userId, + "ip" to ip, + "userAgent" to userAgent, + ) + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } +} diff --git a/library/src/main/java/io/appwrite/services/Avatars.kt b/library/src/main/java/io/appwrite/services/Avatars.kt index 3b11d165..2223223f 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/Teams.kt b/library/src/main/java/io/appwrite/services/Teams.kt index 7227eba6..8a4bb87d 100644 --- a/library/src/main/java/io/appwrite/services/Teams.kt +++ b/library/src/main/java/io/appwrite/services/Teams.kt @@ -539,6 +539,7 @@ class Teams(client: Client) : Service(client) { * @param phone Phone number. Format this number with a leading '+' and a country code, e.g., +16175551212. * @param url URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param name Name of the new team member. Max length: 128 chars. + * @param membershipId Membership ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can't start with a special char. Max length is 36 chars. * @return [io.appwrite.models.Membership] */ @JvmOverloads @@ -550,6 +551,7 @@ class Teams(client: Client) : Service(client) { phone: String? = null, url: String? = null, name: String? = null, + membershipId: String? = null, ): io.appwrite.models.Membership { if (teamId.isEmpty()) { throw AppwriteException("Missing required parameter: \"teamId\"") @@ -564,6 +566,7 @@ class Teams(client: Client) : Service(client) { "roles" to roles, "url" to url, "name" to name, + "membershipId" to membershipId, ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), From d5da54da161d076be135079f40a7cbed43f61021 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 13 Oct 2026 11:56:36 +0100 Subject: [PATCH 2/2] fix: url-encode OAuth2 query parameters A state, success or failure value containing &, +, # or = was concatenated raw into the OAuth2 URL and did not round-trip. Encode each key and value with Uri.encode. --- CHANGELOG.md | 1 + library/src/main/java/io/appwrite/services/Account.kt | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040e3353..6c3623d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Added: optional `state` on `account.createOAuth2Session` and `account.createOAuth2Token`, returned unchanged on the success and failure URLs * Added: optional `membershipId` on `teams.createMembership` * Fixed: `avatars.updatePhoto` sends `PUT`, so uploading a profile photo no longer returns 404 +* Fixed: OAuth2 query parameters (`success`, `failure`, `scopes`, `state`) are URL-encoded, so values containing `&`, `+` or `#` survive the redirect ## 29.1.0 diff --git a/library/src/main/java/io/appwrite/services/Account.kt b/library/src/main/java/io/appwrite/services/Account.kt index de1a93c6..e6201c46 100644 --- a/library/src/main/java/io/appwrite/services/Account.kt +++ b/library/src/main/java/io/appwrite/services/Account.kt @@ -2026,11 +2026,11 @@ class Account(client: Client) : Service(client) { } is List<*> -> { (it.value as List<*>).forEach { v -> - apiQuery.add("${it.key}[]=${v.toString()}") + apiQuery.add("${Uri.encode(it.key)}[]=${Uri.encode(v.toString())}") } } else -> { - apiQuery.add("${it.key}=${it.value.toString()}") + apiQuery.add("${Uri.encode(it.key)}=${Uri.encode(it.value.toString())}") } } } @@ -2513,11 +2513,11 @@ class Account(client: Client) : Service(client) { } is List<*> -> { (it.value as List<*>).forEach { v -> - apiQuery.add("${it.key}[]=${v.toString()}") + apiQuery.add("${Uri.encode(it.key)}[]=${Uri.encode(v.toString())}") } } else -> { - apiQuery.add("${it.key}=${it.value.toString()}") + apiQuery.add("${Uri.encode(it.key)}=${Uri.encode(it.value.toString())}") } } }