diff --git a/CHANGELOG.md b/CHANGELOG.md index 435676ce..bef16837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Change Log +## 29.0.0 + +* Breaking: minimum Android SDK raised from 23 to 24 (Android 7.0) +* Added: `Push` service for MQTT push messages without FCM, plus `PushReceiver` for background handling +* Added: `Topic` builder for push topics +* Added: `endpointPush` and `pushClientId` on `Client`, with `setPushEndpoint` and `setPushClientId` +* Added: `Apps` service for managing OAuth2 apps, keys, secrets and installations +* Added: `Oauth2` service for using Appwrite as an OAuth2/OIDC provider +* Added: `Avatars.updatePhoto` and `Avatars.deletePhoto` +* Added: `duration` parameter to `Account.createEmailPasswordSession` +* Added: `current` parameter to `Account.deleteSessions` +* Added: `total` parameter to `Account.listSessions`, `Locale` list methods and `listTransactions` +* Added: `WEBFLOW` value to `OAuthProvider` +* Added: `toMap()` and `toJson()` on `Query` and `Operator` +* Updated: SDK manifest merges Push permissions, a foreground service and a boot receiver into apps +* Updated: narrower Netty keep rules, raising the R8 obfuscation and optimization scores Google Play reads +* Updated: added the shaded `hivemq-mqtt-client` dependency for `Push`, with consumer R8 rules +* Fixed: `Query` and `Operator` serialize correctly under R8 instead of producing `{}` +* Fixed: Realtime delivers events and error messages under R8 +* Fixed: session cookies saved under R8 stay readable after an app update +* Fixed: `ID.unique()` and `InputFile.fromFile` no longer crash on Android 7.x +* Fixed: Kotlin and Java examples now compile +* Fixed: unsubscribing one of several subscriptions on a topic stops its callback + ## 28.0.0 * Breaking: removed `Account.listLogs` and the `Log`, `LogList` models diff --git a/README.md b/README.md index c8fb25f0..aeffd888 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:28.0.0") +implementation("io.appwrite:sdk-for-android:29.0.0") ``` ### Maven @@ -49,11 +49,52 @@ Add this to your project's `pom.xml` file: io.appwrite sdk-for-android - 28.0.0 + 29.0.0 ``` +### Code shrinking (R8) + +The SDK ships the R8 rules it needs, so no extra configuration is required. Your own classes +that the SDK fills from JSON (the `nestedType` of documents, rows and preferences, and the +`payloadType` of Realtime subscriptions) are read by Gson through reflection. Annotate them +with `@Keep` so R8 keeps their fields: + +```kotlin +@Keep +data class Note(val title: String, val body: String) +``` + +### Push notifications + +#### Background delivery + +A subscription with `background = true` keeps delivering after the app is backgrounded, its +process is killed, or the device restarts, until it is unsubscribed or `push.close()` is +called (do this on sign-out). No service is kept running: a scheduled job and alarm wake the +app every 15 to 60 seconds to reconnect, and the broker replays what was sent in between +(`retry = true`). It reconnects with the credential saved at subscribe time, so use a session +(`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 +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). + +Doze delays the scheduled runs. They are more punctual when your app may schedule exact +alarms (`SCHEDULE_EXACT_ALARM`) or is exempt from battery optimisation; both are your app's +choice and subject to Google Play policy, and the SDK uses them when granted. For immediate delivery even after a kill and +during Doze, call `push.setForeground(true)` from the foreground: it adds a foreground service +with a quiet ongoing notification. If you never enable it, you can remove the service from +your merged manifest, so the Play Console does not ask you to declare it: + +```xml + + +``` + ## Getting Started diff --git a/docs/examples/java/account/create-email-password-session.md b/docs/examples/java/account/create-email-password-session.md index fa7d2523..9e254bd2 100644 --- a/docs/examples/java/account/create-email-password-session.md +++ b/docs/examples/java/account/create-email-password-session.md @@ -14,6 +14,7 @@ Account account = new Account(client); account.createEmailPasswordSession( "email@example.com", // email "password", // password + 60L, // duration (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/account/create-id-token-session.md b/docs/examples/java/account/create-id-token-session.md index 457b9fee..b7bd83fc 100644 --- a/docs/examples/java/account/create-id-token-session.md +++ b/docs/examples/java/account/create-id-token-session.md @@ -17,7 +17,7 @@ account.createIdTokenSession( "", // idToken "", // nonce (optional) "", // accessToken (optional) - 0, // accessTokenExpiry (optional) + 0L, // accessTokenExpiry (optional) "", // name (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/account/create-jwt.md b/docs/examples/java/account/create-jwt.md index f405d306..c41c5721 100644 --- a/docs/examples/java/account/create-jwt.md +++ b/docs/examples/java/account/create-jwt.md @@ -12,7 +12,7 @@ Client client = new Client(context) Account account = new Account(client); account.createJWT( - 0, // duration (optional) + 0L, // duration (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/account/delete-sessions.md b/docs/examples/java/account/delete-sessions.md index 4b93d19a..e0dce6ca 100644 --- a/docs/examples/java/account/delete-sessions.md +++ b/docs/examples/java/account/delete-sessions.md @@ -11,12 +11,16 @@ Client client = new Client(context) Account account = new Account(client); -account.deleteSessions(new CoroutineCallback<>((result, error) -> { - if (error != null) { - error.printStackTrace(); - return; - } +account.deleteSessions( + false, // current (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); - Log.d("Appwrite", result.toString()); -})); ``` diff --git a/docs/examples/java/account/list-sessions.md b/docs/examples/java/account/list-sessions.md index 9e52ea47..5ea50625 100644 --- a/docs/examples/java/account/list-sessions.md +++ b/docs/examples/java/account/list-sessions.md @@ -11,12 +11,16 @@ Client client = new Client(context) Account account = new Account(client); -account.listSessions(new CoroutineCallback<>((result, error) -> { - if (error != null) { - error.printStackTrace(); - return; - } +account.listSessions( + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); - Log.d("Appwrite", result.toString()); -})); ``` diff --git a/docs/examples/java/apps/create-installation-token.md b/docs/examples/java/apps/create-installation-token.md new file mode 100644 index 00000000..e46fc2af --- /dev/null +++ b/docs/examples/java/apps/create-installation-token.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.createInstallationToken( + "", // appId + "", // installationId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/create-key.md b/docs/examples/java/apps/create-key.md new file mode 100644 index 00000000..729c98fd --- /dev/null +++ b/docs/examples/java/apps/create-key.md @@ -0,0 +1,26 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.createKey( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/create-secret.md b/docs/examples/java/apps/create-secret.md new file mode 100644 index 00000000..8acb44ef --- /dev/null +++ b/docs/examples/java/apps/create-secret.md @@ -0,0 +1,26 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.createSecret( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/create.md b/docs/examples/java/apps/create.md new file mode 100644 index 00000000..88694a94 --- /dev/null +++ b/docs/examples/java/apps/create.md @@ -0,0 +1,44 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.create( + "", // appId + "", // name + List.of(), // redirectUris + "", // description (optional) + "https://example.com", // clientUri (optional) + "https://example.com", // logoUri (optional) + "https://example.com", // privacyPolicyUrl (optional) + "https://example.com", // termsUrl (optional) + List.of(), // contacts (optional) + "", // tagline (optional) + List.of(), // tags (optional) + List.of(), // images (optional) + "https://example.com", // supportUrl (optional) + "https://example.com", // dataDeletionUrl (optional) + List.of(), // postLogoutRedirectUris (optional) + false, // enabled (optional) + "public", // type (optional) + false, // deviceFlow (optional) + "", // teamId (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/delete-installation.md b/docs/examples/java/apps/delete-installation.md new file mode 100644 index 00000000..559ab654 --- /dev/null +++ b/docs/examples/java/apps/delete-installation.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.deleteInstallation( + "", // appId + "", // installationId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/delete-key.md b/docs/examples/java/apps/delete-key.md new file mode 100644 index 00000000..312e0be0 --- /dev/null +++ b/docs/examples/java/apps/delete-key.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.deleteKey( + "", // appId + "", // keyId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/delete-secret.md b/docs/examples/java/apps/delete-secret.md new file mode 100644 index 00000000..b21fc759 --- /dev/null +++ b/docs/examples/java/apps/delete-secret.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.deleteSecret( + "", // appId + "", // secretId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/delete-tokens.md b/docs/examples/java/apps/delete-tokens.md new file mode 100644 index 00000000..f873873e --- /dev/null +++ b/docs/examples/java/apps/delete-tokens.md @@ -0,0 +1,26 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.deleteTokens( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/delete.md b/docs/examples/java/apps/delete.md new file mode 100644 index 00000000..0c99520c --- /dev/null +++ b/docs/examples/java/apps/delete.md @@ -0,0 +1,26 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.delete( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/get-installation.md b/docs/examples/java/apps/get-installation.md new file mode 100644 index 00000000..f8cf6b56 --- /dev/null +++ b/docs/examples/java/apps/get-installation.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.getInstallation( + "", // appId + "", // installationId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/get-key.md b/docs/examples/java/apps/get-key.md new file mode 100644 index 00000000..729ccfa6 --- /dev/null +++ b/docs/examples/java/apps/get-key.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.getKey( + "", // appId + "", // keyId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/get-secret.md b/docs/examples/java/apps/get-secret.md new file mode 100644 index 00000000..f4f3c68f --- /dev/null +++ b/docs/examples/java/apps/get-secret.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.getSecret( + "", // appId + "", // secretId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/get.md b/docs/examples/java/apps/get.md new file mode 100644 index 00000000..0b4c5378 --- /dev/null +++ b/docs/examples/java/apps/get.md @@ -0,0 +1,26 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.get( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/list-installation-scopes.md b/docs/examples/java/apps/list-installation-scopes.md new file mode 100644 index 00000000..c09da789 --- /dev/null +++ b/docs/examples/java/apps/list-installation-scopes.md @@ -0,0 +1,22 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.listInstallationScopes(new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); +})); +``` diff --git a/docs/examples/java/apps/list-installations.md b/docs/examples/java/apps/list-installations.md new file mode 100644 index 00000000..f83097e1 --- /dev/null +++ b/docs/examples/java/apps/list-installations.md @@ -0,0 +1,28 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.listInstallations( + "", // appId + List.of(), // queries (optional) + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/list-keys.md b/docs/examples/java/apps/list-keys.md new file mode 100644 index 00000000..7a872751 --- /dev/null +++ b/docs/examples/java/apps/list-keys.md @@ -0,0 +1,28 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.listKeys( + "", // appId + List.of(), // queries (optional) + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/list-o-auth-2-scopes.md b/docs/examples/java/apps/list-o-auth-2-scopes.md new file mode 100644 index 00000000..d96f8c36 --- /dev/null +++ b/docs/examples/java/apps/list-o-auth-2-scopes.md @@ -0,0 +1,22 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.listOAuth2Scopes(new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); +})); +``` diff --git a/docs/examples/java/apps/list-secrets.md b/docs/examples/java/apps/list-secrets.md new file mode 100644 index 00000000..4d823a74 --- /dev/null +++ b/docs/examples/java/apps/list-secrets.md @@ -0,0 +1,28 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.listSecrets( + "", // appId + List.of(), // queries (optional) + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/list.md b/docs/examples/java/apps/list.md new file mode 100644 index 00000000..31d80daa --- /dev/null +++ b/docs/examples/java/apps/list.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.list( + List.of(), // queries (optional) + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/update-team.md b/docs/examples/java/apps/update-team.md new file mode 100644 index 00000000..314c672f --- /dev/null +++ b/docs/examples/java/apps/update-team.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.updateTeam( + "", // appId + "", // teamId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/apps/update.md b/docs/examples/java/apps/update.md new file mode 100644 index 00000000..e8b51fa9 --- /dev/null +++ b/docs/examples/java/apps/update.md @@ -0,0 +1,45 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.update( + "", // appId + "", // name + "", // description (optional) + "https://example.com", // clientUri (optional) + "https://example.com", // logoUri (optional) + "https://example.com", // privacyPolicyUrl (optional) + "https://example.com", // termsUrl (optional) + List.of(), // contacts (optional) + "", // tagline (optional) + List.of(), // tags (optional) + List.of(), // images (optional) + "https://example.com", // supportUrl (optional) + "https://example.com", // dataDeletionUrl (optional) + false, // enabled (optional) + List.of(), // redirectUris (optional) + List.of(), // postLogoutRedirectUris (optional) + "public", // type (optional) + false, // deviceFlow (optional) + List.of(), // installationScopes (optional) + "https://example.com", // installationRedirectUrl (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/avatars/delete-photo.md b/docs/examples/java/avatars/delete-photo.md new file mode 100644 index 00000000..cffc010c --- /dev/null +++ b/docs/examples/java/avatars/delete-photo.md @@ -0,0 +1,22 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Avatars; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Avatars avatars = new Avatars(client); + +avatars.deletePhoto(new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); +})); +``` diff --git a/docs/examples/java/avatars/get-browser.md b/docs/examples/java/avatars/get-browser.md index f101ad54..626de034 100644 --- a/docs/examples/java/avatars/get-browser.md +++ b/docs/examples/java/avatars/get-browser.md @@ -14,9 +14,9 @@ Avatars avatars = new Avatars(client); avatars.getBrowser( Browser.AVANT_BROWSER, // code - 0, // width (optional) - 0, // height (optional) - -1, // quality (optional) + 0L, // width (optional) + 0L, // height (optional) + -1L, // quality (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/avatars/get-credit-card.md b/docs/examples/java/avatars/get-credit-card.md index b12cebc9..f8e114db 100644 --- a/docs/examples/java/avatars/get-credit-card.md +++ b/docs/examples/java/avatars/get-credit-card.md @@ -14,9 +14,9 @@ Avatars avatars = new Avatars(client); avatars.getCreditCard( CreditCard.AMERICAN_EXPRESS, // code - 0, // width (optional) - 0, // height (optional) - -1, // quality (optional) + 0L, // width (optional) + 0L, // height (optional) + -1L, // quality (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/avatars/get-flag.md b/docs/examples/java/avatars/get-flag.md index 4d808513..0acd391f 100644 --- a/docs/examples/java/avatars/get-flag.md +++ b/docs/examples/java/avatars/get-flag.md @@ -14,9 +14,9 @@ Avatars avatars = new Avatars(client); avatars.getFlag( Flag.AFGHANISTAN, // code - 0, // width (optional) - 0, // height (optional) - -1, // quality (optional) + 0L, // width (optional) + 0L, // height (optional) + -1L, // quality (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/avatars/get-image.md b/docs/examples/java/avatars/get-image.md index a22041f5..38bd4dc8 100644 --- a/docs/examples/java/avatars/get-image.md +++ b/docs/examples/java/avatars/get-image.md @@ -13,8 +13,8 @@ Avatars avatars = new Avatars(client); avatars.getImage( "https://example.com", // url - 0, // width (optional) - 0, // height (optional) + 0L, // width (optional) + 0L, // height (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/avatars/get-initials.md b/docs/examples/java/avatars/get-initials.md index 08f5bea9..c22358d2 100644 --- a/docs/examples/java/avatars/get-initials.md +++ b/docs/examples/java/avatars/get-initials.md @@ -13,8 +13,8 @@ Avatars avatars = new Avatars(client); avatars.getInitials( "", // name (optional) - 0, // width (optional) - 0, // height (optional) + 0L, // width (optional) + 0L, // height (optional) "FFFFFF", // background (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/avatars/get-photo.md b/docs/examples/java/avatars/get-photo.md index 2204db78..04769d1c 100644 --- a/docs/examples/java/avatars/get-photo.md +++ b/docs/examples/java/avatars/get-photo.md @@ -12,9 +12,9 @@ Client client = new Client(context) Avatars avatars = new Avatars(client); avatars.getPhoto( - 0, // width (optional) - 0, // height (optional) - 0, // quality (optional) + 0L, // width (optional) + 0L, // height (optional) + 0L, // quality (optional) "png", // output (optional) "g", // rating (optional) "current()", // userId (optional) diff --git a/docs/examples/java/avatars/get-qr.md b/docs/examples/java/avatars/get-qr.md index ad08ab56..b3bbb6b6 100644 --- a/docs/examples/java/avatars/get-qr.md +++ b/docs/examples/java/avatars/get-qr.md @@ -13,8 +13,8 @@ Avatars avatars = new Avatars(client); avatars.getQR( "", // text - 1, // size (optional) - 0, // margin (optional) + 1L, // size (optional) + 0L, // margin (optional) false, // download (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/avatars/get-screenshot.md b/docs/examples/java/avatars/get-screenshot.md index 71f96c5b..82454a65 100644 --- a/docs/examples/java/avatars/get-screenshot.md +++ b/docs/examples/java/avatars/get-screenshot.md @@ -18,12 +18,11 @@ Avatars avatars = new Avatars(client); avatars.getScreenshot( "https://example.com", // url Map.of( - "Authorization", "Bearer token123", - "X-Custom-Header", "value" + "Accept-Language", "en-US,en;q=0.9" ), // headers (optional) - 1920, // viewportWidth (optional) - 1080, // viewportHeight (optional) - 2, // scale (optional) + 1920L, // viewportWidth (optional) + 1080L, // viewportHeight (optional) + 2.0, // scale (optional) BrowserTheme.LIGHT, // theme (optional) "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15", // userAgent (optional) true, // fullpage (optional) @@ -31,13 +30,13 @@ avatars.getScreenshot( Timezone.AFRICA_ABIDJAN, // timezone (optional) 37.7749, // latitude (optional) -122.4194, // longitude (optional) - 100, // accuracy (optional) + 100.0, // accuracy (optional) true, // touch (optional) BrowserPermission.GEOLOCATION, // permissions (optional) - 3, // sleep (optional) - 800, // width (optional) - 600, // height (optional) - 85, // quality (optional) + 3L, // sleep (optional) + 800L, // width (optional) + 600L, // height (optional) + 85L, // quality (optional) ImageFormat.JPG, // output (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/avatars/update-photo.md b/docs/examples/java/avatars/update-photo.md new file mode 100644 index 00000000..8c4d6e90 --- /dev/null +++ b/docs/examples/java/avatars/update-photo.md @@ -0,0 +1,27 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.models.InputFile; +import io.appwrite.services.Avatars; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Avatars avatars = new Avatars(client); + +avatars.updatePhoto( + InputFile.fromPath("file.png"), // file + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/databases/create-transaction.md b/docs/examples/java/databases/create-transaction.md index d96c27a0..616838d2 100644 --- a/docs/examples/java/databases/create-transaction.md +++ b/docs/examples/java/databases/create-transaction.md @@ -12,7 +12,7 @@ Client client = new Client(context) Databases databases = new Databases(client); databases.createTransaction( - 60, // ttl (optional) + 60L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/databases/decrement-document-attribute.md b/docs/examples/java/databases/decrement-document-attribute.md index 1a2f23e2..682dadd2 100644 --- a/docs/examples/java/databases/decrement-document-attribute.md +++ b/docs/examples/java/databases/decrement-document-attribute.md @@ -16,8 +16,8 @@ databases.decrementDocumentAttribute( "", // collectionId "", // documentId "", // attribute - 1, // value (optional) - 0, // min (optional) + 1.0, // value (optional) + 0.0, // min (optional) "", // transactionId (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/databases/increment-document-attribute.md b/docs/examples/java/databases/increment-document-attribute.md index fe363592..07dfd55c 100644 --- a/docs/examples/java/databases/increment-document-attribute.md +++ b/docs/examples/java/databases/increment-document-attribute.md @@ -16,8 +16,8 @@ databases.incrementDocumentAttribute( "", // collectionId "", // documentId "", // attribute - 1, // value (optional) - 100, // max (optional) + 1.0, // value (optional) + 100.0, // max (optional) "", // transactionId (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/databases/list-documents.md b/docs/examples/java/databases/list-documents.md index 8569cb60..8a5a52cb 100644 --- a/docs/examples/java/databases/list-documents.md +++ b/docs/examples/java/databases/list-documents.md @@ -17,7 +17,7 @@ databases.listDocuments( List.of(), // queries (optional) "", // transactionId (optional) false, // total (optional) - 0, // ttl (optional) + 0L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/databases/list-transactions.md b/docs/examples/java/databases/list-transactions.md index ad11c7f7..3e7ca138 100644 --- a/docs/examples/java/databases/list-transactions.md +++ b/docs/examples/java/databases/list-transactions.md @@ -13,6 +13,7 @@ Databases databases = new Databases(client); databases.listTransactions( List.of(), // queries (optional) + false, // total (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/documentsdb/create-documents.md b/docs/examples/java/documentsdb/create-documents.md index c52433a2..ae20335d 100644 --- a/docs/examples/java/documentsdb/create-documents.md +++ b/docs/examples/java/documentsdb/create-documents.md @@ -14,7 +14,14 @@ DocumentsDB documentsDB = new DocumentsDB(client); documentsDB.createDocuments( "", // databaseId "", // collectionId - List.of(), // documents + List.of(Map.of( + "$id", "example1", + "username", "walter.obrien", + "email", "walter.obrien@example.com", + "fullName", "Walter O'Brien", + "age", 30, + "isAdmin", false + )), // documents "", // transactionId (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/documentsdb/create-transaction.md b/docs/examples/java/documentsdb/create-transaction.md index da91c5c8..ac4a96d8 100644 --- a/docs/examples/java/documentsdb/create-transaction.md +++ b/docs/examples/java/documentsdb/create-transaction.md @@ -12,7 +12,7 @@ Client client = new Client(context) DocumentsDB documentsDB = new DocumentsDB(client); documentsDB.createTransaction( - 60, // ttl (optional) + 60L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/documentsdb/decrement-document-attribute.md b/docs/examples/java/documentsdb/decrement-document-attribute.md index f1853ac6..9e19095e 100644 --- a/docs/examples/java/documentsdb/decrement-document-attribute.md +++ b/docs/examples/java/documentsdb/decrement-document-attribute.md @@ -16,8 +16,8 @@ documentsDB.decrementDocumentAttribute( "", // collectionId "", // documentId "", // attribute - 1, // value (optional) - 0, // min (optional) + 1.0, // value (optional) + 0.0, // min (optional) "", // transactionId (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/documentsdb/increment-document-attribute.md b/docs/examples/java/documentsdb/increment-document-attribute.md index fab214e9..470abbf8 100644 --- a/docs/examples/java/documentsdb/increment-document-attribute.md +++ b/docs/examples/java/documentsdb/increment-document-attribute.md @@ -16,8 +16,8 @@ documentsDB.incrementDocumentAttribute( "", // collectionId "", // documentId "", // attribute - 1, // value (optional) - 100, // max (optional) + 1.0, // value (optional) + 100.0, // max (optional) "", // transactionId (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/documentsdb/list-documents.md b/docs/examples/java/documentsdb/list-documents.md index 0abb6734..f6cfe483 100644 --- a/docs/examples/java/documentsdb/list-documents.md +++ b/docs/examples/java/documentsdb/list-documents.md @@ -17,7 +17,7 @@ documentsDB.listDocuments( List.of(), // queries (optional) "", // transactionId (optional) false, // total (optional) - 0, // ttl (optional) + 0L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/documentsdb/list-transactions.md b/docs/examples/java/documentsdb/list-transactions.md index 9b8fcd7b..ab4974a0 100644 --- a/docs/examples/java/documentsdb/list-transactions.md +++ b/docs/examples/java/documentsdb/list-transactions.md @@ -13,6 +13,7 @@ DocumentsDB documentsDB = new DocumentsDB(client); documentsDB.listTransactions( List.of(), // queries (optional) + false, // total (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/graphql/mutation.md b/docs/examples/java/graphql/mutation.md index 9cc7febe..a8488db1 100644 --- a/docs/examples/java/graphql/mutation.md +++ b/docs/examples/java/graphql/mutation.md @@ -12,7 +12,9 @@ Client client = new Client(context) Graphql graphql = new Graphql(client); graphql.mutation( - Map.of("a", "b"), // query + Map.of( + "query", "mutation { accountUpdateName(name: \"Walter\") { name } }" + ), // query new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/graphql/query.md b/docs/examples/java/graphql/query.md index 25704b00..58e63635 100644 --- a/docs/examples/java/graphql/query.md +++ b/docs/examples/java/graphql/query.md @@ -12,7 +12,9 @@ Client client = new Client(context) Graphql graphql = new Graphql(client); graphql.query( - Map.of("a", "b"), // query + Map.of( + "query", "query { localeGet { ip } }" + ), // query new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/locale/list-codes.md b/docs/examples/java/locale/list-codes.md index 534b1199..181c1b8f 100644 --- a/docs/examples/java/locale/list-codes.md +++ b/docs/examples/java/locale/list-codes.md @@ -11,12 +11,16 @@ Client client = new Client(context) Locale locale = new Locale(client); -locale.listCodes(new CoroutineCallback<>((result, error) -> { - if (error != null) { - error.printStackTrace(); - return; - } +locale.listCodes( + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); - Log.d("Appwrite", result.toString()); -})); ``` diff --git a/docs/examples/java/locale/list-continents.md b/docs/examples/java/locale/list-continents.md index 998a31be..e8a60fca 100644 --- a/docs/examples/java/locale/list-continents.md +++ b/docs/examples/java/locale/list-continents.md @@ -11,12 +11,16 @@ Client client = new Client(context) Locale locale = new Locale(client); -locale.listContinents(new CoroutineCallback<>((result, error) -> { - if (error != null) { - error.printStackTrace(); - return; - } +locale.listContinents( + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); - Log.d("Appwrite", result.toString()); -})); ``` diff --git a/docs/examples/java/locale/list-countries-eu.md b/docs/examples/java/locale/list-countries-eu.md index 5950c990..b6c878fc 100644 --- a/docs/examples/java/locale/list-countries-eu.md +++ b/docs/examples/java/locale/list-countries-eu.md @@ -11,12 +11,16 @@ Client client = new Client(context) Locale locale = new Locale(client); -locale.listCountriesEU(new CoroutineCallback<>((result, error) -> { - if (error != null) { - error.printStackTrace(); - return; - } +locale.listCountriesEU( + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); - Log.d("Appwrite", result.toString()); -})); ``` diff --git a/docs/examples/java/locale/list-countries-phones.md b/docs/examples/java/locale/list-countries-phones.md index 6096758e..3d8bb9eb 100644 --- a/docs/examples/java/locale/list-countries-phones.md +++ b/docs/examples/java/locale/list-countries-phones.md @@ -11,12 +11,16 @@ Client client = new Client(context) Locale locale = new Locale(client); -locale.listCountriesPhones(new CoroutineCallback<>((result, error) -> { - if (error != null) { - error.printStackTrace(); - return; - } +locale.listCountriesPhones( + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); - Log.d("Appwrite", result.toString()); -})); ``` diff --git a/docs/examples/java/locale/list-countries.md b/docs/examples/java/locale/list-countries.md index 140fc48a..3238cf27 100644 --- a/docs/examples/java/locale/list-countries.md +++ b/docs/examples/java/locale/list-countries.md @@ -11,12 +11,16 @@ Client client = new Client(context) Locale locale = new Locale(client); -locale.listCountries(new CoroutineCallback<>((result, error) -> { - if (error != null) { - error.printStackTrace(); - return; - } +locale.listCountries( + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); - Log.d("Appwrite", result.toString()); -})); ``` diff --git a/docs/examples/java/locale/list-currencies.md b/docs/examples/java/locale/list-currencies.md index 0e5273d9..631bb346 100644 --- a/docs/examples/java/locale/list-currencies.md +++ b/docs/examples/java/locale/list-currencies.md @@ -11,12 +11,16 @@ Client client = new Client(context) Locale locale = new Locale(client); -locale.listCurrencies(new CoroutineCallback<>((result, error) -> { - if (error != null) { - error.printStackTrace(); - return; - } +locale.listCurrencies( + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); - Log.d("Appwrite", result.toString()); -})); ``` diff --git a/docs/examples/java/locale/list-languages.md b/docs/examples/java/locale/list-languages.md index e85bd0a1..b0000633 100644 --- a/docs/examples/java/locale/list-languages.md +++ b/docs/examples/java/locale/list-languages.md @@ -11,12 +11,16 @@ Client client = new Client(context) Locale locale = new Locale(client); -locale.listLanguages(new CoroutineCallback<>((result, error) -> { - if (error != null) { - error.printStackTrace(); - return; - } +locale.listLanguages( + false, // total (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); - Log.d("Appwrite", result.toString()); -})); ``` diff --git a/docs/examples/java/oauth2/approve.md b/docs/examples/java/oauth2/approve.md new file mode 100644 index 00000000..f2f4a495 --- /dev/null +++ b/docs/examples/java/oauth2/approve.md @@ -0,0 +1,28 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.approve( + "", // grant_id + "", // authorization_details (optional) + "", // scope (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/authorize-post.md b/docs/examples/java/oauth2/authorize-post.md new file mode 100644 index 00000000..99ed6b8e --- /dev/null +++ b/docs/examples/java/oauth2/authorize-post.md @@ -0,0 +1,39 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.authorizePost( + "", // client_id (optional) + "https://example.com", // redirect_uri (optional) + "", // response_type (optional) + "", // scope (optional) + "", // state (optional) + "", // nonce (optional) + "", // code_challenge (optional) + "s256", // code_challenge_method (optional) + "", // prompt (optional) + 0L, // max_age (optional) + "", // authorization_details (optional) + "", // resource (optional) + "", // audience (optional) + "", // request_uri (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/authorize.md b/docs/examples/java/oauth2/authorize.md new file mode 100644 index 00000000..62a452c8 --- /dev/null +++ b/docs/examples/java/oauth2/authorize.md @@ -0,0 +1,39 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.authorize( + "", // client_id (optional) + "https://example.com", // redirect_uri (optional) + "", // response_type (optional) + "", // scope (optional) + "", // state (optional) + "", // nonce (optional) + "", // code_challenge (optional) + "s256", // code_challenge_method (optional) + "", // prompt (optional) + 0L, // max_age (optional) + "", // authorization_details (optional) + "", // resource (optional) + "", // audience (optional) + "", // request_uri (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/create-device-authorization.md b/docs/examples/java/oauth2/create-device-authorization.md new file mode 100644 index 00000000..0b65a1ab --- /dev/null +++ b/docs/examples/java/oauth2/create-device-authorization.md @@ -0,0 +1,30 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.createDeviceAuthorization( + "", // client_id (optional) + "", // scope (optional) + "", // authorization_details (optional) + "", // resource (optional) + "", // audience (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/create-grant.md b/docs/examples/java/oauth2/create-grant.md new file mode 100644 index 00000000..a14c3886 --- /dev/null +++ b/docs/examples/java/oauth2/create-grant.md @@ -0,0 +1,26 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.createGrant( + "", // user_code + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/create-par.md b/docs/examples/java/oauth2/create-par.md new file mode 100644 index 00000000..e65bdb54 --- /dev/null +++ b/docs/examples/java/oauth2/create-par.md @@ -0,0 +1,38 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.createPAR( + "", // client_id + "https://example.com", // redirect_uri + "code", // response_type + "", // scope (optional) + "", // state (optional) + "", // nonce (optional) + "", // code_challenge (optional) + "s256", // code_challenge_method (optional) + "", // prompt (optional) + 0L, // max_age (optional) + "", // authorization_details (optional) + "", // resource (optional) + "", // audience (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/create-token.md b/docs/examples/java/oauth2/create-token.md new file mode 100644 index 00000000..39b6fa25 --- /dev/null +++ b/docs/examples/java/oauth2/create-token.md @@ -0,0 +1,35 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.createToken( + "", // grant_type + "", // code (optional) + "", // refresh_token (optional) + "", // device_code (optional) + "", // client_id (optional) + "", // client_secret (optional) + "", // code_verifier (optional) + "https://example.com", // redirect_uri (optional) + "", // resource (optional) + "", // audience (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/get-grant.md b/docs/examples/java/oauth2/get-grant.md new file mode 100644 index 00000000..18068889 --- /dev/null +++ b/docs/examples/java/oauth2/get-grant.md @@ -0,0 +1,26 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.getGrant( + "", // grant_id + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/list-organizations.md b/docs/examples/java/oauth2/list-organizations.md new file mode 100644 index 00000000..bbc74a78 --- /dev/null +++ b/docs/examples/java/oauth2/list-organizations.md @@ -0,0 +1,28 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.listOrganizations( + 1L, // limit (optional) + 0L, // offset (optional) + "", // search (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/list-projects.md b/docs/examples/java/oauth2/list-projects.md new file mode 100644 index 00000000..6963583a --- /dev/null +++ b/docs/examples/java/oauth2/list-projects.md @@ -0,0 +1,28 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.listProjects( + 1L, // limit (optional) + 0L, // offset (optional) + "", // search (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/logout-post.md b/docs/examples/java/oauth2/logout-post.md new file mode 100644 index 00000000..fefd5e92 --- /dev/null +++ b/docs/examples/java/oauth2/logout-post.md @@ -0,0 +1,31 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.logoutPost( + "", // id_token_hint (optional) + "", // logout_hint (optional) + "", // client_id (optional) + "https://example.com", // post_logout_redirect_uri (optional) + "", // state (optional) + "", // ui_locales (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/logout.md b/docs/examples/java/oauth2/logout.md new file mode 100644 index 00000000..ca7739a2 --- /dev/null +++ b/docs/examples/java/oauth2/logout.md @@ -0,0 +1,31 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.logout( + "", // id_token_hint (optional) + "", // logout_hint (optional) + "", // client_id (optional) + "https://example.com", // post_logout_redirect_uri (optional) + "", // state (optional) + "", // ui_locales (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/reject.md b/docs/examples/java/oauth2/reject.md new file mode 100644 index 00000000..90b9b2e2 --- /dev/null +++ b/docs/examples/java/oauth2/reject.md @@ -0,0 +1,26 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.reject( + "", // grant_id + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/oauth2/revoke.md b/docs/examples/java/oauth2/revoke.md new file mode 100644 index 00000000..0d680eb1 --- /dev/null +++ b/docs/examples/java/oauth2/revoke.md @@ -0,0 +1,29 @@ +```java +import android.util.Log; + +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.revoke( + "", // token + "access_token", // token_type_hint (optional) + "", // client_id (optional) + "", // client_secret (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/presences/list.md b/docs/examples/java/presences/list.md index d66a4161..c2430748 100644 --- a/docs/examples/java/presences/list.md +++ b/docs/examples/java/presences/list.md @@ -14,7 +14,7 @@ Presences presences = new Presences(client); presences.list( List.of(), // queries (optional) false, // total (optional) - 0, // ttl (optional) + 0L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/storage/get-file-preview.md b/docs/examples/java/storage/get-file-preview.md index 3d753c44..2f1060a1 100644 --- a/docs/examples/java/storage/get-file-preview.md +++ b/docs/examples/java/storage/get-file-preview.md @@ -16,15 +16,15 @@ Storage storage = new Storage(client); storage.getFilePreview( "", // bucketId "", // fileId - 0, // width (optional) - 0, // height (optional) + 0L, // width (optional) + 0L, // height (optional) ImageGravity.AUTO, // gravity (optional) - -1, // quality (optional) - 0, // borderWidth (optional) + -1L, // quality (optional) + 0L, // borderWidth (optional) "FFFFFF", // borderColor (optional) - 0, // borderRadius (optional) - 0, // opacity (optional) - -360, // rotation (optional) + 0L, // borderRadius (optional) + 0.0, // opacity (optional) + -360L, // rotation (optional) "FFFFFF", // background (optional) ImageFormat.JPG, // output (optional) "", // token (optional) diff --git a/docs/examples/java/tablesdb/create-transaction.md b/docs/examples/java/tablesdb/create-transaction.md index 8401bfec..0dab2f0c 100644 --- a/docs/examples/java/tablesdb/create-transaction.md +++ b/docs/examples/java/tablesdb/create-transaction.md @@ -12,7 +12,7 @@ Client client = new Client(context) TablesDB tablesDB = new TablesDB(client); tablesDB.createTransaction( - 60, // ttl (optional) + 60L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/tablesdb/decrement-row-column.md b/docs/examples/java/tablesdb/decrement-row-column.md index c998b442..2315f165 100644 --- a/docs/examples/java/tablesdb/decrement-row-column.md +++ b/docs/examples/java/tablesdb/decrement-row-column.md @@ -16,8 +16,8 @@ tablesDB.decrementRowColumn( "", // tableId "", // rowId "", // column - 1, // value (optional) - 0, // min (optional) + 1.0, // value (optional) + 0.0, // min (optional) "", // transactionId (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/tablesdb/increment-row-column.md b/docs/examples/java/tablesdb/increment-row-column.md index 5fb7189e..2e9b52ba 100644 --- a/docs/examples/java/tablesdb/increment-row-column.md +++ b/docs/examples/java/tablesdb/increment-row-column.md @@ -16,8 +16,8 @@ tablesDB.incrementRowColumn( "", // tableId "", // rowId "", // column - 1, // value (optional) - 100, // max (optional) + 1.0, // value (optional) + 100.0, // max (optional) "", // transactionId (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { diff --git a/docs/examples/java/tablesdb/list-rows.md b/docs/examples/java/tablesdb/list-rows.md index cbf40287..6d3a0a78 100644 --- a/docs/examples/java/tablesdb/list-rows.md +++ b/docs/examples/java/tablesdb/list-rows.md @@ -17,7 +17,7 @@ tablesDB.listRows( List.of(), // queries (optional) "", // transactionId (optional) false, // total (optional) - 0, // ttl (optional) + 0L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/tablesdb/list-transactions.md b/docs/examples/java/tablesdb/list-transactions.md index 9ebb180a..8a04ad9c 100644 --- a/docs/examples/java/tablesdb/list-transactions.md +++ b/docs/examples/java/tablesdb/list-transactions.md @@ -13,6 +13,7 @@ TablesDB tablesDB = new TablesDB(client); tablesDB.listTransactions( List.of(), // queries (optional) + false, // total (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/teams/create-membership.md b/docs/examples/java/teams/create-membership.md index 861aeb62..d7fba7c1 100644 --- a/docs/examples/java/teams/create-membership.md +++ b/docs/examples/java/teams/create-membership.md @@ -13,7 +13,7 @@ Teams teams = new Teams(client); teams.createMembership( "", // teamId - List.of(), // roles + List.of("editor"), // roles "email@example.com", // email (optional) "", // userId (optional) "+12065550100", // phone (optional) diff --git a/docs/examples/java/teams/update-membership.md b/docs/examples/java/teams/update-membership.md index 60231f2c..c4208a83 100644 --- a/docs/examples/java/teams/update-membership.md +++ b/docs/examples/java/teams/update-membership.md @@ -14,7 +14,7 @@ Teams teams = new Teams(client); teams.updateMembership( "", // teamId "", // membershipId - List.of(), // roles + List.of("editor"), // roles new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/vectorsdb/create-operations.md b/docs/examples/java/vectorsdb/create-operations.md index 7e503634..8dd6f0b8 100644 --- a/docs/examples/java/vectorsdb/create-operations.md +++ b/docs/examples/java/vectorsdb/create-operations.md @@ -19,7 +19,10 @@ vectorsDB.createOperations( "collectionId", "", "documentId", "", "data", Map.of( - "name", "Walter O'Brien" + "embeddings", List.of(0.12, -0.55, 0.88, 1.02), + "metadata", Map.of( + "name", "First document" + ) ) )), // operations (optional) new CoroutineCallback<>((result, error) -> { diff --git a/docs/examples/java/vectorsdb/create-query.md b/docs/examples/java/vectorsdb/create-query.md index 3f1cfd5c..8c798724 100644 --- a/docs/examples/java/vectorsdb/create-query.md +++ b/docs/examples/java/vectorsdb/create-query.md @@ -17,7 +17,7 @@ vectorsDB.createQuery( List.of(), // queries (optional) "", // transactionId (optional) false, // total (optional) - 0, // ttl (optional) + 0L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/vectorsdb/create-transaction.md b/docs/examples/java/vectorsdb/create-transaction.md index 4784fc47..916f73c1 100644 --- a/docs/examples/java/vectorsdb/create-transaction.md +++ b/docs/examples/java/vectorsdb/create-transaction.md @@ -12,7 +12,7 @@ Client client = new Client(context) VectorsDB vectorsDB = new VectorsDB(client); vectorsDB.createTransaction( - 60, // ttl (optional) + 60L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/vectorsdb/list-documents.md b/docs/examples/java/vectorsdb/list-documents.md index 12ef1f0c..49689d36 100644 --- a/docs/examples/java/vectorsdb/list-documents.md +++ b/docs/examples/java/vectorsdb/list-documents.md @@ -17,7 +17,7 @@ vectorsDB.listDocuments( List.of(), // queries (optional) "", // transactionId (optional) false, // total (optional) - 0, // ttl (optional) + 0L, // ttl (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/java/vectorsdb/list-transactions.md b/docs/examples/java/vectorsdb/list-transactions.md index 18ef0e55..be8905e2 100644 --- a/docs/examples/java/vectorsdb/list-transactions.md +++ b/docs/examples/java/vectorsdb/list-transactions.md @@ -13,6 +13,7 @@ VectorsDB vectorsDB = new VectorsDB(client); vectorsDB.listTransactions( List.of(), // queries (optional) + false, // total (optional) new CoroutineCallback<>((result, error) -> { if (error != null) { error.printStackTrace(); diff --git a/docs/examples/kotlin/account/create-email-password-session.md b/docs/examples/kotlin/account/create-email-password-session.md index 6100e483..aaceb65f 100644 --- a/docs/examples/kotlin/account/create-email-password-session.md +++ b/docs/examples/kotlin/account/create-email-password-session.md @@ -12,5 +12,6 @@ val account = Account(client) val result = account.createEmailPasswordSession( email = "email@example.com", password = "password", + duration = 60, // (optional) ) ``` diff --git a/docs/examples/kotlin/account/delete-sessions.md b/docs/examples/kotlin/account/delete-sessions.md index ff404dbc..96bbc341 100644 --- a/docs/examples/kotlin/account/delete-sessions.md +++ b/docs/examples/kotlin/account/delete-sessions.md @@ -9,5 +9,7 @@ val client = Client(context) val account = Account(client) -val result = account.deleteSessions() +val result = account.deleteSessions( + current = false, // (optional) +) ``` diff --git a/docs/examples/kotlin/account/list-sessions.md b/docs/examples/kotlin/account/list-sessions.md index 8c261a2d..38e7e556 100644 --- a/docs/examples/kotlin/account/list-sessions.md +++ b/docs/examples/kotlin/account/list-sessions.md @@ -9,5 +9,7 @@ val client = Client(context) val account = Account(client) -val result = account.listSessions() +val result = account.listSessions( + total = false, // (optional) +) ``` diff --git a/docs/examples/kotlin/apps/create-installation-token.md b/docs/examples/kotlin/apps/create-installation-token.md new file mode 100644 index 00000000..6226e775 --- /dev/null +++ b/docs/examples/kotlin/apps/create-installation-token.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.createInstallationToken( + appId = "", + installationId = "", +) +``` diff --git a/docs/examples/kotlin/apps/create-key.md b/docs/examples/kotlin/apps/create-key.md new file mode 100644 index 00000000..d6daef84 --- /dev/null +++ b/docs/examples/kotlin/apps/create-key.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.createKey( + appId = "", +) +``` diff --git a/docs/examples/kotlin/apps/create-secret.md b/docs/examples/kotlin/apps/create-secret.md new file mode 100644 index 00000000..d9a4c525 --- /dev/null +++ b/docs/examples/kotlin/apps/create-secret.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.createSecret( + appId = "", +) +``` diff --git a/docs/examples/kotlin/apps/create.md b/docs/examples/kotlin/apps/create.md new file mode 100644 index 00000000..2299c031 --- /dev/null +++ b/docs/examples/kotlin/apps/create.md @@ -0,0 +1,33 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.create( + appId = "", + name = "", + redirectUris = listOf(), + description = "", // (optional) + clientUri = "https://example.com", // (optional) + logoUri = "https://example.com", // (optional) + privacyPolicyUrl = "https://example.com", // (optional) + termsUrl = "https://example.com", // (optional) + contacts = listOf(), // (optional) + tagline = "", // (optional) + tags = listOf(), // (optional) + images = listOf(), // (optional) + supportUrl = "https://example.com", // (optional) + dataDeletionUrl = "https://example.com", // (optional) + postLogoutRedirectUris = listOf(), // (optional) + enabled = false, // (optional) + type = "public", // (optional) + deviceFlow = false, // (optional) + teamId = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/apps/delete-installation.md b/docs/examples/kotlin/apps/delete-installation.md new file mode 100644 index 00000000..c07fee53 --- /dev/null +++ b/docs/examples/kotlin/apps/delete-installation.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.deleteInstallation( + appId = "", + installationId = "", +) +``` diff --git a/docs/examples/kotlin/apps/delete-key.md b/docs/examples/kotlin/apps/delete-key.md new file mode 100644 index 00000000..3ad3825a --- /dev/null +++ b/docs/examples/kotlin/apps/delete-key.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.deleteKey( + appId = "", + keyId = "", +) +``` diff --git a/docs/examples/kotlin/apps/delete-secret.md b/docs/examples/kotlin/apps/delete-secret.md new file mode 100644 index 00000000..ad22d5e0 --- /dev/null +++ b/docs/examples/kotlin/apps/delete-secret.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.deleteSecret( + appId = "", + secretId = "", +) +``` diff --git a/docs/examples/kotlin/apps/delete-tokens.md b/docs/examples/kotlin/apps/delete-tokens.md new file mode 100644 index 00000000..e8d89009 --- /dev/null +++ b/docs/examples/kotlin/apps/delete-tokens.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.deleteTokens( + appId = "", +) +``` diff --git a/docs/examples/kotlin/apps/delete.md b/docs/examples/kotlin/apps/delete.md new file mode 100644 index 00000000..412b55f6 --- /dev/null +++ b/docs/examples/kotlin/apps/delete.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.delete( + appId = "", +) +``` diff --git a/docs/examples/kotlin/apps/get-installation.md b/docs/examples/kotlin/apps/get-installation.md new file mode 100644 index 00000000..288f21d6 --- /dev/null +++ b/docs/examples/kotlin/apps/get-installation.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.getInstallation( + appId = "", + installationId = "", +) +``` diff --git a/docs/examples/kotlin/apps/get-key.md b/docs/examples/kotlin/apps/get-key.md new file mode 100644 index 00000000..8af73116 --- /dev/null +++ b/docs/examples/kotlin/apps/get-key.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.getKey( + appId = "", + keyId = "", +) +``` diff --git a/docs/examples/kotlin/apps/get-secret.md b/docs/examples/kotlin/apps/get-secret.md new file mode 100644 index 00000000..a76bbe4e --- /dev/null +++ b/docs/examples/kotlin/apps/get-secret.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.getSecret( + appId = "", + secretId = "", +) +``` diff --git a/docs/examples/kotlin/apps/get.md b/docs/examples/kotlin/apps/get.md new file mode 100644 index 00000000..3fc30c71 --- /dev/null +++ b/docs/examples/kotlin/apps/get.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.get( + appId = "", +) +``` diff --git a/docs/examples/kotlin/apps/list-installation-scopes.md b/docs/examples/kotlin/apps/list-installation-scopes.md new file mode 100644 index 00000000..bafacf81 --- /dev/null +++ b/docs/examples/kotlin/apps/list-installation-scopes.md @@ -0,0 +1,13 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.listInstallationScopes() +``` diff --git a/docs/examples/kotlin/apps/list-installations.md b/docs/examples/kotlin/apps/list-installations.md new file mode 100644 index 00000000..0819d5fc --- /dev/null +++ b/docs/examples/kotlin/apps/list-installations.md @@ -0,0 +1,17 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.listInstallations( + appId = "", + queries = listOf(), // (optional) + total = false, // (optional) +) +``` diff --git a/docs/examples/kotlin/apps/list-keys.md b/docs/examples/kotlin/apps/list-keys.md new file mode 100644 index 00000000..9977dfe7 --- /dev/null +++ b/docs/examples/kotlin/apps/list-keys.md @@ -0,0 +1,17 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.listKeys( + appId = "", + queries = listOf(), // (optional) + total = false, // (optional) +) +``` diff --git a/docs/examples/kotlin/apps/list-o-auth-2-scopes.md b/docs/examples/kotlin/apps/list-o-auth-2-scopes.md new file mode 100644 index 00000000..b0852955 --- /dev/null +++ b/docs/examples/kotlin/apps/list-o-auth-2-scopes.md @@ -0,0 +1,13 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.listOAuth2Scopes() +``` diff --git a/docs/examples/kotlin/apps/list-secrets.md b/docs/examples/kotlin/apps/list-secrets.md new file mode 100644 index 00000000..aa69cc8c --- /dev/null +++ b/docs/examples/kotlin/apps/list-secrets.md @@ -0,0 +1,17 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.listSecrets( + appId = "", + queries = listOf(), // (optional) + total = false, // (optional) +) +``` diff --git a/docs/examples/kotlin/apps/list.md b/docs/examples/kotlin/apps/list.md new file mode 100644 index 00000000..d2faad48 --- /dev/null +++ b/docs/examples/kotlin/apps/list.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.list( + queries = listOf(), // (optional) + total = false, // (optional) +) +``` diff --git a/docs/examples/kotlin/apps/update-team.md b/docs/examples/kotlin/apps/update-team.md new file mode 100644 index 00000000..b6f69443 --- /dev/null +++ b/docs/examples/kotlin/apps/update-team.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.updateTeam( + appId = "", + teamId = "", +) +``` diff --git a/docs/examples/kotlin/apps/update.md b/docs/examples/kotlin/apps/update.md new file mode 100644 index 00000000..b2bad705 --- /dev/null +++ b/docs/examples/kotlin/apps/update.md @@ -0,0 +1,34 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val result = apps.update( + appId = "", + name = "", + description = "", // (optional) + clientUri = "https://example.com", // (optional) + logoUri = "https://example.com", // (optional) + privacyPolicyUrl = "https://example.com", // (optional) + termsUrl = "https://example.com", // (optional) + contacts = listOf(), // (optional) + tagline = "", // (optional) + tags = listOf(), // (optional) + images = listOf(), // (optional) + supportUrl = "https://example.com", // (optional) + dataDeletionUrl = "https://example.com", // (optional) + enabled = false, // (optional) + redirectUris = listOf(), // (optional) + postLogoutRedirectUris = listOf(), // (optional) + type = "public", // (optional) + deviceFlow = false, // (optional) + installationScopes = listOf(), // (optional) + installationRedirectUrl = "https://example.com", // (optional) +) +``` diff --git a/docs/examples/kotlin/avatars/delete-photo.md b/docs/examples/kotlin/avatars/delete-photo.md new file mode 100644 index 00000000..e16f4173 --- /dev/null +++ b/docs/examples/kotlin/avatars/delete-photo.md @@ -0,0 +1,13 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Avatars + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val avatars = Avatars(client) + +val result = avatars.deletePhoto() +``` diff --git a/docs/examples/kotlin/avatars/get-screenshot.md b/docs/examples/kotlin/avatars/get-screenshot.md index 1093c060..fcfb36c8 100644 --- a/docs/examples/kotlin/avatars/get-screenshot.md +++ b/docs/examples/kotlin/avatars/get-screenshot.md @@ -16,12 +16,11 @@ val avatars = Avatars(client) val result = avatars.getScreenshot( url = "https://example.com", headers = mapOf( - "Authorization" to "Bearer token123", - "X-Custom-Header" to "value" + "Accept-Language" to "en-US,en;q=0.9" ), // (optional) viewportWidth = 1920, // (optional) viewportHeight = 1080, // (optional) - scale = 2, // (optional) + scale = 2.0, // (optional) theme = BrowserTheme.LIGHT, // (optional) userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15", // (optional) fullpage = true, // (optional) @@ -29,7 +28,7 @@ val result = avatars.getScreenshot( timezone = Timezone.AFRICA_ABIDJAN, // (optional) latitude = 37.7749, // (optional) longitude = -122.4194, // (optional) - accuracy = 100, // (optional) + accuracy = 100.0, // (optional) touch = true, // (optional) permissions = BrowserPermission.GEOLOCATION, // (optional) sleep = 3, // (optional) diff --git a/docs/examples/kotlin/avatars/update-photo.md b/docs/examples/kotlin/avatars/update-photo.md new file mode 100644 index 00000000..326fe57a --- /dev/null +++ b/docs/examples/kotlin/avatars/update-photo.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.models.InputFile +import io.appwrite.services.Avatars + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val avatars = Avatars(client) + +val result = avatars.updatePhoto( + file = InputFile.fromPath("file.png"), +) +``` diff --git a/docs/examples/kotlin/databases/decrement-document-attribute.md b/docs/examples/kotlin/databases/decrement-document-attribute.md index c32a6002..07452b01 100644 --- a/docs/examples/kotlin/databases/decrement-document-attribute.md +++ b/docs/examples/kotlin/databases/decrement-document-attribute.md @@ -14,8 +14,8 @@ val result = databases.decrementDocumentAttribute( collectionId = "", documentId = "", attribute = "", - value = 1, // (optional) - min = 0, // (optional) + value = 1.0, // (optional) + min = 0.0, // (optional) transactionId = "", // (optional) ) ``` diff --git a/docs/examples/kotlin/databases/increment-document-attribute.md b/docs/examples/kotlin/databases/increment-document-attribute.md index daaa6d6a..e71fddc7 100644 --- a/docs/examples/kotlin/databases/increment-document-attribute.md +++ b/docs/examples/kotlin/databases/increment-document-attribute.md @@ -14,8 +14,8 @@ val result = databases.incrementDocumentAttribute( collectionId = "", documentId = "", attribute = "", - value = 1, // (optional) - max = 100, // (optional) + value = 1.0, // (optional) + max = 100.0, // (optional) transactionId = "", // (optional) ) ``` diff --git a/docs/examples/kotlin/databases/list-transactions.md b/docs/examples/kotlin/databases/list-transactions.md index 3cabd79f..e4cd4633 100644 --- a/docs/examples/kotlin/databases/list-transactions.md +++ b/docs/examples/kotlin/databases/list-transactions.md @@ -11,5 +11,6 @@ val databases = Databases(client) val result = databases.listTransactions( queries = listOf(), // (optional) + total = false, // (optional) ) ``` diff --git a/docs/examples/kotlin/documentsdb/create-documents.md b/docs/examples/kotlin/documentsdb/create-documents.md index 2923c100..b66cb47d 100644 --- a/docs/examples/kotlin/documentsdb/create-documents.md +++ b/docs/examples/kotlin/documentsdb/create-documents.md @@ -12,7 +12,14 @@ val documentsDB = DocumentsDB(client) val result = documentsDB.createDocuments( databaseId = "", collectionId = "", - documents = listOf(), + documents = listOf(mapOf( + "\$id" to "example1", + "username" to "walter.obrien", + "email" to "walter.obrien@example.com", + "fullName" to "Walter O'Brien", + "age" to 30, + "isAdmin" to false + )), transactionId = "", // (optional) ) ``` diff --git a/docs/examples/kotlin/documentsdb/decrement-document-attribute.md b/docs/examples/kotlin/documentsdb/decrement-document-attribute.md index fdb11fe0..461a2fe0 100644 --- a/docs/examples/kotlin/documentsdb/decrement-document-attribute.md +++ b/docs/examples/kotlin/documentsdb/decrement-document-attribute.md @@ -14,8 +14,8 @@ val result = documentsDB.decrementDocumentAttribute( collectionId = "", documentId = "", attribute = "", - value = 1, // (optional) - min = 0, // (optional) + value = 1.0, // (optional) + min = 0.0, // (optional) transactionId = "", // (optional) ) ``` diff --git a/docs/examples/kotlin/documentsdb/increment-document-attribute.md b/docs/examples/kotlin/documentsdb/increment-document-attribute.md index 8286ad44..0539e4a2 100644 --- a/docs/examples/kotlin/documentsdb/increment-document-attribute.md +++ b/docs/examples/kotlin/documentsdb/increment-document-attribute.md @@ -14,8 +14,8 @@ val result = documentsDB.incrementDocumentAttribute( collectionId = "", documentId = "", attribute = "", - value = 1, // (optional) - max = 100, // (optional) + value = 1.0, // (optional) + max = 100.0, // (optional) transactionId = "", // (optional) ) ``` diff --git a/docs/examples/kotlin/documentsdb/list-transactions.md b/docs/examples/kotlin/documentsdb/list-transactions.md index 46571468..a1a47a2a 100644 --- a/docs/examples/kotlin/documentsdb/list-transactions.md +++ b/docs/examples/kotlin/documentsdb/list-transactions.md @@ -11,5 +11,6 @@ val documentsDB = DocumentsDB(client) val result = documentsDB.listTransactions( queries = listOf(), // (optional) + total = false, // (optional) ) ``` diff --git a/docs/examples/kotlin/graphql/mutation.md b/docs/examples/kotlin/graphql/mutation.md index 0cdb78e6..de686191 100644 --- a/docs/examples/kotlin/graphql/mutation.md +++ b/docs/examples/kotlin/graphql/mutation.md @@ -10,6 +10,8 @@ val client = Client(context) val graphql = Graphql(client) val result = graphql.mutation( - query = mapOf( "a" to "b" ), + query = mapOf( + "query" to "mutation { accountUpdateName(name: \"Walter\") { name } }" + ), ) ``` diff --git a/docs/examples/kotlin/graphql/query.md b/docs/examples/kotlin/graphql/query.md index 3359822a..cd0326da 100644 --- a/docs/examples/kotlin/graphql/query.md +++ b/docs/examples/kotlin/graphql/query.md @@ -10,6 +10,8 @@ val client = Client(context) val graphql = Graphql(client) val result = graphql.query( - query = mapOf( "a" to "b" ), + query = mapOf( + "query" to "query { localeGet { ip } }" + ), ) ``` diff --git a/docs/examples/kotlin/locale/list-codes.md b/docs/examples/kotlin/locale/list-codes.md index d7052144..31a89fc9 100644 --- a/docs/examples/kotlin/locale/list-codes.md +++ b/docs/examples/kotlin/locale/list-codes.md @@ -9,5 +9,7 @@ val client = Client(context) val locale = Locale(client) -val result = locale.listCodes() +val result = locale.listCodes( + total = false, // (optional) +) ``` diff --git a/docs/examples/kotlin/locale/list-continents.md b/docs/examples/kotlin/locale/list-continents.md index 03e917e4..a5ce1c20 100644 --- a/docs/examples/kotlin/locale/list-continents.md +++ b/docs/examples/kotlin/locale/list-continents.md @@ -9,5 +9,7 @@ val client = Client(context) val locale = Locale(client) -val result = locale.listContinents() +val result = locale.listContinents( + total = false, // (optional) +) ``` diff --git a/docs/examples/kotlin/locale/list-countries-eu.md b/docs/examples/kotlin/locale/list-countries-eu.md index a933ed85..9214428e 100644 --- a/docs/examples/kotlin/locale/list-countries-eu.md +++ b/docs/examples/kotlin/locale/list-countries-eu.md @@ -9,5 +9,7 @@ val client = Client(context) val locale = Locale(client) -val result = locale.listCountriesEU() +val result = locale.listCountriesEU( + total = false, // (optional) +) ``` diff --git a/docs/examples/kotlin/locale/list-countries-phones.md b/docs/examples/kotlin/locale/list-countries-phones.md index 8d4c7534..50355d79 100644 --- a/docs/examples/kotlin/locale/list-countries-phones.md +++ b/docs/examples/kotlin/locale/list-countries-phones.md @@ -9,5 +9,7 @@ val client = Client(context) val locale = Locale(client) -val result = locale.listCountriesPhones() +val result = locale.listCountriesPhones( + total = false, // (optional) +) ``` diff --git a/docs/examples/kotlin/locale/list-countries.md b/docs/examples/kotlin/locale/list-countries.md index e67b5fe7..2a762a53 100644 --- a/docs/examples/kotlin/locale/list-countries.md +++ b/docs/examples/kotlin/locale/list-countries.md @@ -9,5 +9,7 @@ val client = Client(context) val locale = Locale(client) -val result = locale.listCountries() +val result = locale.listCountries( + total = false, // (optional) +) ``` diff --git a/docs/examples/kotlin/locale/list-currencies.md b/docs/examples/kotlin/locale/list-currencies.md index fb0087d7..a25d489b 100644 --- a/docs/examples/kotlin/locale/list-currencies.md +++ b/docs/examples/kotlin/locale/list-currencies.md @@ -9,5 +9,7 @@ val client = Client(context) val locale = Locale(client) -val result = locale.listCurrencies() +val result = locale.listCurrencies( + total = false, // (optional) +) ``` diff --git a/docs/examples/kotlin/locale/list-languages.md b/docs/examples/kotlin/locale/list-languages.md index 30fb65bf..acaac2e9 100644 --- a/docs/examples/kotlin/locale/list-languages.md +++ b/docs/examples/kotlin/locale/list-languages.md @@ -9,5 +9,7 @@ val client = Client(context) val locale = Locale(client) -val result = locale.listLanguages() +val result = locale.listLanguages( + total = false, // (optional) +) ``` diff --git a/docs/examples/kotlin/oauth2/approve.md b/docs/examples/kotlin/oauth2/approve.md new file mode 100644 index 00000000..839a862d --- /dev/null +++ b/docs/examples/kotlin/oauth2/approve.md @@ -0,0 +1,17 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.approve( + grantId = "", + authorizationDetails = "", // (optional) + scope = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/authorize-post.md b/docs/examples/kotlin/oauth2/authorize-post.md new file mode 100644 index 00000000..88bc4ee0 --- /dev/null +++ b/docs/examples/kotlin/oauth2/authorize-post.md @@ -0,0 +1,28 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.authorizePost( + clientId = "", // (optional) + redirectUri = "https://example.com", // (optional) + responseType = "", // (optional) + scope = "", // (optional) + state = "", // (optional) + nonce = "", // (optional) + codeChallenge = "", // (optional) + codeChallengeMethod = "s256", // (optional) + prompt = "", // (optional) + maxAge = 0, // (optional) + authorizationDetails = "", // (optional) + resource = "", // (optional) + audience = "", // (optional) + requestUri = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/authorize.md b/docs/examples/kotlin/oauth2/authorize.md new file mode 100644 index 00000000..4d307e8d --- /dev/null +++ b/docs/examples/kotlin/oauth2/authorize.md @@ -0,0 +1,28 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.authorize( + clientId = "", // (optional) + redirectUri = "https://example.com", // (optional) + responseType = "", // (optional) + scope = "", // (optional) + state = "", // (optional) + nonce = "", // (optional) + codeChallenge = "", // (optional) + codeChallengeMethod = "s256", // (optional) + prompt = "", // (optional) + maxAge = 0, // (optional) + authorizationDetails = "", // (optional) + resource = "", // (optional) + audience = "", // (optional) + requestUri = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/create-device-authorization.md b/docs/examples/kotlin/oauth2/create-device-authorization.md new file mode 100644 index 00000000..ab307c37 --- /dev/null +++ b/docs/examples/kotlin/oauth2/create-device-authorization.md @@ -0,0 +1,19 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.createDeviceAuthorization( + clientId = "", // (optional) + scope = "", // (optional) + authorizationDetails = "", // (optional) + resource = "", // (optional) + audience = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/create-grant.md b/docs/examples/kotlin/oauth2/create-grant.md new file mode 100644 index 00000000..74f57c03 --- /dev/null +++ b/docs/examples/kotlin/oauth2/create-grant.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.createGrant( + userCode = "", +) +``` diff --git a/docs/examples/kotlin/oauth2/create-par.md b/docs/examples/kotlin/oauth2/create-par.md new file mode 100644 index 00000000..752c64eb --- /dev/null +++ b/docs/examples/kotlin/oauth2/create-par.md @@ -0,0 +1,27 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.createPAR( + clientId = "", + redirectUri = "https://example.com", + responseType = "code", + scope = "", // (optional) + state = "", // (optional) + nonce = "", // (optional) + codeChallenge = "", // (optional) + codeChallengeMethod = "s256", // (optional) + prompt = "", // (optional) + maxAge = 0, // (optional) + authorizationDetails = "", // (optional) + resource = "", // (optional) + audience = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/create-token.md b/docs/examples/kotlin/oauth2/create-token.md new file mode 100644 index 00000000..5e23344f --- /dev/null +++ b/docs/examples/kotlin/oauth2/create-token.md @@ -0,0 +1,24 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.createToken( + grantType = "", + code = "", // (optional) + refreshToken = "", // (optional) + deviceCode = "", // (optional) + clientId = "", // (optional) + clientSecret = "", // (optional) + codeVerifier = "", // (optional) + redirectUri = "https://example.com", // (optional) + resource = "", // (optional) + audience = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/get-grant.md b/docs/examples/kotlin/oauth2/get-grant.md new file mode 100644 index 00000000..4e5516be --- /dev/null +++ b/docs/examples/kotlin/oauth2/get-grant.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.getGrant( + grantId = "", +) +``` diff --git a/docs/examples/kotlin/oauth2/list-organizations.md b/docs/examples/kotlin/oauth2/list-organizations.md new file mode 100644 index 00000000..463ebec0 --- /dev/null +++ b/docs/examples/kotlin/oauth2/list-organizations.md @@ -0,0 +1,17 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.listOrganizations( + limit = 1, // (optional) + offset = 0, // (optional) + search = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/list-projects.md b/docs/examples/kotlin/oauth2/list-projects.md new file mode 100644 index 00000000..2105828f --- /dev/null +++ b/docs/examples/kotlin/oauth2/list-projects.md @@ -0,0 +1,17 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.listProjects( + limit = 1, // (optional) + offset = 0, // (optional) + search = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/logout-post.md b/docs/examples/kotlin/oauth2/logout-post.md new file mode 100644 index 00000000..48bd14ee --- /dev/null +++ b/docs/examples/kotlin/oauth2/logout-post.md @@ -0,0 +1,20 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.logoutPost( + idTokenHint = "", // (optional) + logoutHint = "", // (optional) + clientId = "", // (optional) + postLogoutRedirectUri = "https://example.com", // (optional) + state = "", // (optional) + uiLocales = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/logout.md b/docs/examples/kotlin/oauth2/logout.md new file mode 100644 index 00000000..08f2c1c1 --- /dev/null +++ b/docs/examples/kotlin/oauth2/logout.md @@ -0,0 +1,20 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.logout( + idTokenHint = "", // (optional) + logoutHint = "", // (optional) + clientId = "", // (optional) + postLogoutRedirectUri = "https://example.com", // (optional) + state = "", // (optional) + uiLocales = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/oauth2/reject.md b/docs/examples/kotlin/oauth2/reject.md new file mode 100644 index 00000000..f4392496 --- /dev/null +++ b/docs/examples/kotlin/oauth2/reject.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.reject( + grantId = "", +) +``` diff --git a/docs/examples/kotlin/oauth2/revoke.md b/docs/examples/kotlin/oauth2/revoke.md new file mode 100644 index 00000000..f5ef08b7 --- /dev/null +++ b/docs/examples/kotlin/oauth2/revoke.md @@ -0,0 +1,18 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.revoke( + token = "", + tokenTypeHint = "access_token", // (optional) + clientId = "", // (optional) + clientSecret = "", // (optional) +) +``` diff --git a/docs/examples/kotlin/storage/get-file-preview.md b/docs/examples/kotlin/storage/get-file-preview.md index e2d2851d..bd106928 100644 --- a/docs/examples/kotlin/storage/get-file-preview.md +++ b/docs/examples/kotlin/storage/get-file-preview.md @@ -21,7 +21,7 @@ val result = storage.getFilePreview( borderWidth = 0, // (optional) borderColor = "FFFFFF", // (optional) borderRadius = 0, // (optional) - opacity = 0, // (optional) + opacity = 0.0, // (optional) rotation = -360, // (optional) background = "FFFFFF", // (optional) output = ImageFormat.JPG, // (optional) diff --git a/docs/examples/kotlin/tablesdb/decrement-row-column.md b/docs/examples/kotlin/tablesdb/decrement-row-column.md index 157c3d11..b8288ad3 100644 --- a/docs/examples/kotlin/tablesdb/decrement-row-column.md +++ b/docs/examples/kotlin/tablesdb/decrement-row-column.md @@ -14,8 +14,8 @@ val result = tablesDB.decrementRowColumn( tableId = "", rowId = "", column = "", - value = 1, // (optional) - min = 0, // (optional) + value = 1.0, // (optional) + min = 0.0, // (optional) transactionId = "", // (optional) ) ``` diff --git a/docs/examples/kotlin/tablesdb/increment-row-column.md b/docs/examples/kotlin/tablesdb/increment-row-column.md index 802c6a2b..1aae1ffc 100644 --- a/docs/examples/kotlin/tablesdb/increment-row-column.md +++ b/docs/examples/kotlin/tablesdb/increment-row-column.md @@ -14,8 +14,8 @@ val result = tablesDB.incrementRowColumn( tableId = "", rowId = "", column = "", - value = 1, // (optional) - max = 100, // (optional) + value = 1.0, // (optional) + max = 100.0, // (optional) transactionId = "", // (optional) ) ``` diff --git a/docs/examples/kotlin/tablesdb/list-transactions.md b/docs/examples/kotlin/tablesdb/list-transactions.md index 03c9d5b4..b680ee15 100644 --- a/docs/examples/kotlin/tablesdb/list-transactions.md +++ b/docs/examples/kotlin/tablesdb/list-transactions.md @@ -11,5 +11,6 @@ val tablesDB = TablesDB(client) val result = tablesDB.listTransactions( queries = listOf(), // (optional) + total = false, // (optional) ) ``` diff --git a/docs/examples/kotlin/teams/create-membership.md b/docs/examples/kotlin/teams/create-membership.md index e5289e64..219119af 100644 --- a/docs/examples/kotlin/teams/create-membership.md +++ b/docs/examples/kotlin/teams/create-membership.md @@ -11,7 +11,7 @@ val teams = Teams(client) val result = teams.createMembership( teamId = "", - roles = listOf(), + roles = listOf("editor"), email = "email@example.com", // (optional) userId = "", // (optional) phone = "+12065550100", // (optional) diff --git a/docs/examples/kotlin/teams/update-membership.md b/docs/examples/kotlin/teams/update-membership.md index 6e721d4e..d9e39d7b 100644 --- a/docs/examples/kotlin/teams/update-membership.md +++ b/docs/examples/kotlin/teams/update-membership.md @@ -12,6 +12,6 @@ val teams = Teams(client) val result = teams.updateMembership( teamId = "", membershipId = "", - roles = listOf(), + roles = listOf("editor"), ) ``` diff --git a/docs/examples/kotlin/vectorsdb/create-operations.md b/docs/examples/kotlin/vectorsdb/create-operations.md index 1a2908af..8bd201a3 100644 --- a/docs/examples/kotlin/vectorsdb/create-operations.md +++ b/docs/examples/kotlin/vectorsdb/create-operations.md @@ -17,7 +17,10 @@ val result = vectorsDB.createOperations( "collectionId" to "", "documentId" to "", "data" to mapOf( - "name" to "Walter O'Brien" + "embeddings" to listOf(0.12, -0.55, 0.88, 1.02), + "metadata" to mapOf( + "name" to "First document" + ) ) )), // (optional) ) diff --git a/docs/examples/kotlin/vectorsdb/list-transactions.md b/docs/examples/kotlin/vectorsdb/list-transactions.md index 97981c01..76fb10e3 100644 --- a/docs/examples/kotlin/vectorsdb/list-transactions.md +++ b/docs/examples/kotlin/vectorsdb/list-transactions.md @@ -11,5 +11,6 @@ val vectorsDB = VectorsDB(client) val result = vectorsDB.listTransactions( queries = listOf(), // (optional) + total = false, // (optional) ) ``` diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 45641217..3a60a6b3 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -46,9 +46,11 @@ android { } defaultConfig { - minSdk = 23 + minSdk = 24 buildConfigField("String", "SDK_VERSION", "\"$publishVersion\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Push: R8 rules for the bundled MQTT client, applied to consuming apps. + consumerProguardFiles("consumer-rules.pro") } buildTypes { @@ -82,6 +84,7 @@ dependencies { api("com.squareup.okhttp3:okhttp:5.5.0") implementation("com.google.code.gson:gson:2.14.0") + implementation("com.hivemq:hivemq-mqtt-client-shaded:1.3.6") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0") implementation("androidx.lifecycle:lifecycle-common-java8:2.11.0") diff --git a/library/consumer-rules.pro b/library/consumer-rules.pro new file mode 100644 index 00000000..04d7db2c --- /dev/null +++ b/library/consumer-rules.pro @@ -0,0 +1,27 @@ +# Push: rules applied to apps that shrink with R8. +# +# The shaded HiveMQ MQTT client relocates Netty and JCTools under +# com.hivemq.client.internal.shaded. Netty references optional classes that are never loaded +# on Android: native transports, the HTTP/WebSocket codecs, proxy handlers, alternative TLS +# providers (tcnative, BouncyCastle, Conscrypt, Jetty ALPN) and logging backends. +-dontwarn com.hivemq.client.internal.shaded.io.netty.channel.epoll.** +-dontwarn com.hivemq.client.internal.shaded.io.netty.channel.kqueue.** +-dontwarn com.hivemq.client.internal.shaded.io.netty.handler.codec.http.** +-dontwarn com.hivemq.client.internal.shaded.io.netty.handler.proxy.** +-dontwarn com.hivemq.client.internal.shaded.io.netty.internal.tcnative.** +-dontwarn org.bouncycastle.** +-dontwarn org.conscrypt.** +-dontwarn org.eclipse.jetty.** +-dontwarn org.apache.log4j.** +-dontwarn org.apache.logging.log4j.** +-dontwarn org.slf4j.** +-dontwarn reactor.blockhound.** + +# Netty and JCTools reach their own fields by name, through atomic field updaters and Unsafe offsets. +-keepclassmembernames class com.hivemq.client.internal.shaded.io.netty.** { ; } +-keepclassmembers class com.hivemq.client.internal.shaded.org.jctools.** { ; } + +# Netty's leak detector looks these methods up by name when its classes load. +-keepclassmembers class com.hivemq.client.internal.shaded.io.netty.buffer.AbstractByteBufAllocator { *** toLeakAwareBuffer(...); } +-keepclassmembers class com.hivemq.client.internal.shaded.io.netty.buffer.AdvancedLeakAwareByteBuf { *** recordLeakNonRefCountingOperation(...); } +-keepclassmembers class com.hivemq.client.internal.shaded.io.netty.util.ReferenceCountUtil { *** touch(...); } diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml index 899321de..44f894ed 100644 --- a/library/src/main/AndroidManifest.xml +++ b/library/src/main/AndroidManifest.xml @@ -3,5 +3,39 @@ + + + + + + + - \ No newline at end of file + + + + + + + + + + + + + diff --git a/library/src/main/java/io/appwrite/Client.kt b/library/src/main/java/io/appwrite/Client.kt index 40431217..c364e835 100644 --- a/library/src/main/java/io/appwrite/Client.kt +++ b/library/src/main/java/io/appwrite/Client.kt @@ -8,6 +8,7 @@ import io.appwrite.cookies.stores.SharedPreferencesCookieStore import io.appwrite.exceptions.AppwriteException import io.appwrite.extensions.fromJson import io.appwrite.extensions.toJsonRequestBody +import io.appwrite.extensions.toJsonWithNulls import io.appwrite.models.InputFile import io.appwrite.models.UploadProgress import kotlinx.coroutines.CoroutineScope @@ -49,7 +50,11 @@ class Client @JvmOverloads constructor( var endpoint: String = "https://cloud.appwrite.io/v1", @set:JvmSynthetic var endpointRealtime: String? = null, - private var selfSigned: Boolean = false + private var selfSigned: Boolean = false, + @set:JvmSynthetic + var endpointPush: String? = null, + @set:JvmSynthetic + var pushClientId: String? = null ) : CoroutineScope { companion object { @@ -96,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 "28.0.0", + "x-sdk-version" to "29.0.0", "x-appwrite-response-format" to "2.3.0" ) config = mutableMapOf() @@ -324,6 +329,37 @@ class Client @JvmOverloads constructor( return this } + /** + * Set push endpoint + * + * The MQTT broker URL the [io.appwrite.services.Push] service connects to, e.g. + * `mqtt://host:1883` or `mqtts://host:8883`. + * + * @param endpoint + * + * @return this + */ + fun setPushEndpoint(endpoint: String): Client { + this.endpointPush = endpoint + return this + } + + /** + * Set push client id + * + * A stable client id for the [io.appwrite.services.Push] service. The broker keys its + * offline-replay cursor on this id, so pass a stable value to resume replay across + * restarts. Defaults to the userId decoded from the JWT (else the credential) when unset. + * + * @param pushClientId + * + * @return this + */ + fun setPushClientId(pushClientId: String): Client { + this.pushClientId = pushClientId + return this + } + /** * Add Header * @@ -439,9 +475,12 @@ class Client @JvmOverloads constructor( filteredParams.forEach { when { - it.key == "file" -> { + it.value is MultipartBody.Part -> { builder.addPart(it.value as MultipartBody.Part) } + it.value is Map<*, *> -> { + builder.addFormDataPart(it.key, (it.value as Map<*, *>).toJsonWithNulls()) + } it.value is List<*> -> { val list = it.value as List<*> for (index in list.indices) { @@ -508,11 +547,21 @@ class Client @JvmOverloads constructor( headers: MutableMap, params: MutableMap, responseType: Class, - converter: ((Any) -> T), + converter: ((Any) -> T)? = null, paramName: String, idParamName: String? = null, onProgress: ((UploadProgress) -> Unit)? = null, ): T { + if (params[paramName] == null) { + return call( + method = "POST", + path, + headers, + params, + responseType, + converter + ) + } val input = params[paramName] as InputFile val size: Long = when (input.sourceType) { "path", "file" -> { @@ -524,7 +573,7 @@ class Client @JvmOverloads constructor( else -> throw UnsupportedOperationException() } - if (size < CHUNK_SIZE) { + 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()) @@ -690,7 +739,7 @@ class Client @JvmOverloads constructor( result = completedResultRef.get() ?: lastResultRef.get() } - return converter(result as Map) + return converter?.invoke(result as Map) ?: result as T } /** diff --git a/library/src/main/java/io/appwrite/ID.kt b/library/src/main/java/io/appwrite/ID.kt index cf415258..93f948fc 100644 --- a/library/src/main/java/io/appwrite/ID.kt +++ b/library/src/main/java/io/appwrite/ID.kt @@ -1,6 +1,5 @@ package io.appwrite -import java.time.Instant import kotlin.math.floor import kotlin.random.Random @@ -12,8 +11,7 @@ class ID { // Generate an hex ID based on timestamp // Recreated from https://www.php.net/manual/en/function.uniqid.php private fun hexTimestamp(): String { - val now = Instant.now() - val sec = now.epochSecond + val sec = System.currentTimeMillis() / 1000 val usec = (System.nanoTime() / 1000) % 1000 val hexTimestamp = "%08x%05x".format(sec, usec) diff --git a/library/src/main/java/io/appwrite/Operator.kt b/library/src/main/java/io/appwrite/Operator.kt index 578dc087..74ab2cb9 100644 --- a/library/src/main/java/io/appwrite/Operator.kt +++ b/library/src/main/java/io/appwrite/Operator.kt @@ -22,6 +22,13 @@ class Operator( ) { override fun toString() = this.toJson() + fun toMap(): Map = mapOf( + "method" to method, + "values" to values, + ).filterValues { it != null } + + fun toJson(): String = toMap().toJson() + companion object { fun increment(value: Number = 1, max: Number? = null): String { require(!value.toDouble().isNaN() && !value.toDouble().isInfinite()) { "Value cannot be NaN or Infinity" } diff --git a/library/src/main/java/io/appwrite/Query.kt b/library/src/main/java/io/appwrite/Query.kt index b0d4ab0f..23f100df 100644 --- a/library/src/main/java/io/appwrite/Query.kt +++ b/library/src/main/java/io/appwrite/Query.kt @@ -18,6 +18,14 @@ class Query( */ override fun toString() = this.toJson() + fun toMap(): Map = mapOf( + "method" to method, + "attribute" to attribute, + "values" to values, + ).filterValues { it != null } + + fun toJson(): String = toMap().toJson() + companion object { /** @@ -350,7 +358,7 @@ class Query( * @param queries The list of query strings to combine. * @returns The query string. */ - fun or(queries: List) = Query("or", null, queries.map { it.fromJson() }).toJson() + fun or(queries: List) = Query("or", null, queries.map { it.fromJson>() }).toJson() /** * Combine multiple queries using logical AND operator. @@ -358,7 +366,7 @@ class Query( * @param queries The list of query strings to combine. * @returns The query string. */ - fun and(queries: List) = Query("and", null, queries.map { it.fromJson() }).toJson() + fun and(queries: List) = Query("and", null, queries.map { it.fromJson>() }).toJson() /** * Filter array elements where at least one element matches all the specified queries. @@ -367,7 +375,7 @@ class Query( * @param queries The list of query strings to match against array elements. * @returns The query string. */ - fun elemMatch(attribute: String, queries: List) = Query("elemMatch", attribute, queries.map { it.fromJson() }).toJson() + fun elemMatch(attribute: String, queries: List) = Query("elemMatch", attribute, queries.map { it.fromJson>() }).toJson() /** * Filter resources where attribute is at a specific distance from the given coordinates. diff --git a/library/src/main/java/io/appwrite/Topic.kt b/library/src/main/java/io/appwrite/Topic.kt new file mode 100644 index 00000000..8945da0e --- /dev/null +++ b/library/src/main/java/io/appwrite/Topic.kt @@ -0,0 +1,68 @@ +package io.appwrite + +/** + * Validate the levels passed to `path()`: a non-empty list of non-empty strings, none holding + * the level separator `/` or the wildcards `+` and `#` (use `any()` and `all()` for those). + */ +private fun levelsOf(levels: List): List { + require(levels.isNotEmpty()) { "path() needs at least one level" } + levels.forEachIndexed { index, level -> + require(level.isNotEmpty()) { "empty level at index $index" } + require(!level.contains("/")) { + val parts = level.split("/").joinToString(", ") { "'$it'" } + "\"$level\" contains \"/\", split it: path([$parts])" + } + require(level != "+") { "\"+\" is reserved, use any()" } + require(!level.contains("+")) { "\"$level\" contains \"+\", use any() for wildcards" } + require(level != "#") { "\"#\" is reserved, use all()" } + require(!level.contains("#")) { "\"$level\" contains \"#\", use all() for wildcards" } + } + return levels.toList() +} + +/** + * Helper to build MQTT topics for [services.Push], level by level. + * + * ``` + * Topic.path(listOf("user", userId, "notification")) // "user/123/notification" + * Topic.path(listOf("user")).any().path(listOf("notification")) // "user/+/notification" + * Topic.path(listOf("org")).any().path(listOf("logs")).all() // "org/+/logs/#" + * ``` + * + * `any()` matches exactly one level and `all()` everything below, at any depth (including the + * parent itself). `all()` ends the topic, so it returns a [ResolvedTopic]. + * + * @throws IllegalArgumentException from `path()` when a level is empty or holds `/`, `+` or `#`. + */ +class Topic private constructor( + private val levels: List, +) { + /** Append the given levels. */ + fun path(levels: List): Topic = Topic(this.levels + levelsOf(levels)) + + /** Append a single-level wildcard (`+`): matches exactly one level. */ + fun any(): Topic = Topic(levels + "+") + + /** Append the multi-level wildcard (`#`) and end the topic. */ + fun all(): ResolvedTopic = ResolvedTopic(levels + "#") + + override fun toString(): String = levels.joinToString("/") + + companion object { + /** Start a topic with the given levels, e.g. `Topic.path(listOf("user", userId))`. */ + fun path(levels: List): Topic = Topic(levelsOf(levels)) + + /** Start a topic with a single-level wildcard (`+`). */ + fun any(): Topic = Topic(listOf("+")) + + /** The multi-level wildcard alone (`#`): matches every topic. */ + fun all(): ResolvedTopic = ResolvedTopic(listOf("#")) + } +} + +/** A topic ended by `all()`: nothing can be appended after the `#` wildcard. */ +class ResolvedTopic internal constructor( + private val levels: List, +) { + override fun toString(): String = levels.joinToString("/") +} diff --git a/library/src/main/java/io/appwrite/cookies/InternalCookie.kt b/library/src/main/java/io/appwrite/cookies/InternalCookie.kt index dfb8948a..6a1422b1 100644 --- a/library/src/main/java/io/appwrite/cookies/InternalCookie.kt +++ b/library/src/main/java/io/appwrite/cookies/InternalCookie.kt @@ -31,6 +31,38 @@ data class InternalCookie( cookie.version ) + companion object { + fun from(map: Map<*, *>) = InternalCookie( + comment = map["comment"] as? String, + commentURL = map["commentURL"] as? String, + discard = map["discard"] as? Boolean, + domain = map["domain"] as String, + maxAge = (map["maxAge"] as? Number)?.toLong(), + name = map["name"] as String, + path = map["path"] as? String, + portlist = map["portlist"] as? String, + secure = map["secure"] as? Boolean, + value = map["value"] as String, + version = (map["version"] as? Number)?.toInt(), + httpOnly = map["httpOnly"] as? Boolean, + ) + } + + fun toMap(): Map = mapOf( + "comment" to comment, + "commentURL" to commentURL, + "discard" to discard, + "domain" to domain, + "maxAge" to maxAge, + "name" to name, + "path" to path, + "portlist" to portlist, + "secure" to secure, + "value" to value, + "version" to version, + "httpOnly" to httpOnly, + ) + fun toHttpCookie() = HttpCookie(name, value).apply { comment = this@InternalCookie.comment commentURL = this@InternalCookie.commentURL diff --git a/library/src/main/java/io/appwrite/cookies/stores/SharedPreferencesCookieStore.kt b/library/src/main/java/io/appwrite/cookies/stores/SharedPreferencesCookieStore.kt index 61986f6c..85d97e0f 100644 --- a/library/src/main/java/io/appwrite/cookies/stores/SharedPreferencesCookieStore.kt +++ b/library/src/main/java/io/appwrite/cookies/stores/SharedPreferencesCookieStore.kt @@ -4,7 +4,6 @@ import android.content.SharedPreferences import android.os.Build import android.util.Log import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import io.appwrite.cookies.InternalCookie import java.net.HttpCookie import java.net.URI @@ -20,9 +19,8 @@ open class SharedPreferencesCookieStore( preferences.all.forEach { (key, value) -> try { val index = URI.create(key) - val listType = object : TypeToken>() {}.type - val internalCookies = - gson.fromJson>(value.toString(), listType) + val internalCookies = gson.fromJson(value.toString(), List::class.java) + .map { InternalCookie.from(it as Map<*, *>) } val cookies = internalCookies.map { it.toHttpCookie() }.toMutableList() uriIndex[index] = cookies } catch (exception: Throwable) { @@ -59,8 +57,7 @@ open class SharedPreferencesCookieStore( } } - val listType = object : TypeToken>() {}.type - val json = gson.toJson(internalCookies, listType) + val json = gson.toJson(internalCookies.map { it.toMap() }) preferences .edit() @@ -82,8 +79,7 @@ open class SharedPreferencesCookieStore( } } } - val listType = object : TypeToken>() {}.type - val json = gson.toJson(internalCookies, listType) + val json = gson.toJson(internalCookies?.map { it.toMap() }) preferences.edit().apply { when (cookies) { diff --git a/library/src/main/java/io/appwrite/enums/OAuthProvider.kt b/library/src/main/java/io/appwrite/enums/OAuthProvider.kt index 4eb5cbfd..265e7807 100644 --- a/library/src/main/java/io/appwrite/enums/OAuthProvider.kt +++ b/library/src/main/java/io/appwrite/enums/OAuthProvider.kt @@ -87,6 +87,8 @@ enum class OAuthProvider(val value: String) { TRADESHIFTBOX("tradeshiftBox"), @SerializedName("twitch") TWITCH("twitch"), + @SerializedName("webflow") + WEBFLOW("webflow"), @SerializedName("wordpress") WORDPRESS("wordpress"), @SerializedName("x") diff --git a/library/src/main/java/io/appwrite/models/Account.kt b/library/src/main/java/io/appwrite/models/Account.kt new file mode 100644 index 00000000..38865ff8 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Account.kt @@ -0,0 +1,266 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * Account + */ +data class Account( + /** + * User ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * User creation date in ISO 8601 format. + */ + @SerializedName("\$createdAt") + val createdAt: String, + + /** + * User update date in ISO 8601 format. + */ + @SerializedName("\$updatedAt") + val updatedAt: String, + + /** + * User name. + */ + @SerializedName("name") + val name: String, + + /** + * User registration date in ISO 8601 format. + */ + @SerializedName("registration") + val registration: String, + + /** + * User status. Pass `true` for enabled and `false` for disabled. + */ + @SerializedName("status") + val status: Boolean, + + /** + * Labels for the user. + */ + @SerializedName("labels") + val labels: List, + + /** + * Password update time in ISO 8601 format. + */ + @SerializedName("passwordUpdate") + val passwordUpdate: String, + + /** + * User email address. + */ + @SerializedName("email") + val email: String, + + /** + * User phone number in E.164 format. + */ + @SerializedName("phone") + val phone: String, + + /** + * Email verification status. + */ + @SerializedName("emailVerification") + val emailVerification: Boolean, + + /** + * Canonical form of the user email address. + */ + @SerializedName("emailCanonical") + var emailCanonical: String?, + + /** + * Whether the user email is from a free email provider. + */ + @SerializedName("emailIsFree") + var emailIsFree: Boolean?, + + /** + * Whether the user email is from a disposable email provider. + */ + @SerializedName("emailIsDisposable") + var emailIsDisposable: Boolean?, + + /** + * Whether the user email is from a corporate domain. + */ + @SerializedName("emailIsCorporate") + var emailIsCorporate: Boolean?, + + /** + * Whether the user email is in its canonical form. + */ + @SerializedName("emailIsCanonical") + var emailIsCanonical: Boolean?, + + /** + * Whether the password was found in a known data breach the last time it was checked. Null when the password has never been checked. + */ + @SerializedName("passwordPwned") + var passwordPwned: Boolean?, + + /** + * Phone verification status. + */ + @SerializedName("phoneVerification") + val phoneVerification: Boolean, + + /** + * Multi factor authentication status. + */ + @SerializedName("mfa") + val mfa: Boolean, + + /** + * User preferences as a key-value object + */ + @SerializedName("prefs") + val prefs: Preferences, + + /** + * A user-owned message receiver. A single user may have multiple e.g. emails, phones, and a browser. Each target is registered with a single provider. + */ + @SerializedName("targets") + val targets: List, + + /** + * Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours. + */ + @SerializedName("accessedAt") + val accessedAt: String, + + /** + * Whether the user can impersonate other users. + */ + @SerializedName("impersonator") + var impersonator: Boolean?, + + /** + * ID of the original actor performing the impersonation. Present only when the current request is impersonating another user. Internal audit logs attribute the action to this user, while the impersonated target is recorded only in internal audit payload data. + */ + @SerializedName("impersonatorUserId") + var impersonatorUserId: String?, + +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + "\$createdAt" to createdAt as Any, + "\$updatedAt" to updatedAt as Any, + "name" to name as Any, + "registration" to registration as Any, + "status" to status as Any, + "labels" to labels as Any, + "passwordUpdate" to passwordUpdate as Any, + "email" to email as Any, + "phone" to phone as Any, + "emailVerification" to emailVerification as Any, + "emailCanonical" to emailCanonical as Any?, + "emailIsFree" to emailIsFree as Any?, + "emailIsDisposable" to emailIsDisposable as Any?, + "emailIsCorporate" to emailIsCorporate as Any?, + "emailIsCanonical" to emailIsCanonical as Any?, + "passwordPwned" to passwordPwned as Any?, + "phoneVerification" to phoneVerification as Any, + "mfa" to mfa as Any, + "prefs" to prefs.toMap() as Any, + "targets" to targets.map { it.toMap() } as Any, + "accessedAt" to accessedAt as Any, + "impersonator" to impersonator as Any?, + "impersonatorUserId" to impersonatorUserId as Any?, + ) + + companion object { + operator fun invoke( + id: String, + createdAt: String, + updatedAt: String, + name: String, + registration: String, + status: Boolean, + labels: List, + passwordUpdate: String, + email: String, + phone: String, + emailVerification: Boolean, + emailCanonical: String?, + emailIsFree: Boolean?, + emailIsDisposable: Boolean?, + emailIsCorporate: Boolean?, + emailIsCanonical: Boolean?, + passwordPwned: Boolean?, + phoneVerification: Boolean, + mfa: Boolean, + prefs: Preferences>, + targets: List, + accessedAt: String, + impersonator: Boolean?, + impersonatorUserId: String?, + ) = Account>( + id, + createdAt, + updatedAt, + name, + registration, + status, + labels, + passwordUpdate, + email, + phone, + emailVerification, + emailCanonical, + emailIsFree, + emailIsDisposable, + emailIsCorporate, + emailIsCanonical, + passwordPwned, + phoneVerification, + mfa, + prefs, + targets, + accessedAt, + impersonator, + impersonatorUserId, + ) + + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + nestedType: Class + ) = Account( + id = map["\$id"] as String, + createdAt = map["\$createdAt"] as String, + updatedAt = map["\$updatedAt"] as String, + name = map["name"] as String, + registration = map["registration"] as String, + status = map["status"] as Boolean, + labels = map["labels"] as List, + passwordUpdate = map["passwordUpdate"] as String, + email = map["email"] as String, + phone = map["phone"] as String, + emailVerification = map["emailVerification"] as Boolean, + emailCanonical = map["emailCanonical"] as? String, + emailIsFree = map["emailIsFree"] as? Boolean, + emailIsDisposable = map["emailIsDisposable"] as? Boolean, + emailIsCorporate = map["emailIsCorporate"] as? Boolean, + emailIsCanonical = map["emailIsCanonical"] as? Boolean, + passwordPwned = map["passwordPwned"] as? Boolean, + phoneVerification = map["phoneVerification"] as Boolean, + mfa = map["mfa"] as Boolean, + prefs = Preferences.from(map = map["prefs"] as Map, nestedType), + targets = (map["targets"] as List>).map { Target.from(map = it) }, + accessedAt = map["accessedAt"] as String, + impersonator = map["impersonator"] as? Boolean, + impersonatorUserId = map["impersonatorUserId"] as? String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/App.kt b/library/src/main/java/io/appwrite/models/App.kt new file mode 100644 index 00000000..f4a715c8 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/App.kt @@ -0,0 +1,229 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * App + */ +data class App( + /** + * App ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * App creation time in ISO 8601 format. + */ + @SerializedName("\$createdAt") + val createdAt: String, + + /** + * App update date in ISO 8601 format. + */ + @SerializedName("\$updatedAt") + val updatedAt: String, + + /** + * Application name. + */ + @SerializedName("name") + val name: String, + + /** + * Application description shown to users during OAuth2 consent. + */ + @SerializedName("description") + val description: String, + + /** + * Application homepage URL shown to users during OAuth2 consent. + */ + @SerializedName("clientUri") + val clientUri: String, + + /** + * Application logo URL shown to users during OAuth2 consent. + */ + @SerializedName("logoUri") + val logoUri: String, + + /** + * Application privacy policy URL shown to users during OAuth2 consent. + */ + @SerializedName("privacyPolicyUrl") + val privacyPolicyUrl: String, + + /** + * Application terms of service URL shown to users during OAuth2 consent. + */ + @SerializedName("termsUrl") + val termsUrl: String, + + /** + * Application support or security contact emails. + */ + @SerializedName("contacts") + val contacts: List, + + /** + * Application tagline shown to users during OAuth2 consent. + */ + @SerializedName("tagline") + val tagline: String, + + /** + * Application tags shown to users during OAuth2 consent. + */ + @SerializedName("tags") + val tags: List, + + /** + * Application labels. Read-only for clients; only a server SDK using a project API key can update them. + */ + @SerializedName("labels") + val labels: List, + + /** + * Application image URLs shown to users during OAuth2 consent. + */ + @SerializedName("images") + val images: List, + + /** + * Application support URL shown to users during OAuth2 consent. + */ + @SerializedName("supportUrl") + val supportUrl: String, + + /** + * Application data deletion URL shown to users during OAuth2 consent. + */ + @SerializedName("dataDeletionUrl") + val dataDeletionUrl: String, + + /** + * List of authorized redirect URIs. These URIs can be used to redirect users after they authenticate. + */ + @SerializedName("redirectUris") + val redirectUris: List, + + /** + * List of authorized post-logout redirect URIs for OpenID Connect RP-Initiated Logout. The logout endpoint only redirects users to URIs in this list after ending their session. + */ + @SerializedName("postLogoutRedirectUris") + val postLogoutRedirectUris: List, + + /** + * Whether the app is enabled or not. + */ + @SerializedName("enabled") + val enabled: Boolean, + + /** + * OAuth2 client type. `public` for SPAs, mobile, and native apps that cannot keep a client secret (PKCE required); `confidential` for server-side clients that authenticate with a client secret. + */ + @SerializedName("type") + val type: String, + + /** + * Whether this client may use the OAuth2 Device Authorization Grant (RFC 8628). + */ + @SerializedName("deviceFlow") + val deviceFlow: Boolean, + + /** + * ID of team that owns the application, if owned by team. Otherwise, user ID will be used. + */ + @SerializedName("teamId") + val teamId: String, + + /** + * ID of user who owns the application, if owned by user. Otherwise, team ID will be used. + */ + @SerializedName("userId") + val userId: String, + + /** + * Scopes the application requests when installed on a team. Organization-level and project-level scopes only. + */ + @SerializedName("installationScopes") + val installationScopes: List, + + /** + * URL users are redirected to after creating or updating an installation of this application. Empty for no redirect. + */ + @SerializedName("installationRedirectUrl") + val installationRedirectUrl: String, + + /** + * List of application secrets. + */ + @SerializedName("secrets") + val secrets: List, + +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + "\$createdAt" to createdAt as Any, + "\$updatedAt" to updatedAt as Any, + "name" to name as Any, + "description" to description as Any, + "clientUri" to clientUri as Any, + "logoUri" to logoUri as Any, + "privacyPolicyUrl" to privacyPolicyUrl as Any, + "termsUrl" to termsUrl as Any, + "contacts" to contacts as Any, + "tagline" to tagline as Any, + "tags" to tags as Any, + "labels" to labels as Any, + "images" to images as Any, + "supportUrl" to supportUrl as Any, + "dataDeletionUrl" to dataDeletionUrl as Any, + "redirectUris" to redirectUris as Any, + "postLogoutRedirectUris" to postLogoutRedirectUris as Any, + "enabled" to enabled as Any, + "type" to type as Any, + "deviceFlow" to deviceFlow as Any, + "teamId" to teamId as Any, + "userId" to userId as Any, + "installationScopes" to installationScopes as Any, + "installationRedirectUrl" to installationRedirectUrl as Any, + "secrets" to secrets.map { it.toMap() } as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = App( + id = map["\$id"] as String, + createdAt = map["\$createdAt"] as String, + updatedAt = map["\$updatedAt"] as String, + name = map["name"] as String, + description = map["description"] as String, + clientUri = map["clientUri"] as String, + logoUri = map["logoUri"] as String, + privacyPolicyUrl = map["privacyPolicyUrl"] as String, + termsUrl = map["termsUrl"] as String, + contacts = map["contacts"] as List, + tagline = map["tagline"] as String, + tags = map["tags"] as List, + labels = map["labels"] as List, + images = map["images"] as List, + supportUrl = map["supportUrl"] as String, + dataDeletionUrl = map["dataDeletionUrl"] as String, + redirectUris = map["redirectUris"] as List, + postLogoutRedirectUris = map["postLogoutRedirectUris"] as List, + enabled = map["enabled"] as Boolean, + type = map["type"] as String, + deviceFlow = map["deviceFlow"] as Boolean, + teamId = map["teamId"] as String, + userId = map["userId"] as String, + installationScopes = map["installationScopes"] as List, + installationRedirectUrl = map["installationRedirectUrl"] as String, + secrets = (map["secrets"] as List>).map { AppSecret.from(map = it) }, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/AppKey.kt b/library/src/main/java/io/appwrite/models/AppKey.kt new file mode 100644 index 00000000..f59e8d50 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/AppKey.kt @@ -0,0 +1,93 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * AppKey + */ +data class AppKey( + /** + * App key ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * App key creation time in ISO 8601 format. + */ + @SerializedName("\$createdAt") + val createdAt: String, + + /** + * App key update time in ISO 8601 format. + */ + @SerializedName("\$updatedAt") + val updatedAt: String, + + /** + * Application ID this app key belongs to. + */ + @SerializedName("appId") + val appId: String, + + /** + * App key secret. + */ + @SerializedName("secret") + val secret: String, + + /** + * Last few characters of the app key secret, used to help identify it. + */ + @SerializedName("hint") + val hint: String, + + /** + * ID of the user who created the app key. + */ + @SerializedName("createdById") + val createdById: String, + + /** + * Name of the user who created the app key. + */ + @SerializedName("createdByName") + val createdByName: String, + + /** + * Time the app key was last used for authentication in ISO 8601 format. Null if never used. + */ + @SerializedName("lastAccessedAt") + var lastAccessedAt: String?, + +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + "\$createdAt" to createdAt as Any, + "\$updatedAt" to updatedAt as Any, + "appId" to appId as Any, + "secret" to secret as Any, + "hint" to hint as Any, + "createdById" to createdById as Any, + "createdByName" to createdByName as Any, + "lastAccessedAt" to lastAccessedAt as Any?, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = AppKey( + id = map["\$id"] as String, + createdAt = map["\$createdAt"] as String, + updatedAt = map["\$updatedAt"] as String, + appId = map["appId"] as String, + secret = map["secret"] as String, + hint = map["hint"] as String, + createdById = map["createdById"] as String, + createdByName = map["createdByName"] as String, + lastAccessedAt = map["lastAccessedAt"] as? String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/AppKeyList.kt b/library/src/main/java/io/appwrite/models/AppKeyList.kt new file mode 100644 index 00000000..ea4b1cca --- /dev/null +++ b/library/src/main/java/io/appwrite/models/AppKeyList.kt @@ -0,0 +1,37 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * App keys list + */ +data class AppKeyList( + /** + * Total number of keys that matched your query. + */ + @SerializedName("total") + val total: Long, + + /** + * List of keys. + */ + @SerializedName("keys") + val keys: List, + +) { + fun toMap(): Map = mapOf( + "total" to total as Any, + "keys" to keys.map { it.toMap() } as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = AppKeyList( + total = (map["total"] as Number).toLong(), + keys = (map["keys"] as List>).map { AppKey.from(map = it) }, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/AppScope.kt b/library/src/main/java/io/appwrite/models/AppScope.kt new file mode 100644 index 00000000..4cc7fe95 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/AppScope.kt @@ -0,0 +1,61 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * AppScope + */ +data class AppScope( + /** + * Scope value as requested by apps. + */ + @SerializedName("value") + val value: String, + + /** + * Human-readable description of what the scope grants. + */ + @SerializedName("description") + val description: String, + + /** + * What the scope grants access to. One of `account`, `project`, or `organization`. Only `project` and `organization` scopes are installable. + */ + @SerializedName("type") + val type: String, + + /** + * Scope category, used to group scopes on consent and installation screens. + */ + @SerializedName("category") + val category: String, + + /** + * Whether the scope is deprecated. Deprecated scopes can still be requested but should not be offered for new grants. + */ + @SerializedName("deprecated") + val deprecated: Boolean, + +) { + fun toMap(): Map = mapOf( + "value" to value as Any, + "description" to description as Any, + "type" to type as Any, + "category" to category as Any, + "deprecated" to deprecated as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = AppScope( + value = map["value"] as String, + description = map["description"] as String, + type = map["type"] as String, + category = map["category"] as String, + deprecated = map["deprecated"] as Boolean, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/AppScopeList.kt b/library/src/main/java/io/appwrite/models/AppScopeList.kt new file mode 100644 index 00000000..cbcf91fb --- /dev/null +++ b/library/src/main/java/io/appwrite/models/AppScopeList.kt @@ -0,0 +1,37 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * App scopes list + */ +data class AppScopeList( + /** + * Total number of scopes that matched your query. + */ + @SerializedName("total") + val total: Long, + + /** + * List of scopes. + */ + @SerializedName("scopes") + val scopes: List, + +) { + fun toMap(): Map = mapOf( + "total" to total as Any, + "scopes" to scopes.map { it.toMap() } as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = AppScopeList( + total = (map["total"] as Number).toLong(), + scopes = (map["scopes"] as List>).map { AppScope.from(map = it) }, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/AppSecret.kt b/library/src/main/java/io/appwrite/models/AppSecret.kt new file mode 100644 index 00000000..0349ffbd --- /dev/null +++ b/library/src/main/java/io/appwrite/models/AppSecret.kt @@ -0,0 +1,93 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * AppSecret + */ +data class AppSecret( + /** + * Secret ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * Secret creation time in ISO 8601 format. + */ + @SerializedName("\$createdAt") + val createdAt: String, + + /** + * Secret update time in ISO 8601 format. + */ + @SerializedName("\$updatedAt") + val updatedAt: String, + + /** + * Application ID this secret belongs to. + */ + @SerializedName("appId") + val appId: String, + + /** + * Always empty. The application client secret is returned only once, in the response of the createSecret method. + */ + @SerializedName("secret") + val secret: String, + + /** + * Last few characters of the client secret, used to help identify it. + */ + @SerializedName("hint") + val hint: String, + + /** + * ID of the user who created the secret. + */ + @SerializedName("createdById") + val createdById: String, + + /** + * Name of the user who created the secret. + */ + @SerializedName("createdByName") + val createdByName: String, + + /** + * Time the secret was last used for authentication in ISO 8601 format. Null if never used. + */ + @SerializedName("lastAccessedAt") + var lastAccessedAt: String?, + +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + "\$createdAt" to createdAt as Any, + "\$updatedAt" to updatedAt as Any, + "appId" to appId as Any, + "secret" to secret as Any, + "hint" to hint as Any, + "createdById" to createdById as Any, + "createdByName" to createdByName as Any, + "lastAccessedAt" to lastAccessedAt as Any?, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = AppSecret( + id = map["\$id"] as String, + createdAt = map["\$createdAt"] as String, + updatedAt = map["\$updatedAt"] as String, + appId = map["appId"] as String, + secret = map["secret"] as String, + hint = map["hint"] as String, + createdById = map["createdById"] as String, + createdByName = map["createdByName"] as String, + lastAccessedAt = map["lastAccessedAt"] as? String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/AppSecretList.kt b/library/src/main/java/io/appwrite/models/AppSecretList.kt new file mode 100644 index 00000000..04d2ebb7 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/AppSecretList.kt @@ -0,0 +1,37 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * App secrets list + */ +data class AppSecretList( + /** + * Total number of secrets that matched your query. + */ + @SerializedName("total") + val total: Long, + + /** + * List of secrets. + */ + @SerializedName("secrets") + val secrets: List, + +) { + fun toMap(): Map = mapOf( + "total" to total as Any, + "secrets" to secrets.map { it.toMap() } as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = AppSecretList( + total = (map["total"] as Number).toLong(), + secrets = (map["secrets"] as List>).map { AppSecret.from(map = it) }, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/AppSecretPlaintext.kt b/library/src/main/java/io/appwrite/models/AppSecretPlaintext.kt new file mode 100644 index 00000000..098b7f19 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/AppSecretPlaintext.kt @@ -0,0 +1,93 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * AppSecretPlaintext + */ +data class AppSecretPlaintext( + /** + * Secret ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * Secret creation time in ISO 8601 format. + */ + @SerializedName("\$createdAt") + val createdAt: String, + + /** + * Secret update time in ISO 8601 format. + */ + @SerializedName("\$updatedAt") + val updatedAt: String, + + /** + * Application ID this secret belongs to. + */ + @SerializedName("appId") + val appId: String, + + /** + * Application client secret. Returned only when the secret is created; subsequent reads always return an empty value. + */ + @SerializedName("secret") + val secret: String, + + /** + * Last few characters of the client secret, used to help identify it. + */ + @SerializedName("hint") + val hint: String, + + /** + * ID of the user who created the secret. + */ + @SerializedName("createdById") + val createdById: String, + + /** + * Name of the user who created the secret. + */ + @SerializedName("createdByName") + val createdByName: String, + + /** + * Time the secret was last used for authentication in ISO 8601 format. Null if never used. + */ + @SerializedName("lastAccessedAt") + var lastAccessedAt: String?, + +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + "\$createdAt" to createdAt as Any, + "\$updatedAt" to updatedAt as Any, + "appId" to appId as Any, + "secret" to secret as Any, + "hint" to hint as Any, + "createdById" to createdById as Any, + "createdByName" to createdByName as Any, + "lastAccessedAt" to lastAccessedAt as Any?, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = AppSecretPlaintext( + id = map["\$id"] as String, + createdAt = map["\$createdAt"] as String, + updatedAt = map["\$updatedAt"] as String, + appId = map["appId"] as String, + secret = map["secret"] as String, + hint = map["hint"] as String, + createdById = map["createdById"] as String, + createdByName = map["createdByName"] as String, + lastAccessedAt = map["lastAccessedAt"] as? String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/AppsList.kt b/library/src/main/java/io/appwrite/models/AppsList.kt new file mode 100644 index 00000000..5dcff821 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/AppsList.kt @@ -0,0 +1,37 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * Apps list + */ +data class AppsList( + /** + * Total number of apps that matched your query. + */ + @SerializedName("total") + val total: Long, + + /** + * List of apps. + */ + @SerializedName("apps") + val apps: List, + +) { + fun toMap(): Map = mapOf( + "total" to total as Any, + "apps" to apps.map { it.toMap() } as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = AppsList( + total = (map["total"] as Number).toLong(), + apps = (map["apps"] as List>).map { App.from(map = it) }, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/InputFile.kt b/library/src/main/java/io/appwrite/models/InputFile.kt index a5b377aa..779da916 100644 --- a/library/src/main/java/io/appwrite/models/InputFile.kt +++ b/library/src/main/java/io/appwrite/models/InputFile.kt @@ -2,8 +2,6 @@ package io.appwrite.models import java.io.File import java.net.URLConnection -import java.nio.file.Files -import java.nio.file.Paths class InputFile private constructor() { @@ -17,9 +15,7 @@ class InputFile private constructor() { fun fromFile(file: File) = InputFile().apply { path = file.canonicalPath filename = file.name - mimeType = Files.probeContentType(Paths.get(file.canonicalPath)) - ?: URLConnection.guessContentTypeFromName(filename) - ?: "" + mimeType = URLConnection.guessContentTypeFromName(filename) ?: "" sourceType = "file" } diff --git a/library/src/main/java/io/appwrite/models/Oauth2Approve.kt b/library/src/main/java/io/appwrite/models/Oauth2Approve.kt new file mode 100644 index 00000000..e09ab9ea --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2Approve.kt @@ -0,0 +1,29 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 Approve + */ +data class Oauth2Approve( + /** + * URL the end user should be redirected to after the grant is approved, carrying the authorization `code` and/or `id_token` along with the original `state`. + */ + @SerializedName("redirectUrl") + val redirectUrl: String, + +) { + fun toMap(): Map = mapOf( + "redirectUrl" to redirectUrl as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2Approve( + redirectUrl = map["redirectUrl"] as String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2Authorize.kt b/library/src/main/java/io/appwrite/models/Oauth2Authorize.kt new file mode 100644 index 00000000..e0812f7e --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2Authorize.kt @@ -0,0 +1,37 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 Authorize + */ +data class Oauth2Authorize( + /** + * OAuth2 grant ID. Set when the user must give explicit consent; pass it to the approve or reject endpoint. Empty when a redirect URL is returned instead. + */ + @SerializedName("grantId") + val grantId: String, + + /** + * URL the end user should be redirected to when the flow can complete without consent. Empty when consent is still required. + */ + @SerializedName("redirectUrl") + val redirectUrl: String, + +) { + fun toMap(): Map = mapOf( + "grantId" to grantId as Any, + "redirectUrl" to redirectUrl as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2Authorize( + grantId = map["grantId"] as String, + redirectUrl = map["redirectUrl"] as String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2DeviceAuthorization.kt b/library/src/main/java/io/appwrite/models/Oauth2DeviceAuthorization.kt new file mode 100644 index 00000000..58c7e5ee --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2DeviceAuthorization.kt @@ -0,0 +1,69 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 Device Authorization + */ +data class Oauth2DeviceAuthorization( + /** + * Device verification code used by the client to poll the token endpoint. + */ + @SerializedName("device_code") + val device_code: String, + + /** + * Short code the end user enters on the verification page. + */ + @SerializedName("user_code") + val user_code: String, + + /** + * URL where the end user enters the user code. + */ + @SerializedName("verification_uri") + val verification_uri: String, + + /** + * Verification URL with the user code prefilled as a query parameter. + */ + @SerializedName("verification_uri_complete") + val verification_uri_complete: String, + + /** + * Lifetime of the device code and user code in seconds. + */ + @SerializedName("expires_in") + val expires_in: Long, + + /** + * Minimum polling interval for the token endpoint in seconds. + */ + @SerializedName("interval") + val interval: Long, + +) { + fun toMap(): Map = mapOf( + "device_code" to device_code as Any, + "user_code" to user_code as Any, + "verification_uri" to verification_uri as Any, + "verification_uri_complete" to verification_uri_complete as Any, + "expires_in" to expires_in as Any, + "interval" to interval as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2DeviceAuthorization( + device_code = map["device_code"] as String, + user_code = map["user_code"] as String, + verification_uri = map["verification_uri"] as String, + verification_uri_complete = map["verification_uri_complete"] as String, + expires_in = (map["expires_in"] as Number).toLong(), + interval = (map["interval"] as Number).toLong(), + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2Grant.kt b/library/src/main/java/io/appwrite/models/Oauth2Grant.kt new file mode 100644 index 00000000..ef1a7c39 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2Grant.kt @@ -0,0 +1,117 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 Grant + */ +data class Oauth2Grant( + /** + * Grant ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * Grant creation time in ISO 8601 format. + */ + @SerializedName("\$createdAt") + val createdAt: String, + + /** + * Grant update date in ISO 8601 format. + */ + @SerializedName("\$updatedAt") + val updatedAt: String, + + /** + * ID of the user the grant belongs to. + */ + @SerializedName("userId") + val userId: String, + + /** + * ID of the OAuth2 client (app) the grant was requested for. + */ + @SerializedName("appId") + val appId: String, + + /** + * Requested OAuth2 scopes the user is being asked to consent to. + */ + @SerializedName("scopes") + val scopes: List, + + /** + * Requested RFC 8707 resource indicators the user is being asked to consent to. + */ + @SerializedName("resources") + val resources: List, + + /** + * Requested authorization_details the user is being asked to consent to, as a JSON string. Each entry has a `type` plus project-defined fields. + */ + @SerializedName("authorizationDetails") + val authorizationDetails: String, + + /** + * OIDC prompt directive the consent screen should honor. Space-separated list of: login, consent, select_account. + */ + @SerializedName("prompt") + val prompt: String, + + /** + * Redirect URI the user will be sent to after the flow completes. + */ + @SerializedName("redirectUri") + val redirectUri: String, + + /** + * Unix timestamp of when the user last authenticated. + */ + @SerializedName("authTime") + val authTime: Long, + + /** + * Grant expiration time in ISO 8601 format. + */ + @SerializedName("expire") + val expire: String, + +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + "\$createdAt" to createdAt as Any, + "\$updatedAt" to updatedAt as Any, + "userId" to userId as Any, + "appId" to appId as Any, + "scopes" to scopes as Any, + "resources" to resources as Any, + "authorizationDetails" to authorizationDetails as Any, + "prompt" to prompt as Any, + "redirectUri" to redirectUri as Any, + "authTime" to authTime as Any, + "expire" to expire as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2Grant( + id = map["\$id"] as String, + createdAt = map["\$createdAt"] as String, + updatedAt = map["\$updatedAt"] as String, + userId = map["userId"] as String, + appId = map["appId"] as String, + scopes = map["scopes"] as List, + resources = map["resources"] as List, + authorizationDetails = map["authorizationDetails"] as String, + prompt = map["prompt"] as String, + redirectUri = map["redirectUri"] as String, + authTime = (map["authTime"] as Number).toLong(), + expire = map["expire"] as String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2Organization.kt b/library/src/main/java/io/appwrite/models/Oauth2Organization.kt new file mode 100644 index 00000000..605916a0 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2Organization.kt @@ -0,0 +1,29 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 Organization + */ +data class Oauth2Organization( + /** + * Organization ID. + */ + @SerializedName("\$id") + val id: String, + +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2Organization( + id = map["\$id"] as String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2OrganizationList.kt b/library/src/main/java/io/appwrite/models/Oauth2OrganizationList.kt new file mode 100644 index 00000000..b90ee636 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2OrganizationList.kt @@ -0,0 +1,37 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 accessible organizations list + */ +data class Oauth2OrganizationList( + /** + * Total number of organizations that matched your query. + */ + @SerializedName("total") + val total: Long, + + /** + * List of organizations. + */ + @SerializedName("organizations") + val organizations: List, + +) { + fun toMap(): Map = mapOf( + "total" to total as Any, + "organizations" to organizations.map { it.toMap() } as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2OrganizationList( + total = (map["total"] as Number).toLong(), + organizations = (map["organizations"] as List>).map { Oauth2Organization.from(map = it) }, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2PAR.kt b/library/src/main/java/io/appwrite/models/Oauth2PAR.kt new file mode 100644 index 00000000..94d175ee --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2PAR.kt @@ -0,0 +1,37 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 PAR + */ +data class Oauth2PAR( + /** + * Authorization request handle to pass to the authorize endpoint. + */ + @SerializedName("request_uri") + val request_uri: String, + + /** + * Lifetime of the authorization request handle in seconds. + */ + @SerializedName("expires_in") + val expires_in: Long, + +) { + fun toMap(): Map = mapOf( + "request_uri" to request_uri as Any, + "expires_in" to expires_in as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2PAR( + request_uri = map["request_uri"] as String, + expires_in = (map["expires_in"] as Number).toLong(), + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2Project.kt b/library/src/main/java/io/appwrite/models/Oauth2Project.kt new file mode 100644 index 00000000..eb085dce --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2Project.kt @@ -0,0 +1,45 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 Project + */ +data class Oauth2Project( + /** + * Project ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * Region ID the project is deployed in. + */ + @SerializedName("region") + val region: String, + + /** + * API endpoint of the region the project is deployed in. Empty when the region has no public hostname configured. + */ + @SerializedName("endpoint") + val endpoint: String, + +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + "region" to region as Any, + "endpoint" to endpoint as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2Project( + id = map["\$id"] as String, + region = map["region"] as String, + endpoint = map["endpoint"] as String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2ProjectList.kt b/library/src/main/java/io/appwrite/models/Oauth2ProjectList.kt new file mode 100644 index 00000000..551b9ee4 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2ProjectList.kt @@ -0,0 +1,37 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 accessible projects list + */ +data class Oauth2ProjectList( + /** + * Total number of projects that matched your query. + */ + @SerializedName("total") + val total: Long, + + /** + * List of projects. + */ + @SerializedName("projects") + val projects: List, + +) { + fun toMap(): Map = mapOf( + "total" to total as Any, + "projects" to projects.map { it.toMap() } as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2ProjectList( + total = (map["total"] as Number).toLong(), + projects = (map["projects"] as List>).map { Oauth2Project.from(map = it) }, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2Reject.kt b/library/src/main/java/io/appwrite/models/Oauth2Reject.kt new file mode 100644 index 00000000..b23f308c --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2Reject.kt @@ -0,0 +1,29 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 Reject + */ +data class Oauth2Reject( + /** + * URL the end user should be redirected to after the grant is rejected, carrying an `access_denied` error. + */ + @SerializedName("redirectUrl") + val redirectUrl: String, + +) { + fun toMap(): Map = mapOf( + "redirectUrl" to redirectUrl as Any, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2Reject( + redirectUrl = map["redirectUrl"] as String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/Oauth2Token.kt b/library/src/main/java/io/appwrite/models/Oauth2Token.kt new file mode 100644 index 00000000..f36f7768 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Oauth2Token.kt @@ -0,0 +1,77 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * OAuth2 Token + */ +data class Oauth2Token( + /** + * OAuth2 access token. + */ + @SerializedName("access_token") + val access_token: String, + + /** + * OAuth2 token type. + */ + @SerializedName("token_type") + val token_type: String, + + /** + * Access token lifetime in seconds. + */ + @SerializedName("expires_in") + val expires_in: Long, + + /** + * OAuth2 refresh token. + */ + @SerializedName("refresh_token") + val refresh_token: String, + + /** + * Space-separated scopes granted to the access token. + */ + @SerializedName("scope") + val scope: String, + + /** + * Granted RFC 9396 authorization details as a JSON string. + */ + @SerializedName("authorization_details") + var authorization_details: String?, + + /** + * OpenID Connect ID token. Returned when the `openid` scope is granted. + */ + @SerializedName("id_token") + var id_token: String?, + +) { + fun toMap(): Map = mapOf( + "access_token" to access_token as Any, + "token_type" to token_type as Any, + "expires_in" to expires_in as Any, + "refresh_token" to refresh_token as Any, + "scope" to scope as Any, + "authorization_details" to authorization_details as Any?, + "id_token" to id_token as Any?, + ) + + companion object { + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Oauth2Token( + access_token = map["access_token"] as String, + token_type = map["token_type"] as String, + expires_in = (map["expires_in"] as Number).toLong(), + refresh_token = map["refresh_token"] as String, + scope = map["scope"] as String, + authorization_details = map["authorization_details"] as? String, + id_token = map["id_token"] as? String, + ) + } +} diff --git a/library/src/main/java/io/appwrite/models/RealtimeModels.kt b/library/src/main/java/io/appwrite/models/RealtimeModels.kt index ed55bbb1..8d083ad0 100644 --- a/library/src/main/java/io/appwrite/models/RealtimeModels.kt +++ b/library/src/main/java/io/appwrite/models/RealtimeModels.kt @@ -39,14 +39,33 @@ data class RealtimeCallback( open class RealtimeResponse( val type: String, val data: Any? -) +) { + companion object { + fun from(map: Map) = RealtimeResponse( + type = map["type"] as? String ?: "", + data = map["data"], + ) + } +} data class RealtimeResponseEvent( val events: Collection, val channels: Collection, val timestamp: String, var payload: T -) +) { + companion object { + fun from(map: Map): RealtimeResponseEvent? { + val payload = map["payload"] ?: return null + return RealtimeResponseEvent( + events = (map["events"] as? List<*>)?.map { it.toString() } ?: emptyList(), + channels = (map["channels"] as? List<*>)?.map { it.toString() } ?: emptyList(), + timestamp = map["timestamp"]?.toString() ?: "", + payload = payload, + ) + } + } +} enum class RealtimeCode(val value: Int) { POLICY_VIOLATION(1008), diff --git a/library/src/main/java/io/appwrite/services/Account.kt b/library/src/main/java/io/appwrite/services/Account.kt index 17cdf820..8df38221 100644 --- a/library/src/main/java/io/appwrite/services/Account.kt +++ b/library/src/main/java/io/appwrite/services/Account.kt @@ -14,7 +14,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import java.io.File /** - * The Account service allows you to authenticate and manage a user account. */ + * The Account service allows you to authenticate and manage a user account. + */ class Account(client: Client) : Service(client) { /** * Get the currently logged in user. @@ -1548,11 +1549,17 @@ class Account(client: Client) : Service(client) { /** * Get the list of active sessions across different devices for the currently logged in user. * + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.SessionList] */ - suspend fun listSessions(): io.appwrite.models.SessionList { + @JvmOverloads + suspend fun listSessions( + total: Boolean? = null, + ): io.appwrite.models.SessionList { val apiPath = "/account/sessions" - val apiParams = mutableMapOf() + val apiParams = mutableMapOf( + "total" to total, + ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), "accept" to "application/json", @@ -1572,13 +1579,19 @@ class Account(client: Client) : Service(client) { } /** - * Delete all sessions from the user account and remove any sessions cookies from the end client. + * Delete all sessions from the user account and remove any sessions cookies from the end client. Pass `current` as false to keep the session making the request and sign out of every other session. * + * @param current Delete the current session too. Use false to sign out of every other session while staying signed in on this one. * @return [Any] */ - suspend fun deleteSessions(): Any { + @JvmOverloads + suspend fun deleteSessions( + current: Boolean? = null, + ): Any { val apiPath = "/account/sessions" - val apiParams = mutableMapOf() + val apiParams = mutableMapOf( + "current" to current, + ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), "content-type" to "application/json", @@ -1621,22 +1634,26 @@ class Account(client: Client) : Service(client) { } /** - * Allow the user to login into their account by providing a valid email and password combination. This route will create a new session for the user. + * Allow the user to login into their account by providing a valid email and password combination. This route will create a new session for the user. Use the optional `duration` parameter to create a shorter session, for example when the user doesn't choose "remember me". It must be at least 60 seconds and cannot exceed the project maximum session length. * * A user is limited to 10 active sessions at a time by default. [Learn more about session limits](https://appwrite.io/docs/authentication-security#limits). * * @param email User email. * @param password User password. Must be at least 8 chars. + * @param duration Session length in seconds. Minimum is 60 seconds, and it cannot exceed the project maximum session length. Defaults to the project maximum session length. * @return [io.appwrite.models.Session] */ + @JvmOverloads suspend fun createEmailPasswordSession( email: String, password: String, + duration: Long? = null, ): io.appwrite.models.Session { val apiPath = "/account/sessions/email" val apiParams = mutableMapOf( "email" to email, "password" to password, + "duration" to duration, ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), @@ -1763,7 +1780,7 @@ class Account(client: Client) : Service(client) { * A user is limited to 10 active sessions at a time by default. [Learn more about session limits](https://appwrite.io/docs/authentication-security#limits). * * - * @param provider OAuth2 Provider. Currently, supported providers are: amazon, apple, appwrite, auth0, authentik, autodesk, bitbucket, bitly, box, cloudflare, dailymotion, discord, disqus, dropbox, etsy, facebook, figma, fusionauth, github, gitlab, google, huggingface, kakao, keycloak, kick, linkedin, microsoft, notion, oidc, okta, paypal, paypalSandbox, podio, resend, salesforce, slack, spotify, stripe, tiktok, tradeshift, tradeshiftBox, twitch, wordpress, x, yahoo, yammer, yandex, zoho, zoom. + * @param provider OAuth2 Provider. Currently, supported providers are: amazon, apple, appwrite, auth0, authentik, autodesk, bitbucket, bitly, box, cloudflare, dailymotion, discord, disqus, dropbox, etsy, facebook, figma, fusionauth, github, gitlab, google, huggingface, kakao, keycloak, kick, linkedin, microsoft, notion, oidc, okta, paypal, paypalSandbox, podio, resend, salesforce, slack, spotify, stripe, tiktok, tradeshift, tradeshiftBox, twitch, webflow, wordpress, x, yahoo, yammer, yandex, zoho, zoom. * @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. @@ -2247,7 +2264,7 @@ class Account(client: Client) : Service(client) { * * A user is limited to 10 active sessions at a time by default. [Learn more about session limits](https://appwrite.io/docs/authentication-security#limits). * - * @param provider OAuth2 Provider. Currently, supported providers are: amazon, apple, appwrite, auth0, authentik, autodesk, bitbucket, bitly, box, cloudflare, dailymotion, discord, disqus, dropbox, etsy, facebook, figma, fusionauth, github, gitlab, google, huggingface, kakao, keycloak, kick, linkedin, microsoft, notion, oidc, okta, paypal, paypalSandbox, podio, resend, salesforce, slack, spotify, stripe, tiktok, tradeshift, tradeshiftBox, twitch, wordpress, x, yahoo, yammer, yandex, zoho, zoom. + * @param provider OAuth2 Provider. Currently, supported providers are: amazon, apple, appwrite, auth0, authentik, autodesk, bitbucket, bitly, box, cloudflare, dailymotion, discord, disqus, dropbox, etsy, facebook, figma, fusionauth, github, gitlab, google, huggingface, kakao, keycloak, kick, linkedin, microsoft, notion, oidc, okta, paypal, paypalSandbox, podio, resend, salesforce, slack, spotify, stripe, tiktok, tradeshift, tradeshiftBox, twitch, webflow, wordpress, x, yahoo, yammer, yandex, zoho, zoom. * @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. diff --git a/library/src/main/java/io/appwrite/services/Apps.kt b/library/src/main/java/io/appwrite/services/Apps.kt new file mode 100644 index 00000000..20f0de13 --- /dev/null +++ b/library/src/main/java/io/appwrite/services/Apps.kt @@ -0,0 +1,881 @@ +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 + +/** + * The Apps service allows you to manage OAuth2 applications, their keys, secrets, scopes, and installations. + */ +class Apps(client: Client) : Service(client) { + /** + * List applications. + * + * @param queries Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of 100 queries are allowed, each 4096 characters long. + * @param total When set to false, the total count returned will be 0 and will not be calculated. + * @return [io.appwrite.models.AppsList] + */ + @JvmOverloads + suspend fun list( + queries: List? = null, + total: Boolean? = null, + ): io.appwrite.models.AppsList { + val apiPath = "/apps" + val apiParams = mutableMapOf( + "queries" to queries, + "total" to total, + ) + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppsList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppsList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppsList::class.java, + converter, + ) + } + + /** + * Create a new application. + * + * @param appId Application 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. + * @param name Application name. + * @param redirectUris Redirect URIs. Each must be an https URL, an http loopback URL (localhost, 127.0.0.1, [::1]), or a private-use scheme URI (e.g. com.example.app:/oauth), and must not contain a fragment. + * @param description Application description shown to users during OAuth2 consent. + * @param clientUri Application homepage URL shown to users during OAuth2 consent. + * @param logoUri Application logo URL shown to users during OAuth2 consent. + * @param privacyPolicyUrl Application privacy policy URL shown to users during OAuth2 consent. + * @param termsUrl Application terms of service URL shown to users during OAuth2 consent. + * @param contacts Application support or security contact emails. Maximum of 100 contacts are allowed. + * @param tagline Application tagline shown to users during OAuth2 consent. + * @param tags Application tags shown to users during OAuth2 consent. Maximum of 100 tags are allowed, each up to 64 characters long. + * @param images Application image URLs shown to users during OAuth2 consent. Maximum of 100 images are allowed. + * @param supportUrl Application support URL shown to users during OAuth2 consent. + * @param dataDeletionUrl Application data deletion URL shown to users during OAuth2 consent. + * @param postLogoutRedirectUris Post-logout redirect URIs for OpenID Connect RP-Initiated Logout. Each must be an https URL, an http loopback URL, or a private-use scheme URI, and must not contain a fragment. After ending the user session, the logout endpoint only redirects to URIs in this list. + * @param enabled Is application enabled? + * @param type OAuth2 client type. Use `public` for SPAs, mobile, and native apps that cannot keep a `client_secret` — PKCE is then required at the token endpoint. Use `confidential` for server-side clients that present a `client_secret`. Defaults to `confidential`. + * @param deviceFlow Allow this client to use the OAuth2 Device Authorization Grant (RFC 8628) for input-constrained devices such as TVs and CLIs. Defaults to false. + * @param teamId Team unique ID. + * @return [io.appwrite.models.App] + */ + @JvmOverloads + suspend fun create( + appId: String, + name: String, + redirectUris: List, + description: String? = null, + clientUri: String? = null, + logoUri: String? = null, + privacyPolicyUrl: String? = null, + termsUrl: String? = null, + contacts: List? = null, + tagline: String? = null, + tags: List? = null, + images: List? = null, + supportUrl: String? = null, + dataDeletionUrl: String? = null, + postLogoutRedirectUris: List? = null, + enabled: Boolean? = null, + type: String? = null, + deviceFlow: Boolean? = null, + teamId: String? = null, + ): io.appwrite.models.App { + val apiPath = "/apps" + val apiParams = mutableMapOf( + "appId" to appId, + "name" to name, + "description" to description, + "clientUri" to clientUri, + "logoUri" to logoUri, + "privacyPolicyUrl" to privacyPolicyUrl, + "termsUrl" to termsUrl, + "contacts" to contacts, + "tagline" to tagline, + "tags" to tags, + "images" to images, + "supportUrl" to supportUrl, + "dataDeletionUrl" to dataDeletionUrl, + "redirectUris" to redirectUris, + "postLogoutRedirectUris" to postLogoutRedirectUris, + "enabled" to enabled, + "type" to type, + "deviceFlow" to deviceFlow, + "teamId" to teamId, + ) + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.App = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.App.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.App::class.java, + converter, + ) + } + + /** + * List scopes an application can request when installed on a team. + * + * @return [io.appwrite.models.AppScopeList] + */ + suspend fun listInstallationScopes(): io.appwrite.models.AppScopeList { + val apiPath = "/apps/scopes/installations" + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppScopeList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppScopeList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppScopeList::class.java, + converter, + ) + } + + /** + * List scopes an application can request during the OAuth2 flow. + * + * @return [io.appwrite.models.AppScopeList] + */ + suspend fun listOAuth2Scopes(): io.appwrite.models.AppScopeList { + val apiPath = "/apps/scopes/oauth2" + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppScopeList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppScopeList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppScopeList::class.java, + converter, + ) + } + + /** + * Get an application by its unique ID. + * + * @param appId Application unique ID. + * @return [io.appwrite.models.App] + */ + suspend fun get( + appId: String, + ): io.appwrite.models.App { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.App = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.App.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.App::class.java, + converter, + ) + } + + /** + * Update an application by its unique ID. + * + * @param appId Application unique ID. + * @param name Application name. + * @param description Application description shown to users during OAuth2 consent. + * @param clientUri Application homepage URL shown to users during OAuth2 consent. + * @param logoUri Application logo URL shown to users during OAuth2 consent. + * @param privacyPolicyUrl Application privacy policy URL shown to users during OAuth2 consent. + * @param termsUrl Application terms of service URL shown to users during OAuth2 consent. + * @param contacts Application support or security contact emails. Maximum of 100 contacts are allowed. + * @param tagline Application tagline shown to users during OAuth2 consent. + * @param tags Application tags shown to users during OAuth2 consent. Maximum of 100 tags are allowed, each up to 64 characters long. + * @param images Application image URLs shown to users during OAuth2 consent. Maximum of 100 images are allowed. + * @param supportUrl Application support URL shown to users during OAuth2 consent. + * @param dataDeletionUrl Application data deletion URL shown to users during OAuth2 consent. + * @param enabled Is application enabled? + * @param redirectUris Redirect URIs. Each must be an https URL, an http loopback URL (localhost, 127.0.0.1, [::1]), or a private-use scheme URI (e.g. com.example.app:/oauth), and must not contain a fragment. + * @param postLogoutRedirectUris Post-logout redirect URIs for OpenID Connect RP-Initiated Logout. Each must be an https URL, an http loopback URL, or a private-use scheme URI, and must not contain a fragment. After ending the user session, the logout endpoint only redirects to URIs in this list. + * @param type OAuth2 client type. Use `public` for SPAs, mobile, and native apps that cannot keep a `client_secret` — PKCE is then required at the token endpoint. Use `confidential` for server-side clients that present a `client_secret`. Defaults to `confidential`. + * @param deviceFlow Allow this client to use the OAuth2 Device Authorization Grant (RFC 8628) for input-constrained devices such as TVs and CLIs. Defaults to false. + * @param installationScopes Scopes the application requests when installed on a team. Only scopes allowed by the project's OAuth2 server installation scopes configuration are accepted; use the list installation scopes endpoint to discover available values. Maximum of 100 scopes are allowed. + * @param installationRedirectUrl URL users are redirected to after creating or updating an installation of this application. Must be an https URL, an http loopback URL (localhost, 127.0.0.1, [::1]), or a private-use scheme URI, and must not contain a fragment. Leave empty for no redirect. + * @return [io.appwrite.models.App] + */ + @JvmOverloads + suspend fun update( + appId: String, + name: String, + description: String? = null, + clientUri: String? = null, + logoUri: String? = null, + privacyPolicyUrl: String? = null, + termsUrl: String? = null, + contacts: List? = null, + tagline: String? = null, + tags: List? = null, + images: List? = null, + supportUrl: String? = null, + dataDeletionUrl: String? = null, + enabled: Boolean? = null, + redirectUris: List? = null, + postLogoutRedirectUris: List? = null, + type: String? = null, + deviceFlow: Boolean? = null, + installationScopes: List? = null, + installationRedirectUrl: String? = null, + ): io.appwrite.models.App { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf( + "name" to name, + "description" to description, + "clientUri" to clientUri, + "logoUri" to logoUri, + "privacyPolicyUrl" to privacyPolicyUrl, + "termsUrl" to termsUrl, + "contacts" to contacts, + "tagline" to tagline, + "tags" to tags, + "images" to images, + "supportUrl" to supportUrl, + "dataDeletionUrl" to dataDeletionUrl, + "enabled" to enabled, + "redirectUris" to redirectUris, + "postLogoutRedirectUris" to postLogoutRedirectUris, + "type" to type, + "deviceFlow" to deviceFlow, + "installationScopes" to installationScopes, + "installationRedirectUrl" to installationRedirectUrl, + ) + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.App = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.App.from(map = it as Map) + } + return client.call( + "PUT", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.App::class.java, + converter, + ) + } + + /** + * Delete an application by its unique ID. + * + * @param appId Application unique ID. + * @return [Any] + */ + suspend fun delete( + appId: String, + ): Any { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + return client.call( + "DELETE", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } + + /** + * List installations of an application. Requires an app key sent in the `X-Appwrite-Key` header alongside the `X-Appwrite-App` header, or a caller with update access to the app. + * + * @param appId Application unique ID. + * @param queries Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of 100 queries are allowed, each 4096 characters long. + * @param total When set to false, the total count returned will be 0 and will not be calculated. + * @return [io.appwrite.models.AppInstallationList] + */ + @JvmOverloads + suspend fun listInstallations( + appId: String, + queries: List? = null, + total: Boolean? = null, + ): io.appwrite.models.AppInstallationList { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}/installations" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf( + "queries" to queries, + "total" to total, + ) + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppInstallationList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppInstallationList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppInstallationList::class.java, + converter, + ) + } + + /** + * Get an installation of an application by its unique ID. Requires an app key sent in the `X-Appwrite-Key` header alongside the `X-Appwrite-App` header, or a caller with update access to the app. + * + * @param appId Application unique ID. + * @param installationId Installation unique ID. + * @return [io.appwrite.models.AppInstallation] + */ + suspend fun getInstallation( + appId: String, + installationId: String, + ): io.appwrite.models.AppInstallation { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + if (installationId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"installationId\"") + } + val apiPath = ("/apps/{appId}/installations/{installationId}" + .replace("{appId}", appId) + .replace("{installationId}", installationId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppInstallation = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppInstallation.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppInstallation::class.java, + converter, + ) + } + + /** + * Delete an installation of an application by its unique ID. Requires a caller with update access to the app. Previously issued installation access tokens are revoked. + * + * @param appId Application unique ID. + * @param installationId Installation unique ID. + * @return [Any] + */ + suspend fun deleteInstallation( + appId: String, + installationId: String, + ): Any { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + if (installationId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"installationId\"") + } + val apiPath = ("/apps/{appId}/installations/{installationId}" + .replace("{appId}", appId) + .replace("{installationId}", installationId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + return client.call( + "DELETE", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } + + /** + * Create a token for an installation of an application. Requires an app key sent in the `X-Appwrite-Key` header alongside the `X-Appwrite-App` header, or a caller with update access to the app. The returned token carries the scopes and authorization details granted to the installation, and can be used as an `Authorization: Bearer` header everywhere OAuth2 access tokens are accepted. Multiple tokens can be active for the same installation at once; each token stays valid until it expires or the installation is updated or deleted. + * + * @param appId Application unique ID. + * @param installationId Installation unique ID. + * @return [io.appwrite.models.Oauth2Token] + */ + suspend fun createInstallationToken( + appId: String, + installationId: String, + ): io.appwrite.models.Oauth2Token { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + if (installationId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"installationId\"") + } + val apiPath = ("/apps/{appId}/installations/{installationId}/tokens" + .replace("{appId}", appId) + .replace("{installationId}", installationId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2Token = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2Token.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2Token::class.java, + converter, + ) + } + + /** + * List app keys for an application. + * + * @param appId Application unique ID. + * @param queries Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of 100 queries are allowed, each 4096 characters long. + * @param total When set to false, the total count returned will be 0 and will not be calculated. + * @return [io.appwrite.models.AppKeyList] + */ + @JvmOverloads + suspend fun listKeys( + appId: String, + queries: List? = null, + total: Boolean? = null, + ): io.appwrite.models.AppKeyList { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}/keys" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf( + "queries" to queries, + "total" to total, + ) + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppKeyList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppKeyList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppKeyList::class.java, + converter, + ) + } + + /** + * Create a new app key for an application. App keys carry no scopes; send one in the `X-Appwrite-Key` header alongside the `X-Appwrite-App` header to list the application's installations and create installation access tokens. + * + * @param appId Application unique ID. + * @return [io.appwrite.models.AppKey] + */ + suspend fun createKey( + appId: String, + ): io.appwrite.models.AppKey { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}/keys" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppKey = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppKey.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppKey::class.java, + converter, + ) + } + + /** + * Get an app key by its unique ID. + * + * @param appId Application unique ID. + * @param keyId App key unique ID. + * @return [io.appwrite.models.AppKey] + */ + suspend fun getKey( + appId: String, + keyId: String, + ): io.appwrite.models.AppKey { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + if (keyId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"keyId\"") + } + val apiPath = ("/apps/{appId}/keys/{keyId}" + .replace("{appId}", appId) + .replace("{keyId}", keyId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppKey = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppKey.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppKey::class.java, + converter, + ) + } + + /** + * Delete an app key by its unique ID. + * + * @param appId Application unique ID. + * @param keyId App key unique ID. + * @return [Any] + */ + suspend fun deleteKey( + appId: String, + keyId: String, + ): Any { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + if (keyId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"keyId\"") + } + val apiPath = ("/apps/{appId}/keys/{keyId}" + .replace("{appId}", appId) + .replace("{keyId}", keyId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + return client.call( + "DELETE", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } + + /** + * List client secrets for an application. + * + * @param appId Application unique ID. + * @param queries Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of 100 queries are allowed, each 4096 characters long. + * @param total When set to false, the total count returned will be 0 and will not be calculated. + * @return [io.appwrite.models.AppSecretList] + */ + @JvmOverloads + suspend fun listSecrets( + appId: String, + queries: List? = null, + total: Boolean? = null, + ): io.appwrite.models.AppSecretList { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}/secrets" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf( + "queries" to queries, + "total" to total, + ) + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppSecretList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppSecretList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppSecretList::class.java, + converter, + ) + } + + /** + * Create a new client secret for an application. + * + * @param appId Application unique ID. + * @return [io.appwrite.models.AppSecretPlaintext] + */ + suspend fun createSecret( + appId: String, + ): io.appwrite.models.AppSecretPlaintext { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}/secrets" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppSecretPlaintext = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppSecretPlaintext.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppSecretPlaintext::class.java, + converter, + ) + } + + /** + * Get an application client secret by its unique ID. + * + * @param appId Application unique ID. + * @param secretId Secret unique ID. + * @return [io.appwrite.models.AppSecret] + */ + suspend fun getSecret( + appId: String, + secretId: String, + ): io.appwrite.models.AppSecret { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + if (secretId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"secretId\"") + } + val apiPath = ("/apps/{appId}/secrets/{secretId}" + .replace("{appId}", appId) + .replace("{secretId}", secretId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.AppSecret = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.AppSecret.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.AppSecret::class.java, + converter, + ) + } + + /** + * Delete an application client secret by its unique ID. + * + * @param appId Application unique ID. + * @param secretId Secret unique ID. + * @return [Any] + */ + suspend fun deleteSecret( + appId: String, + secretId: String, + ): Any { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + if (secretId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"secretId\"") + } + val apiPath = ("/apps/{appId}/secrets/{secretId}" + .replace("{appId}", appId) + .replace("{secretId}", secretId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + return client.call( + "DELETE", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } + + /** + * Transfer an application to another team by its unique ID. + * + * @param appId Application unique ID. + * @param teamId Team ID of the team to transfer application to. + * @return [io.appwrite.models.App] + */ + suspend fun updateTeam( + appId: String, + teamId: String, + ): io.appwrite.models.App { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}/team" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf( + "teamId" to teamId, + ) + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.App = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.App.from(map = it as Map) + } + return client.call( + "PATCH", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.App::class.java, + converter, + ) + } + + /** + * Revoke all tokens for an application by its unique ID. + * + * @param appId Application unique ID. + * @return [Any] + */ + suspend fun deleteTokens( + appId: String, + ): Any { + if (appId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"appId\"") + } + val apiPath = ("/apps/{appId}/tokens" + .replace("{appId}", appId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + return client.call( + "DELETE", + 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 cce2d3da..3b11d165 100644 --- a/library/src/main/java/io/appwrite/services/Avatars.kt +++ b/library/src/main/java/io/appwrite/services/Avatars.kt @@ -12,7 +12,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import java.io.File /** - * The Avatars service aims to help you complete everyday tasks related to your app image, icons, and avatars. */ + * The Avatars service aims to help you complete everyday tasks related to your app image, icons, and avatars. + */ class Avatars(client: Client) : Service(client) { /** * You can use this endpoint to show different browser icons to your users. The code argument receives the browser code as it appears in your user [GET /account/sessions](https://appwrite.io/docs/references/cloud/client-web/account#getSessions) endpoint. Use width, height and quality arguments to change the output settings. @@ -240,7 +241,7 @@ class Avatars(client: Client) : Service(client) { } /** - * Returns the best available profile photo for a user. The endpoint tries each source in priority order and returns the first successful result: OAuth2 identity photo, Gravatar, Libravatar, Appwrite Initials, built-in static fallback. + * Returns the best available profile photo for a user. The endpoint tries each source in priority order and returns the first successful result: a custom uploaded photo (see avatars.updatePhoto), OAuth2 identity photo, Gravatar, Libravatar, Appwrite Initials, built-in static fallback. * * Passing `userId` — `current()` for the authenticated user — resolves the photo from everything known about that user: identity photos, email, and name. An explicit `emailHash` or `name` then overrides just that value, and the user's remaining sources stay in the chain. Without `userId`, passing `emailHash` and/or `name` resolves the avatar from those values alone: the hash is looked up on Gravatar and Libravatar, the name is rendered as initials, and the session user stays out of the chain so their own photo never shadows the avatar being asked for. When nothing is passed, the photo resolves for the currently authenticated user. Emails are only ever accepted pre-hashed, so no address ends up in a URL. * @@ -289,6 +290,82 @@ class Avatars(client: Client) : Service(client) { ) } + /** + * Update the profile photo of the currently authenticated user. The uploaded image takes priority over every other photo source, including OAuth2 identity photos, Gravatar, and Libravatar. Updating an already customized photo replaces it. The image must be at most 5MB and is sent in a single request. + * + * @param file Binary image file of at most 5MB. Allowed file types are png, jpg, jpeg, and webp. + * @return [io.appwrite.models.Account] + */ + suspend fun updatePhoto( + file: InputFile, + nestedType: Class, + onProgress: ((UploadProgress) -> Unit)? = null + ): io.appwrite.models.Account { + val apiPath = "/avatars/photo" + val apiParams = mutableMapOf( + "file" to file, + ) + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "multipart/form-data", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Account = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Account.from(map = it as Map, nestedType) + } + val idParamName: String? = null + val paramName = "file" + return client.chunkedUpload( + apiPath, + apiHeaders, + apiParams, + responseType = classOf(), + converter, + paramName = paramName, + idParamName = idParamName, + onProgress = onProgress, + ) + } + + /** + * Update the profile photo of the currently authenticated user. The uploaded image takes priority over every other photo source, including OAuth2 identity photos, Gravatar, and Libravatar. Updating an already customized photo replaces it. The image must be at most 5MB and is sent in a single request. + * + * @param file Binary image file of at most 5MB. Allowed file types are png, jpg, jpeg, and webp. + * @return [io.appwrite.models.Account] + */ + @Throws(AppwriteException::class) + suspend fun updatePhoto( + file: InputFile, + onProgress: ((UploadProgress) -> Unit)? = null + ): io.appwrite.models.Account> = updatePhoto( + file, + nestedType = classOf(), + onProgress = onProgress + ) + + /** + * Delete the profile photo of the currently authenticated user and store the built-in static placeholder in its place. The placeholder is the user's photo from then on, so it takes priority over every other photo source — OAuth2 identity photos, Gravatar, Libravatar, and initials — until a new photo is uploaded with avatars.updatePhoto. + * + * @return [Any] + */ + suspend fun deletePhoto(): Any { + val apiPath = "/avatars/photo" + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "X-Appwrite-Project" to client.config["project"].orEmpty(), + "content-type" to "application/json", + "accept" to "application/json", + ) + return client.call( + "DELETE", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } + /** * Converts a given plain text to a QR code image. You can use the query parameters to change the size and style of the resulting image. * @@ -334,7 +411,7 @@ class Avatars(client: Client) : Service(client) { * When width and height are specified, the image is resized accordingly. If both dimensions are 0, the API provides an image at original size. If dimensions are not specified, the default viewport size is 1280x720px. * * @param url Website URL which you want to capture. - * @param headers HTTP headers to send with the browser request. Defaults to empty. + * @param headers HTTP headers to send with the browser request. Only Accept and Accept-Language are allowed. Defaults to empty. * @param viewportWidth Browser viewport width. Pass an integer between 1 to 1920. Defaults to 1280. * @param viewportHeight Browser viewport height. Pass an integer between 1 to 1080. Defaults to 720. * @param scale Browser scale factor. Pass a number between 0.1 to 3. Defaults to 1. diff --git a/library/src/main/java/io/appwrite/services/Databases.kt b/library/src/main/java/io/appwrite/services/Databases.kt index fcc85678..8b3b8215 100644 --- a/library/src/main/java/io/appwrite/services/Databases.kt +++ b/library/src/main/java/io/appwrite/services/Databases.kt @@ -10,12 +10,14 @@ import okhttp3.Cookie import java.io.File /** - * The Databases service allows you to create structured collections of documents, query and filter lists of documents */ + * The Databases service allows you to create structured collections of documents, query and filter lists of documents + */ class Databases(client: Client) : Service(client) { /** * List transactions across all databases. * * @param queries Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.TransactionList] */ @Deprecated( @@ -25,10 +27,12 @@ class Databases(client: Client) : Service(client) { @JvmOverloads suspend fun listTransactions( queries: List? = null, + total: Boolean? = null, ): io.appwrite.models.TransactionList { val apiPath = "/databases/transactions" val apiParams = mutableMapOf( "queries" to queries, + "total" to total, ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), diff --git a/library/src/main/java/io/appwrite/services/DocumentsDB.kt b/library/src/main/java/io/appwrite/services/DocumentsDB.kt index fb7cd720..9c403245 100644 --- a/library/src/main/java/io/appwrite/services/DocumentsDB.kt +++ b/library/src/main/java/io/appwrite/services/DocumentsDB.kt @@ -10,21 +10,25 @@ import okhttp3.Cookie import java.io.File /** - * */ + * + */ class DocumentsDB(client: Client) : Service(client) { /** * List transactions across all databases. * * @param queries Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.TransactionList] */ @JvmOverloads suspend fun listTransactions( queries: List? = null, + total: Boolean? = null, ): io.appwrite.models.TransactionList { val apiPath = "/documentsdb/transactions" val apiParams = mutableMapOf( "queries" to queries, + "total" to total, ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), diff --git a/library/src/main/java/io/appwrite/services/Functions.kt b/library/src/main/java/io/appwrite/services/Functions.kt index 57821693..0417207c 100644 --- a/library/src/main/java/io/appwrite/services/Functions.kt +++ b/library/src/main/java/io/appwrite/services/Functions.kt @@ -10,7 +10,8 @@ import okhttp3.Cookie import java.io.File /** - * The Functions Service allows you view, create and manage your Cloud Functions. */ + * The Functions Service allows you view, create and manage your Cloud Functions. + */ class Functions(client: Client) : Service(client) { /** * Get a list of all the current user function execution logs. You can use the query params to filter your results. diff --git a/library/src/main/java/io/appwrite/services/Graphql.kt b/library/src/main/java/io/appwrite/services/Graphql.kt index c1a776d9..dba5986f 100644 --- a/library/src/main/java/io/appwrite/services/Graphql.kt +++ b/library/src/main/java/io/appwrite/services/Graphql.kt @@ -10,7 +10,8 @@ import okhttp3.Cookie import java.io.File /** - * The GraphQL API allows you to query and mutate your Appwrite server using GraphQL. */ + * The GraphQL API allows you to query and mutate your Appwrite server using GraphQL. + */ class Graphql(client: Client) : Service(client) { /** * Execute a GraphQL mutation. diff --git a/library/src/main/java/io/appwrite/services/Locale.kt b/library/src/main/java/io/appwrite/services/Locale.kt index 9c08a68d..a6eb364c 100644 --- a/library/src/main/java/io/appwrite/services/Locale.kt +++ b/library/src/main/java/io/appwrite/services/Locale.kt @@ -10,7 +10,8 @@ import okhttp3.Cookie import java.io.File /** - * The Locale service allows you to customize your app based on your users' location. */ + * The Locale service allows you to customize your app based on your users' location. + */ class Locale(client: Client) : Service(client) { /** * Get the current user location based on IP. Returns an object with user country code, country name, continent name, continent code, ip address and suggested currency. You can use the locale header to get the data in a supported language. @@ -43,11 +44,17 @@ class Locale(client: Client) : Service(client) { /** * List of all locale codes in [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). * + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.LocaleCodeList] */ - suspend fun listCodes(): io.appwrite.models.LocaleCodeList { + @JvmOverloads + suspend fun listCodes( + total: Boolean? = null, + ): io.appwrite.models.LocaleCodeList { val apiPath = "/locale/codes" - val apiParams = mutableMapOf() + val apiParams = mutableMapOf( + "total" to total, + ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), "accept" to "application/json", @@ -69,11 +76,17 @@ class Locale(client: Client) : Service(client) { /** * List of all continents. You can use the locale header to get the data in a supported language. * + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.ContinentList] */ - suspend fun listContinents(): io.appwrite.models.ContinentList { + @JvmOverloads + suspend fun listContinents( + total: Boolean? = null, + ): io.appwrite.models.ContinentList { val apiPath = "/locale/continents" - val apiParams = mutableMapOf() + val apiParams = mutableMapOf( + "total" to total, + ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), "accept" to "application/json", @@ -95,11 +108,17 @@ class Locale(client: Client) : Service(client) { /** * List of all countries. You can use the locale header to get the data in a supported language. * + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.CountryList] */ - suspend fun listCountries(): io.appwrite.models.CountryList { + @JvmOverloads + suspend fun listCountries( + total: Boolean? = null, + ): io.appwrite.models.CountryList { val apiPath = "/locale/countries" - val apiParams = mutableMapOf() + val apiParams = mutableMapOf( + "total" to total, + ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), "accept" to "application/json", @@ -121,11 +140,17 @@ class Locale(client: Client) : Service(client) { /** * List of all countries that are currently members of the EU. You can use the locale header to get the data in a supported language. * + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.CountryList] */ - suspend fun listCountriesEU(): io.appwrite.models.CountryList { + @JvmOverloads + suspend fun listCountriesEU( + total: Boolean? = null, + ): io.appwrite.models.CountryList { val apiPath = "/locale/countries/eu" - val apiParams = mutableMapOf() + val apiParams = mutableMapOf( + "total" to total, + ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), "accept" to "application/json", @@ -147,11 +172,17 @@ class Locale(client: Client) : Service(client) { /** * List of all countries phone codes. You can use the locale header to get the data in a supported language. * + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.PhoneList] */ - suspend fun listCountriesPhones(): io.appwrite.models.PhoneList { + @JvmOverloads + suspend fun listCountriesPhones( + total: Boolean? = null, + ): io.appwrite.models.PhoneList { val apiPath = "/locale/countries/phones" - val apiParams = mutableMapOf() + val apiParams = mutableMapOf( + "total" to total, + ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), "accept" to "application/json", @@ -173,11 +204,17 @@ class Locale(client: Client) : Service(client) { /** * List of all currencies, including currency symbol, name, plural, and decimal digits for all major and minor currencies. You can use the locale header to get the data in a supported language. * + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.CurrencyList] */ - suspend fun listCurrencies(): io.appwrite.models.CurrencyList { + @JvmOverloads + suspend fun listCurrencies( + total: Boolean? = null, + ): io.appwrite.models.CurrencyList { val apiPath = "/locale/currencies" - val apiParams = mutableMapOf() + val apiParams = mutableMapOf( + "total" to total, + ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), "accept" to "application/json", @@ -199,11 +236,17 @@ class Locale(client: Client) : Service(client) { /** * List of all languages classified by ISO 639-1 including 2-letter code, name in English, and name in the respective language. * + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.LanguageList] */ - suspend fun listLanguages(): io.appwrite.models.LanguageList { + @JvmOverloads + suspend fun listLanguages( + total: Boolean? = null, + ): io.appwrite.models.LanguageList { val apiPath = "/locale/languages" - val apiParams = mutableMapOf() + val apiParams = mutableMapOf( + "total" to total, + ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), "accept" to "application/json", diff --git a/library/src/main/java/io/appwrite/services/Messaging.kt b/library/src/main/java/io/appwrite/services/Messaging.kt index 0e816f78..e0e36b13 100644 --- a/library/src/main/java/io/appwrite/services/Messaging.kt +++ b/library/src/main/java/io/appwrite/services/Messaging.kt @@ -10,7 +10,8 @@ import okhttp3.Cookie import java.io.File /** - * The Messaging service allows you to send messages to any provider type (SMTP, push notification, SMS, etc.). */ + * The Messaging service allows you to send messages to any provider type (SMTP, push notification, SMS, etc.). + */ class Messaging(client: Client) : Service(client) { /** * Create a new subscriber. diff --git a/library/src/main/java/io/appwrite/services/Oauth2.kt b/library/src/main/java/io/appwrite/services/Oauth2.kt new file mode 100644 index 00000000..030f4cef --- /dev/null +++ b/library/src/main/java/io/appwrite/services/Oauth2.kt @@ -0,0 +1,680 @@ +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 + +/** + * The OAuth2 service allows you to authorize apps and issue standards-based OAuth2 and OpenID Connect tokens. + */ +class Oauth2(client: Client) : Service(client) { + /** + * Approve an OAuth2 grant after the user gives consent. Returns the `redirectUrl` the end user should be sent to. The consent screen may optionally pass enriched `authorization_details` to record the concrete resources the user selected. You can pass Accept header of `application/json` to receive a JSON response instead of a redirect. + * + * @param grantId Grant ID made during authorization, provided to consent screen in URL search params. + * @param authorizationDetails Enriched `authorization_details` the user consented to, replacing what the client requested. Each entry must use a `type` the project accepts. Optional; omit to keep the originally requested details. + * @param scope Space-separated scopes the user consented to. Must be a subset of the scopes originally requested; identity scopes such as `openid` are always retained. Optional; omit to keep the originally requested scopes. + * @return [io.appwrite.models.Oauth2Approve] + */ + @JvmOverloads + suspend fun approve( + grantId: String, + authorizationDetails: String? = null, + scope: String? = null, + ): io.appwrite.models.Oauth2Approve { + val apiPath = ("/oauth2/{project_id}/approve" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "grant_id" to grantId, + "authorization_details" to authorizationDetails, + "scope" to scope, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2Approve = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2Approve.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2Approve::class.java, + converter, + ) + } + + /** + * Begin the OAuth2 authorization flow. When called without a session, the user is redirected to the consent screen without grant ID. When called with a session, the redirect URL includes param for grant ID. You can pass Accept header of `application/json` to receive a JSON response instead of a redirect. + * + * @param clientId OAuth2 client ID. Either a registered app ID or an HTTPS client ID metadata document URL. + * @param redirectUri Redirect URI where visitor will be redirected after authorization, whether successful or not. + * @param responseType OAuth2 / OIDC response type. One of `code` (Authorization Code Flow), `id_token` (Implicit Flow, OIDC login only), or `code id_token` (Hybrid Flow). + * @param scope Space-separated OAuth2 scopes. Can include project scopes, and built-in scopes: `openid`, `email`, `profile`, `phone`. + * @param state OAuth2 state. You receive this back in the redirect URI. + * @param nonce OIDC nonce parameter to prevent replay attacks. Required when response_type includes `id_token`. + * @param codeChallenge PKCE code challenge. Required when OAuth2 app is public. + * @param codeChallengeMethod PKCE code challenge method. Required when OAuth2 app is public. + * @param prompt OIDC prompt parameter for customization of consent screen. Space-separated list of: none, login, consent, select_account. + * @param maxAge OIDC max_age paraleter for customization of consent screen. Maximum allowable elapsed time in seconds since the user last authenticated. If exceeded, re-authentication is required. + * @param authorizationDetails Rich authorization request. JSON array of objects, each with a `type` and project-defined fields + * @param resource RFC 8707 resource indicator URI or URI list. Each value must be an absolute URI without a fragment. + * @param audience Compatibility alias for a single OAuth2 resource indicator URI. + * @param requestUri OAuth2 authorization request handle returned by the pushed authorization request endpoint. + * @return [io.appwrite.models.Oauth2Authorize] + */ + @JvmOverloads + suspend fun authorize( + clientId: String? = null, + redirectUri: String? = null, + responseType: String? = null, + scope: String? = null, + state: String? = null, + nonce: String? = null, + codeChallenge: String? = null, + codeChallengeMethod: String? = null, + prompt: String? = null, + maxAge: Long? = null, + authorizationDetails: String? = null, + resource: String? = null, + audience: String? = null, + requestUri: String? = null, + ): io.appwrite.models.Oauth2Authorize { + val apiPath = ("/oauth2/{project_id}/authorize" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "client_id" to clientId, + "redirect_uri" to redirectUri, + "response_type" to responseType, + "scope" to scope, + "state" to state, + "nonce" to nonce, + "code_challenge" to codeChallenge, + "code_challenge_method" to codeChallengeMethod, + "prompt" to prompt, + "max_age" to maxAge, + "authorization_details" to authorizationDetails, + "resource" to resource, + "audience" to audience, + "request_uri" to requestUri, + ) + val apiHeaders = mutableMapOf( + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2Authorize = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2Authorize.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2Authorize::class.java, + converter, + ) + } + + /** + * Begin the OAuth2 authorization flow. When called without a session, the user is redirected to the consent screen without grant ID. When called with a session, the redirect URL includes param for grant ID. You can pass Accept header of `application/json` to receive a JSON response instead of a redirect. + * + * @param clientId OAuth2 client ID. Either a registered app ID or an HTTPS client ID metadata document URL. + * @param redirectUri Redirect URI where visitor will be redirected after authorization, whether successful or not. + * @param responseType OAuth2 / OIDC response type. One of `code` (Authorization Code Flow), `id_token` (Implicit Flow, OIDC login only), or `code id_token` (Hybrid Flow). + * @param scope Space-separated OAuth2 scopes. Can include project scopes, and built-in scopes: `openid`, `email`, `profile`, `phone`. + * @param state OAuth2 state. You receive this back in the redirect URI. + * @param nonce OIDC nonce parameter to prevent replay attacks. Required when response_type includes `id_token`. + * @param codeChallenge PKCE code challenge. Required when OAuth2 app is public. + * @param codeChallengeMethod PKCE code challenge method. Required when OAuth2 app is public. + * @param prompt OIDC prompt parameter for customization of consent screen. Space-separated list of: none, login, consent, select_account. + * @param maxAge OIDC max_age paraleter for customization of consent screen. Maximum allowable elapsed time in seconds since the user last authenticated. If exceeded, re-authentication is required. + * @param authorizationDetails Rich authorization request. JSON array of objects, each with a `type` and project-defined fields + * @param resource RFC 8707 resource indicator URI or URI list. Each value must be an absolute URI without a fragment. + * @param audience Compatibility alias for a single OAuth2 resource indicator URI. + * @param requestUri OAuth2 authorization request handle returned by the pushed authorization request endpoint. + * @return [io.appwrite.models.Oauth2Authorize] + */ + @JvmOverloads + suspend fun authorizePost( + clientId: String? = null, + redirectUri: String? = null, + responseType: String? = null, + scope: String? = null, + state: String? = null, + nonce: String? = null, + codeChallenge: String? = null, + codeChallengeMethod: String? = null, + prompt: String? = null, + maxAge: Long? = null, + authorizationDetails: String? = null, + resource: String? = null, + audience: String? = null, + requestUri: String? = null, + ): io.appwrite.models.Oauth2Authorize { + val apiPath = ("/oauth2/{project_id}/authorize" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "client_id" to clientId, + "redirect_uri" to redirectUri, + "response_type" to responseType, + "scope" to scope, + "state" to state, + "nonce" to nonce, + "code_challenge" to codeChallenge, + "code_challenge_method" to codeChallengeMethod, + "prompt" to prompt, + "max_age" to maxAge, + "authorization_details" to authorizationDetails, + "resource" to resource, + "audience" to audience, + "request_uri" to requestUri, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2Authorize = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2Authorize.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2Authorize::class.java, + converter, + ) + } + + /** + * Start the OAuth2 Device Authorization Grant. Returns the device code, user code, verification URL, expiration, and polling interval. + * + * @param clientId OAuth2 client ID. Either a registered app ID or an HTTPS client ID metadata document URL. + * @param scope Space-separated OAuth2 scopes. Can include project scopes, and built-in scopes: `openid`, `email`, `profile`. + * @param authorizationDetails Rich authorization request. JSON array of objects, each with a `type` and project-defined fields + * @param resource RFC 8707 resource indicator URI or URI list. Each value must be an absolute URI without a fragment. + * @param audience Compatibility alias for a single OAuth2 resource indicator URI. + * @return [io.appwrite.models.Oauth2DeviceAuthorization] + */ + @JvmOverloads + suspend fun createDeviceAuthorization( + clientId: String? = null, + scope: String? = null, + authorizationDetails: String? = null, + resource: String? = null, + audience: String? = null, + ): io.appwrite.models.Oauth2DeviceAuthorization { + val apiPath = ("/oauth2/{project_id}/device_authorization" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "client_id" to clientId, + "scope" to scope, + "authorization_details" to authorizationDetails, + "resource" to resource, + "audience" to audience, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2DeviceAuthorization = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2DeviceAuthorization.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2DeviceAuthorization::class.java, + converter, + ) + } + + /** + * Exchange a device flow user code for an OAuth2 grant. The authenticated user is bound to the pending grant. Pass the returned grant ID to the get grant endpoint to render the consent screen, then to the approve or reject endpoint to complete the flow. + * + * @param userCode User code displayed on the device. + * @return [io.appwrite.models.Oauth2Grant] + */ + suspend fun createGrant( + userCode: String, + ): io.appwrite.models.Oauth2Grant { + val apiPath = ("/oauth2/{project_id}/grants" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "user_code" to userCode, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2Grant = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2Grant.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2Grant::class.java, + converter, + ) + } + + /** + * Get an OAuth2 grant by its ID. Used by the consent screen to display the details of the authorization the user is being asked to approve. A grant can only be read by the user it belongs to, or by server SDK. + * + * @param grantId Grant ID made during authorization, provided to consent screen in URL search params. + * @return [io.appwrite.models.Oauth2Grant] + */ + suspend fun getGrant( + grantId: String, + ): io.appwrite.models.Oauth2Grant { + if (grantId.isEmpty()) { + throw AppwriteException("Missing required parameter: \"grantId\"") + } + val apiPath = ("/oauth2/{project_id}/grants/{grant_id}" + .replace("{project_id}", client.config["project"].orEmpty()) + .replace("{grant_id}", grantId) + ) + val apiParams = mutableMapOf() + val apiHeaders = mutableMapOf( + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2Grant = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2Grant.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2Grant::class.java, + converter, + ) + } + + /** + * OpenID Connect RP-Initiated Logout. Ends the user session and revokes the tokens issued to the app identified by the `id_token_hint`, then redirects the user to `post_logout_redirect_uri` when it matches a URI registered on the app. + * + * @param idTokenHint ID Token previously issued to the app, used as proof of the logout request. Required to end the session; signature and issuer are validated while expiry is ignored. + * @param logoutHint Hint about the user that is logging out. Accepted for OIDC compatibility. + * @param clientId OAuth2 client ID. When both `client_id` and `id_token_hint` are provided, they must identify the same app. + * @param postLogoutRedirectUri URI to redirect the user to after logout. Must exactly match a URI registered in the app's `postLogoutRedirectUris`. + * @param state Opaque value passed back unchanged in the `state` query param of the post-logout redirect. + * @param uiLocales Preferred languages for any logout UI, as space-separated BCP47 tags. Accepted for OIDC compatibility. + * @return [Any] + */ + @JvmOverloads + suspend fun logout( + idTokenHint: String? = null, + logoutHint: String? = null, + clientId: String? = null, + postLogoutRedirectUri: String? = null, + state: String? = null, + uiLocales: String? = null, + ): Any { + val apiPath = ("/oauth2/{project_id}/logout" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "id_token_hint" to idTokenHint, + "logout_hint" to logoutHint, + "client_id" to clientId, + "post_logout_redirect_uri" to postLogoutRedirectUri, + "state" to state, + "ui_locales" to uiLocales, + ) + val apiHeaders = mutableMapOf( + "accept" to "application/json", + ) + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } + + /** + * OpenID Connect RP-Initiated Logout. Ends the user session and revokes the tokens issued to the app identified by the `id_token_hint`, then redirects the user to `post_logout_redirect_uri` when it matches a URI registered on the app. + * + * @param idTokenHint ID Token previously issued to the app, used as proof of the logout request. Required to end the session; signature and issuer are validated while expiry is ignored. + * @param logoutHint Hint about the user that is logging out. Accepted for OIDC compatibility. + * @param clientId OAuth2 client ID. When both `client_id` and `id_token_hint` are provided, they must identify the same app. + * @param postLogoutRedirectUri URI to redirect the user to after logout. Must exactly match a URI registered in the app's `postLogoutRedirectUris`. + * @param state Opaque value passed back unchanged in the `state` query param of the post-logout redirect. + * @param uiLocales Preferred languages for any logout UI, as space-separated BCP47 tags. Accepted for OIDC compatibility. + * @return [Any] + */ + @JvmOverloads + suspend fun logoutPost( + idTokenHint: String? = null, + logoutHint: String? = null, + clientId: String? = null, + postLogoutRedirectUri: String? = null, + state: String? = null, + uiLocales: String? = null, + ): Any { + val apiPath = ("/oauth2/{project_id}/logout" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "id_token_hint" to idTokenHint, + "logout_hint" to logoutHint, + "client_id" to clientId, + "post_logout_redirect_uri" to postLogoutRedirectUri, + "state" to state, + "ui_locales" to uiLocales, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + "accept" to "application/json", + ) + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } + + /** + * List the organizations the OAuth2 access token can access. Resolves the token's `organization` authorization details, expanding the `*` wildcard into the concrete set of organizations the user can see. + * + * @param limit Maximum number of organizations to return. Between 1 and 5000. + * @param offset Number of organizations to skip before returning results. Used for pagination. + * @param search Search term to filter your list results. Max length: 256 chars. + * @return [io.appwrite.models.Oauth2OrganizationList] + */ + @JvmOverloads + suspend fun listOrganizations( + limit: Long? = null, + offset: Long? = null, + search: String? = null, + ): io.appwrite.models.Oauth2OrganizationList { + val apiPath = ("/oauth2/{project_id}/organizations" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "limit" to limit, + "offset" to offset, + "search" to search, + ) + val apiHeaders = mutableMapOf( + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2OrganizationList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2OrganizationList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2OrganizationList::class.java, + converter, + ) + } + + /** + * Store an OAuth2 authorization request server-side and receive a short-lived request_uri handle for the authorize endpoint. + * + * @param clientId OAuth2 client ID. Either a registered app ID or an HTTPS client ID metadata document URL. + * @param redirectUri Redirect URI where visitor will be redirected after authorization, whether successful or not. + * @param responseType OAuth2 / OIDC response type. + * @param scope Space-separated OAuth2 scopes. Can include project scopes, and built-in scopes: `openid`, `email`, `profile`, `phone`. + * @param state OAuth2 state. You receive this back in the redirect URI. + * @param nonce OIDC nonce parameter to prevent replay attacks. Required when response_type includes `id_token`. + * @param codeChallenge PKCE code challenge. Required when OAuth2 app is public. + * @param codeChallengeMethod PKCE code challenge method. Required when OAuth2 app is public. + * @param prompt OIDC prompt parameter for customization of consent screen. Space-separated list of: none, login, consent, select_account. + * @param maxAge OIDC max_age parameter for customization of consent screen. + * @param authorizationDetails Rich authorization request. JSON array of objects, each with a `type` and project-defined fields + * @param resource RFC 8707 resource indicator URI or URI list. Each value must be an absolute URI without a fragment. + * @param audience Compatibility alias for a single OAuth2 resource indicator URI. + * @return [io.appwrite.models.Oauth2PAR] + */ + @JvmOverloads + suspend fun createPAR( + clientId: String, + redirectUri: String, + responseType: String, + scope: String? = null, + state: String? = null, + nonce: String? = null, + codeChallenge: String? = null, + codeChallengeMethod: String? = null, + prompt: String? = null, + maxAge: Long? = null, + authorizationDetails: String? = null, + resource: String? = null, + audience: String? = null, + ): io.appwrite.models.Oauth2PAR { + val apiPath = ("/oauth2/{project_id}/par" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "client_id" to clientId, + "redirect_uri" to redirectUri, + "response_type" to responseType, + "scope" to scope, + "state" to state, + "nonce" to nonce, + "code_challenge" to codeChallenge, + "code_challenge_method" to codeChallengeMethod, + "prompt" to prompt, + "max_age" to maxAge, + "authorization_details" to authorizationDetails, + "resource" to resource, + "audience" to audience, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2PAR = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2PAR.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2PAR::class.java, + converter, + ) + } + + /** + * List the projects the OAuth2 access token can access. Resolves the token's `project` authorization details, expanding the `*` wildcard into the concrete set of projects the user can see. + * + * @param limit Maximum number of projects to return. Between 1 and 5000. + * @param offset Number of projects to skip before returning results. Used for pagination. + * @param search Search term to filter your list results. Max length: 256 chars. + * @return [io.appwrite.models.Oauth2ProjectList] + */ + @JvmOverloads + suspend fun listProjects( + limit: Long? = null, + offset: Long? = null, + search: String? = null, + ): io.appwrite.models.Oauth2ProjectList { + val apiPath = ("/oauth2/{project_id}/projects" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "limit" to limit, + "offset" to offset, + "search" to search, + ) + val apiHeaders = mutableMapOf( + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2ProjectList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2ProjectList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2ProjectList::class.java, + converter, + ) + } + + /** + * Reject an OAuth2 grant when the user denies consent. Returns the `redirectUrl` the end user should be sent to with an `access_denied` error. You can pass Accept header of `application/json` to receive a JSON response instead of a redirect. + * + * @param grantId Grant ID made during authorization, provided to consent screen in URL search params. + * @return [io.appwrite.models.Oauth2Reject] + */ + suspend fun reject( + grantId: String, + ): io.appwrite.models.Oauth2Reject { + val apiPath = ("/oauth2/{project_id}/reject" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "grant_id" to grantId, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2Reject = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2Reject.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2Reject::class.java, + converter, + ) + } + + /** + * Revoke an OAuth2 access token or refresh token. + * + * @param token The access or refresh token to revoke. + * @param tokenTypeHint Type of token to revoke (access_token or refresh_token). + * @param clientId OAuth2 client ID. Either a registered app ID or an HTTPS client ID metadata document URL. + * @param clientSecret OAuth2 client secret. Required for confidential apps; omitted for public apps. + * @return [Any] + */ + @JvmOverloads + suspend fun revoke( + token: String, + tokenTypeHint: String? = null, + clientId: String? = null, + clientSecret: String? = null, + ): Any { + val apiPath = ("/oauth2/{project_id}/revoke" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "token" to token, + "token_type_hint" to tokenTypeHint, + "client_id" to clientId, + "client_secret" to clientSecret, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + "accept" to "application/json", + ) + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } + + /** + * Exchange an OAuth2 authorization code, refresh token, or device code for access and refresh tokens. + * + * @param grantType OAuth2 grant type. Can be one of: `authorization_code`, `refresh_token`, `urn:ietf:params:oauth:grant-type:device_code`. + * @param code Authorization code to be exchanged for access and refresh tokens. Required for `authorization_code` grant type. + * @param refreshToken Refresh token to be exchanged for a new access and refresh tokens. Required for `refresh_token` grant type. + * @param deviceCode Device code obtained from the device authorization endpoint. Required for `urn:ietf:params:oauth:grant-type:device_code` grant type. + * @param clientId OAuth2 client ID. Either a registered app ID or an HTTPS client ID metadata document URL. + * @param clientSecret OAuth2 client secret. Required for confidential apps. + * @param codeVerifier PKCE code verifier. Required for public apps. + * @param redirectUri Redirect URI. Required for `authorization_code` grant type. + * @param resource RFC 8707 resource indicator URI or URI list. Each value must be an absolute URI without a fragment. + * @param audience Compatibility alias for a single OAuth2 resource indicator URI. + * @return [io.appwrite.models.Oauth2Token] + */ + @JvmOverloads + suspend fun createToken( + grantType: String, + code: String? = null, + refreshToken: String? = null, + deviceCode: String? = null, + clientId: String? = null, + clientSecret: String? = null, + codeVerifier: String? = null, + redirectUri: String? = null, + resource: String? = null, + audience: String? = null, + ): io.appwrite.models.Oauth2Token { + val apiPath = ("/oauth2/{project_id}/token" + .replace("{project_id}", client.config["project"].orEmpty()) + ) + val apiParams = mutableMapOf( + "grant_type" to grantType, + "code" to code, + "refresh_token" to refreshToken, + "device_code" to deviceCode, + "client_id" to clientId, + "client_secret" to clientSecret, + "code_verifier" to codeVerifier, + "redirect_uri" to redirectUri, + "resource" to resource, + "audience" to audience, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + "accept" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Oauth2Token = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Oauth2Token.from(map = it as Map) + } + return client.call( + "POST", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Oauth2Token::class.java, + converter, + ) + } +} diff --git a/library/src/main/java/io/appwrite/services/Organization.kt b/library/src/main/java/io/appwrite/services/Organization.kt index 264a356e..00ed08a9 100644 --- a/library/src/main/java/io/appwrite/services/Organization.kt +++ b/library/src/main/java/io/appwrite/services/Organization.kt @@ -10,7 +10,8 @@ import okhttp3.Cookie import java.io.File /** - * The Organization service allows you to manage organization-level projects. */ + * The Organization service allows you to manage organization-level projects. + */ class Organization(client: Client) : Service(client) { /** * List app installations on the organization. Any organization member can read installations. diff --git a/library/src/main/java/io/appwrite/services/Presences.kt b/library/src/main/java/io/appwrite/services/Presences.kt index a28448e4..31181a0c 100644 --- a/library/src/main/java/io/appwrite/services/Presences.kt +++ b/library/src/main/java/io/appwrite/services/Presences.kt @@ -10,7 +10,8 @@ import okhttp3.Cookie import java.io.File /** - * The Presences service allows you to track and manage real-time user presence in your project. */ + * The Presences service allows you to track and manage real-time user presence in your project. + */ class Presences(client: Client) : Service(client) { /** * List presence logs. Expired entries are filtered out automatically. diff --git a/library/src/main/java/io/appwrite/services/Push.kt b/library/src/main/java/io/appwrite/services/Push.kt new file mode 100644 index 00000000..6fab40ad --- /dev/null +++ b/library/src/main/java/io/appwrite/services/Push.kt @@ -0,0 +1,765 @@ +package io.appwrite.services + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.hivemq.client.mqtt.datatypes.MqttQos +import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient +import io.appwrite.Client +import io.appwrite.ResolvedTopic +import io.appwrite.Service +import io.appwrite.Topic +import io.appwrite.exceptions.AppwriteException +import java.net.URI +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap + +private const val LOG_TAG = "AppwritePush" + +/** + * Handle for a live subscription created with [Push.subscribe]. + * + * Unlike the realtime service's bare unsubscribe callable, a push subscription carries + * per-subscription state (background delivery, notification title), so [Push.subscribe] + * returns this handle: [unsubscribe] drops it (and closes the connection once the last one + * is gone) and [update] toggles its options live without resubscribing. + */ +class PushSubscription internal constructor( + private val unsubscribeAction: () -> Unit, + private val updateAction: (Boolean?, String?, Boolean?) -> Unit, +) { + /** Drop this subscription. Closes the connection once the last one is gone. */ + fun unsubscribe() = unsubscribeAction() + + /** + * Live-update this subscription's background delivery, notification title and/or + * `retry` (QoS). Only the arguments you pass change; the rest stay as they + * were. Changing `retry` re-subscribes this subscription's topics at the new QoS. + */ + @JvmOverloads + fun update(background: Boolean? = null, title: String? = null, retry: Boolean? = null) = + updateAction(background, title, retry) +} + +/** + * Appwrite native push service — the realtime analog delivered over an MQTT broker, + * without FCM or APNS. + * + * While the app process is alive the connection stays open and each message is handed to + * your callback. A subscription can also opt into background delivery (`background = true`): + * it is saved, and keeps delivering while the app is backgrounded, after the process is + * killed, and after a reboot or an app update, until it is unsubscribed or [close] is called + * (e.g. on sign-out). No service is kept running for it: a scheduled job and alarm wake the + * process every 15 to 60 seconds to reconnect, and the broker keeps the messages sent in + * between and replays them (for `retry = true`). While the process is alive, messages arrive + * immediately. [setForeground] adds a foreground service for immediate delivery even after a kill + * and during Doze. Messages no in-app callback receives (the process was started in the + * background) go to a declared [PushReceiver], and are posted as a notification that opens + * the app unless it handles them. While any background subscription exists, all subscriptions + * share its connection. + * + * Background delivery reconnects with the credential saved at subscribe time, so use a session + * (`Client.setSession`) for it: a JWT expires after minutes. A refused credential stops + * background delivery and reaches [onError], also when it was registered later. + * + * The credential is read off the client — set a JWT or session on it + * (`Client.setJWT` / `Client.setSession`), the same way every other service reads auth. + * + * The connection always keeps its session (clean start off) so the broker can redeliver + * missed messages, and reliability is chosen per subscription via [subscribe]'s + * `retry`. The broker owns the replay window server-side (no MQTT session-expiry + * is set). The client id defaults to one per user and install, so replay resumes across + * restarts; `Client.setPushClientId(...)` overrides it. TLS (and skipping cert verification via a `?tlsInsecure=true` query flag) is + * derived from the endpoint, so the whole connection is described in one place. + * + * ```kotlin + * val client = Client().setEndpoint("...").setProject("...").setJWT(jwt) + * val push = Push(client, context) + * val sub = push.subscribe("user/123/#") { message -> + * println("${'$'}{message.topic}: ${'$'}{message.data}") + * } + * // ...later + * sub.unsubscribe() + * + * // Or, with no topic, the signed-in user's own topic (`users/`): + * push.subscribe { message -> println(message.data) } + * ``` + */ +class Push( + client: Client, + context: Context, +) : Service(client) { + + // The broker location is taken from Client.setPushEndpoint() when set (e.g. + // "mqtts://host:8883"), otherwise from the regular endpoint. + private val pushEndpoint: String = client.endpointPush ?: client.endpoint + + // When no explicit push endpoint is set, default to a `push.` host on the regular endpoint + // (mirroring how realtime derives from the endpoint, on the push subdomain). + private val host: String = + (runCatching { URI(pushEndpoint).host }.getOrNull() ?: "localhost") + .let { if (client.endpointPush != null) it else "push.$it" } + + // Secure by default: TLS on when the endpoint scheme is secure (https/wss/mqtts/ssl), so + // the credential in the CONNECT packet is not sent in the clear. + private val tls: Boolean = + runCatching { URI(pushEndpoint).scheme }.getOrNull()?.lowercase() in listOf("https", "wss", "mqtts", "ssl") + + // Take the port from the push endpoint only (a regular http/https endpoint's port is the + // API port, not the broker's); otherwise fall back to the MQTT default for the TLS mode. + private val port: Int = + client.endpointPush?.let { runCatching { URI(it).port.takeIf { p -> p != -1 } }.getOrNull() } + ?: (if (tls) 8883 else 1883) + + // Skip TLS cert verification (self-signed brokers / testing) via a query flag on the push + // endpoint, e.g. "mqtts://host:8883?tlsInsecure=true" — so the whole connection is + // described by one URL, not a second config knob. + private val tlsInsecure: Boolean = + client.endpointPush?.let { + runCatching { + val query = URI(it).query ?: "" + query.split("&").any { p -> p == "tlsInsecure=true" || p == "tlsInsecure=1" } + }.getOrDefault(false) + } ?: false + + // Read on the MQTT client's threads to tell a closed/replaced client's failures apart. + @Volatile + private var mqtt: Mqtt5AsyncClient? = null + + private var connectedConfig: PushConfig? = null + private val connectLock = Any() + + @Volatile + private var connectionEpoch = 0 + + // Local id -> subscription (filter + callback + per-sub options). The id is never sent to + // the broker; it only lets the same topic carry more than one callback. HiveMQ callbacks + // look their subscription up by id on delivery, because a callback stays attached to its + // filter until the filter itself is unsubscribed, which waits for the filter's last sub. + private val subscriptions = ConcurrentHashMap() + + // Whether the connection currently lives in the process-wide background host (its own MQTT + // client) rather than here. It moves there while any subscription, live or saved by an + // earlier run, wants background, and back here once none do — one connection either way. + private var backgroundActive = false + + // For background delivery: its saved state, scheduled runs, service and notifications. + private val appContext: Context = context.applicationContext + + init { + // Resume saved background delivery now, instead of at the next scheduled run. + PushBackground.resume(appContext) + } + + private var onOpen: (() -> Unit)? = null + private var onClose: (() -> Unit)? = null + private var onError: ((Throwable) -> Unit)? = null + + private class Sub( + val filter: String, + val callback: (PushMessage) -> Unit, + var background: Boolean, + var title: String?, + var retry: Boolean, + ) { + @Volatile + var client: Mqtt5AsyncClient? = null + } + + // The broker subscription for a filter uses the highest QoS any subscription on it wants, + // so a QoS-0 subscription never downgrades a QoS-1 one sharing the filter. + private fun effectiveQos(filter: String): MqttQos = + if (subscriptions.values.any { it.filter == filter && it.retry }) { + MqttQos.AT_LEAST_ONCE + } else { + MqttQos.AT_MOST_ONCE + } + + /** Register a callback invoked when the connection opens. */ + fun onOpen(callback: () -> Unit): Push { + onOpen = callback + return this + } + + /** Register a callback invoked when the connection closes. */ + fun onClose(callback: () -> Unit): Push { + onClose = callback + return this + } + + /** + * Register a callback invoked on every error the push service hits: a failed connect or + * automatic reconnect, a lost connection, a broker DISCONNECT, a rejected SUBSCRIBE or + * UNSUBSCRIBE, a foreground-service failure, and the failure of a [subscribe], + * [PushSubscription.update] or [PushSubscription.unsubscribe] call (which still throws to + * its caller). When the broker explains a refused connection or a DISCONNECT with an MQTT 5 + * Reason String, that is the error's message. + * + * Errors outside a call (on the MQTT client's or the foreground service's threads) are never + * thrown, so they cannot crash the app: with no callback registered they are logged. A + * failing call still throws to its caller. + */ + fun onError(callback: (Throwable) -> Unit): Push { + onError = callback + PushBackground.errorHandler = { deliverBackgroundError(it) } + // A refused credential that stopped background delivery while no onError was registered. + PushStore.takeStoppedError(appContext)?.let { report(AppwriteException(it)) } + return this + } + + /** + * Run background delivery in a foreground service (with a quiet ongoing + * notification), for immediate delivery even after the process is killed and during Doze, + * instead of only the scheduled runs. Saved, so it applies after restarts too; call it from + * the foreground (Android 12+ refuses to start the service from the background). Apps that + * never enable it can remove the service from their merged manifest. It only affects + * `background = true` subscriptions; `background = false` ones still deliver only while the + * app process is alive. + */ + fun setForeground(enabled: Boolean): Push { + PushBackground.setForeground(appContext, enabled) + return this + } + + /** + * Subscribe to the signed-in user's own topic, `users/` — "messages for me" + * without knowing any topic, e.g. `push.subscribe { message -> ... }`. + * + * The user id is read locally from the credential set on the client: the `userId` claim of + * the JWT (`Client.setJWT`), else the `id` in the session secret (`Client.setSession`). + * Throws when neither yields one. Like FCM/APNs delivery, it defaults to [background] `true` + * (keeps delivering while the app is backgrounded or closed) and [retry] + * `true` (QoS 1); pass either to override. Otherwise the options and the returned + * [PushSubscription] behave exactly as when subscribing to that topic; see the + * `List` overload. + */ + @JvmOverloads + fun subscribe( + background: Boolean = true, + title: String? = null, + retry: Boolean = true, + callback: (PushMessage) -> Unit, + ): PushSubscription { + val userId = reporting { resolvePushUserId(client) } + return subscribe(Topic.path(listOf("users", userId)), background, title, retry, callback) + } + + /** + * Subscribe to a single topic. Returns a [PushSubscription] handle. + * + * Request the `POST_NOTIFICATIONS` runtime permission with [requestNotificationPermission] + * before subscribing with `background = true` on Android 13+. + */ + @JvmOverloads + fun subscribe( + topic: String, + background: Boolean = false, + title: String? = null, + retry: Boolean = true, + callback: (PushMessage) -> Unit, + ): PushSubscription = + subscribe(listOf(topic), background, title, retry, callback) + + /** + * Subscribe to a single topic built with [io.appwrite.Topic], e.g. + * `push.subscribe(Topic.path(listOf("user", userId)).any()) { message -> ... }`. + * Returns a [PushSubscription] handle; see the `List` overload for the options. + */ + @JvmOverloads + fun subscribe( + topic: Topic, + background: Boolean = false, + title: String? = null, + retry: Boolean = true, + callback: (PushMessage) -> Unit, + ): PushSubscription = + subscribe(listOf(topic.toString()), background, title, retry, callback) + + /** + * Subscribe to a single topic ended with `all()`, e.g. + * `push.subscribe(Topic.path(listOf("user", userId)).all()) { message -> ... }`. + * Returns a [PushSubscription] handle; see the `List` overload for the options. + */ + @JvmOverloads + fun subscribe( + topic: ResolvedTopic, + background: Boolean = false, + title: String? = null, + retry: Boolean = true, + callback: (PushMessage) -> Unit, + ): PushSubscription = + subscribe(listOf(topic.toString()), background, title, retry, callback) + + /** + * Subscribe to a list mixing raw strings and topics built with [io.appwrite.Topic], e.g. + * `push.subscribe(listOf("news", Topic.path(listOf("chat")).any())) { message -> ... }`. + * Each element must be a `String`, `Topic` or `ResolvedTopic` (Kotlin has no common type for + * them, so this is checked at runtime). Returns a [PushSubscription] handle; see the + * `List` overload for the options. + */ + @JvmOverloads + @JvmName("subscribeTopics") + fun subscribe( + topics: List, + background: Boolean = false, + title: String? = null, + retry: Boolean = true, + callback: (PushMessage) -> Unit, + ): PushSubscription { + val filters = topics.mapIndexed { index, topic -> + require(topic is String || topic is Topic || topic is ResolvedTopic) { + "topics[$index] must be a String, Topic or ResolvedTopic, got ${topic::class.simpleName}" + } + topic.toString() + } + return subscribe(filters, background, title, retry, callback) + } + + /** + * Subscribe to one or more topics. Returns a [PushSubscription] handle. + * + * Topics are MQTT filters: pass raw strings, or build them with [io.appwrite.Topic] + * through the `Topic` overloads, e.g. `push.subscribe(Topic.path(listOf("user")).any()) { ... }`. + * + * For a foreground subscription this blocks until the broker has acknowledged every + * SUBSCRIBE (SUBACK): the broker only starts routing a topic once its subscription is + * registered, so publishing before the ack races the message ahead of the subscription + * and it is dropped (no retained replay on MQTT). + * + * Set [background] to keep receiving while the app is backgrounded or closed, also after the + * process is killed or the device restarts, and post a notification per matching message + * with [title] (defaulting to the message topic); see the class docs. [retry] chooses this + * subscription's QoS + * (`true` -> QoS 1 default, `false` -> QoS 0). Toggle any of these later with + * [PushSubscription.update]. + * + * Note: a `background` subscription hands the connection to the background host, which + * connects and subscribes asynchronously and retries failures on its next run — so it + * returns once the host has been handed the subscription, **not** after SUBACK (the SUBACK + * guarantee above holds for foreground subscriptions). [onOpen]/[onClose] observe only the + * in-process (foreground) connection, not the background one; [onError] observes both. + */ + @JvmOverloads + fun subscribe( + topics: List, + background: Boolean = false, + title: String? = null, + retry: Boolean = true, + callback: (PushMessage) -> Unit, + ): PushSubscription = reporting { + val ids = mutableListOf() + for (topic in topics) { + val id = UUID.randomUUID().toString() + ids.add(id) + subscriptions[id] = Sub(topic, callback, background, title, retry) + } + + // If any SUBSCRIBE fails, roll this call's registrations back and return to a + // consistent host for whatever subscriptions remain, then rethrow. + try { + if (hostedInBackground()) { + hostInBackground() + } else { + subscribeInProcess(ids) + } + } catch (e: Exception) { + for (id in ids) { + subscriptions.remove(id) + } + if (subscriptions.isEmpty()) { + releaseAll() + } else { + reconcile() + } + throw e + } + + PushSubscription( + { reporting { unsubscribeIds(ids) } }, + { bg, t, rd -> reporting { updateIds(ids, bg, t, rd) } }, + ) + } + + // Every error the service hits goes through here: to onError when one is registered. It is + // never thrown from here (these errors arrive on HiveMQ's or the service's threads, where a + // throw would crash the app), so with no onError it is logged, like Realtime does. + private fun report(error: Throwable) { + val callback = onError + if (callback == null) { + Log.e(LOG_TAG, "Push error", error) + return + } + try { + callback(error) + } catch (callbackError: Exception) { + Log.e(LOG_TAG, "Push onError threw", callbackError) + } + } + + // Run a call that fails by throwing to its caller: the failure also reaches onError when one + // is registered, and is still thrown to the caller, who can catch it. + private fun reporting(block: () -> T): T = try { + block() + } catch (e: Exception) { + if (onError != null) { + report(e) + } + throw e + } + + // report() for an error raised on the MQTT client's (or the background host's) thread. + private fun reportAsync(error: Throwable) = report(error) + + // A background host error, for its errorHandler: delivered to onError when one is registered. + // Returns false when none is, so the host logs it (and keeps a refusal for a later onError). + private fun deliverBackgroundError(error: Throwable): Boolean { + if (onError == null) { + return false + } + report(error) + return true + } + + // Report a fire-and-forget request's failure (e.g. a SUBACK/UNSUBACK error code) unless + // [client] was closed or replaced meanwhile, which fails its pending requests on purpose. + private fun CompletableFuture<*>.reportFailure(client: Mqtt5AsyncClient) { + whenComplete { _, error -> + if (error != null && mqtt === client) { + reportAsync(error) + } + } + } + + // In-process host: connect and register this call's new filters, blocking on each SUBACK. + private fun subscribeInProcess(ids: List) { + while (true) { + val (client, epoch) = synchronized(connectLock) { connect() to connectionEpoch } + try { + for (id in ids) { + val sub = subscriptions[id] ?: continue + if (sub.client === client) { + continue + } + client.subscribeWith() + .topicFilter(sub.filter) + .qos(effectiveQos(sub.filter)) + .callback { publish -> subscriptions[id]?.callback?.invoke(publish.toPushMessage()) } + .send() + .get() + sub.client = client + } + return + } catch (e: Exception) { + if (epoch == connectionEpoch) { + throw e + } + } + } + } + + // Relocate the connection into the background host and sync it with the current + // subscription set (filters, per-sub callbacks/titles), saving the background ones. + private fun hostInBackground() { + if (mqtt != null) { + mqtt?.disconnect() + mqtt = null + } + subscriptions.values.forEach { it.client = null } + PushBackground.errorHandler = { deliverBackgroundError(it) } + PushBackground.host(appContext, buildConfig(), currentListeners()) + backgroundActive = true + } + + // Bring the in-process connection up and (re)register every filter — used when the last + // background subscription goes away and the connection relocates back in-process. + private fun ensureInProcessSubscribed() { + val client = connect() + for ((id, sub) in subscriptions) { + if (sub.client === client) { + continue + } + client.subscribeWith() + .topicFilter(sub.filter) + .qos(effectiveQos(sub.filter)) + .callback { publish -> subscriptions[id]?.callback?.invoke(publish.toPushMessage()) } + .send() + .reportFailure(client) + sub.client = client + } + } + + // Re-subscribe a filter in-process at its new effective QoS. Unsubscribe first so the + // per-subscribe HiveMQ callbacks are not duplicated, then re-add one per sub on the filter. + private fun resubscribeFilterInProcess(filter: String) { + val client = mqtt ?: return + client.unsubscribeWith().topicFilter(filter).send().reportFailure(client) + val qos = effectiveQos(filter) + for ((id, sub) in subscriptions.filter { it.value.filter == filter }) { + client.subscribeWith() + .topicFilter(filter) + .qos(qos) + .callback { publish -> subscriptions[id]?.callback?.invoke(publish.toPushMessage()) } + .send() + .reportFailure(client) + sub.client = client + } + } + + // Ensure the correct host (in-process vs service) for whatever subscriptions remain, and + // sync it. Called after an unsubscribe/update may have flipped whether background is wanted. + // Only acts on a host change; an in-process update that changes QoS is applied by the + // caller (so an unchanged in-process host is not needlessly resubscribed). + private fun reconcile() { + if (subscriptions.isEmpty()) { + return + } + if (hostedInBackground()) { + // Relocate to (or re-sync) the background host, which applies added/removed/QoS-changed topics. + hostInBackground() + } else if (backgroundActive) { + // Relocate back in-process. + backgroundActive = false + PushBackground.release(appContext) + ensureInProcessSubscribed() + } + } + + private fun unsubscribeIds(ids: List) { + val wasService = backgroundActive + val maybeDowngrade = mutableSetOf() + for (id in ids) { + val entry = subscriptions.remove(id) + val filter = entry?.filter + if (entry?.retry == true && filter != null) { + maybeDowngrade.add(filter) + } + // In-process only: drop the broker filter once its last local callback is gone. + if (!wasService && filter != null && subscriptions.values.none { it.filter == filter }) { + mqtt?.let { client -> client.unsubscribeWith().topicFilter(filter).send().reportFailure(client) } + } + } + if (subscriptions.isEmpty()) { + releaseAll() + return + } + // A removed sub may have been the last background one (relocate in-process) or just + // change the service's topic/QoS set (the service re-sync applies the new QoS). + if (wasService) { + reconcile() + return + } + // In-process, a filter that lost its last QoS-1 sub but still has QoS-0 subs must be + // re-subscribed lower, or the broker keeps replaying to at-most-once subs. + for (filter in maybeDowngrade) { + if (subscriptions.values.any { it.filter == filter } && effectiveQos(filter) == MqttQos.AT_MOST_ONCE) { + resubscribeFilterInProcess(filter) + } + } + } + + private fun updateIds(ids: List, background: Boolean?, title: String?, retry: Boolean?) { + val changedFilters = mutableSetOf() + for (id in ids) { + val sub = subscriptions[id] ?: continue + if (background != null) { + sub.background = background + } + if (title != null) { + sub.title = title + } + if (retry != null && retry != sub.retry) { + sub.retry = retry + changedFilters.add(sub.filter) + } + } + val wasService = backgroundActive + // Handle any host change (relocation); a relocated host resubscribes everything fresh. + reconcile() + // Apply QoS changes to a host that did not relocate: the service re-sync above already + // covers the service host, so only the steady in-process host needs an explicit resub. + if (!hostedInBackground() && !wasService) { + for (filter in changedFilters) { + resubscribeFilterInProcess(filter) + } + } + } + + // The connection lives in the background host while any live subscription wants background, + // or an earlier run saved background subscriptions that are still delivered: two connections + // with the same client id would take over each other's broker session. + private fun hostedInBackground(): Boolean = + subscriptions.values.any { it.background } || PushBackground.hasSaved(appContext) + + private fun currentListeners(): List = + subscriptions.values.map { PushListener(setOf(it.filter), it.callback, it.background, it.title, it.retry) } + + /** + * Tear down the connection and drop all subscriptions, including the background ones saved + * by an earlier run, and stop background delivery. Call it on sign-out. + */ + fun close() { + mqtt?.disconnect() + mqtt = null + subscriptions.clear() + backgroundActive = false + PushBackground.stop(appContext) + } + + // The last live subscription is gone: close the connection, keeping any background + // subscriptions saved by an earlier run. + private fun releaseAll() { + mqtt?.disconnect() + mqtt = null + subscriptions.clear() + if (backgroundActive) { + backgroundActive = false + PushBackground.release(appContext) + } + } + + private fun connect(): Mqtt5AsyncClient = synchronized(connectLock) { + val config = buildConfig() + // Reuse the existing client even while it is mid automatic-reconnect. Building a second + // client here would give two clients the same stable id, so they would fight over the + // broker session and callbacks could stay attached to the abandoned one. + mqtt?.let { existing -> + if (config == connectedConfig) { + return existing + } + connectionEpoch++ + existing.disconnect() + mqtt = null + } + + // The listeners surface the lifecycle to onOpen/onClose and every connection error to + // onError, except a refused initial CONNECT: pushConnect throws that one, and the + // failing call reports it. + val client = buildPushClient( + config, + onConnected = { onOpen?.invoke() }, + onDisconnected = { onClose?.invoke() }, + onError = { reportAsync(it) }, + ) + mqtt = client + connectedConfig = config + + try { + client.pushConnect(config) + } catch (e: Exception) { + client.disconnect() + if (mqtt === client) { + mqtt = null + connectedConfig = null + } + throw e + } + + for ((id, sub) in subscriptions) { + if (sub.client == null || sub.client === client) { + continue + } + client.subscribeWith() + .topicFilter(sub.filter) + .qos(effectiveQos(sub.filter)) + .callback { publish -> subscriptions[id]?.callback?.invoke(publish.toPushMessage()) } + .send() + .reportFailure(client) + sub.client = client + } + + client + } + + private fun buildConfig(): PushConfig { + val (authMethod, credential) = resolvePushAuth(client) + return PushConfig( + host = host, + port = port, + tls = tls, + tlsInsecure = tlsInsecure, + // HiveMQ cannot connect with an empty id (it expects the broker to assign one), so + // when the app did not call Client.setPushClientId() derive a stable id per user and + // install. The broker keeps the replay position per user and client id, so each + // device resumes its own backlog across restarts, and two devices of one user do not + // take over each other's session. + clientId = client.pushClientId?.takeIf { it.isNotEmpty() } + ?: defaultPushClientId(appContext, authMethod, credential), + keepAlive = KEEP_ALIVE_SECONDS, + authMethod = authMethod, + credential = credential, + project = client.config["project"] ?: "", + ) + } + + companion object { + // Fixed keep-alive (PINGREQ interval) in seconds — not exposed as an option. + private const val KEEP_ALIVE_SECONDS = 60 + + /** The notification tap intent's extra holding the message topic. */ + const val EXTRA_TOPIC = PushBackground.EXTRA_TOPIC + + /** The notification tap intent's extra holding the message payload as a string. */ + const val EXTRA_PAYLOAD = PushBackground.EXTRA_PAYLOAD + + /** + * Request the `POST_NOTIFICATIONS` runtime permission (Android 13+). Call from an + * Activity before subscribing with `background = true`; on older versions it is a no-op. + */ + @JvmStatic + @JvmOverloads + fun requestNotificationPermission(activity: Activity, requestCode: Int = 9412) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return + } + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + requestCode, + ) + } + } + } +} + +/** Read the credential set on the client (via Client.setJWT / setSession). */ +internal fun resolvePushAuth(client: Client): Pair { + val jwt = client.config["jwt"] ?: client.config["jWT"] + val session = client.config["session"] + return when { + !jwt.isNullOrEmpty() -> "appwrite-jwt" to jwt + !session.isNullOrEmpty() -> "appwrite-session" to session + else -> throw AppwriteException( + "No credential set on the client; call Client.setJWT() or Client.setSession() first.", + ) + } +} + +/** + * The signed-in user's id for the topic-less [Push.subscribe], read locally in the same order as + * [resolvePushAuth]: the JWT's `userId` claim, else the `id` in the session secret (standard + * base64 of `{"id": ..., "secret": ...}`). Throws when neither yields one. + */ +internal fun resolvePushUserId(client: Client): String { + val jwt = client.config["jwt"] ?: client.config["jWT"] + val session = client.config["session"] + val userId = when { + !jwt.isNullOrEmpty() -> userIdFromJwt(jwt) + !session.isNullOrEmpty() -> userIdFromSession(session) + else -> "" + } + if (userId.isEmpty()) { + throw AppwriteException( + "subscribe() without a topic needs a signed-in user: set a JWT or session on the client", + ) + } + return userId +} diff --git a/library/src/main/java/io/appwrite/services/PushBackground.kt b/library/src/main/java/io/appwrite/services/PushBackground.kt new file mode 100644 index 00000000..52517344 --- /dev/null +++ b/library/src/main/java/io/appwrite/services/PushBackground.kt @@ -0,0 +1,1009 @@ +package io.appwrite.services + +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.Network +import android.os.Build +import android.os.PowerManager +import android.os.SystemClock +import android.util.AtomicFile +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.hivemq.client.mqtt.MqttGlobalPublishFilter +import com.hivemq.client.mqtt.datatypes.MqttQos +import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient +import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5ConnAckException +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish +import io.appwrite.exceptions.AppwriteException +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +private const val LOG_TAG = "AppwritePush" + +/** A background subscription saved across restarts: its filter, QoS choice and notification title. */ +internal data class PushEntry( + val filter: String, + val retry: Boolean, + val title: String?, +) + +/** + * The adaptive heartbeat: its interval, the current streak of failed heartbeats, and when the + * interval last shrank (wall-clock ms, as it must survive reboots). + */ +internal data class PushHeartbeat( + val intervalMs: Long, + val failures: Int = 0, + val shrunkAt: Long = 0L, +) + +/** + * The process-wide host of the background push connection, modelled on how socket push + * services survive on Android without keeping a service alive. + * + * The background subscriptions and their connection config are saved to private storage (see + * [PushStore]), so delivery survives the process being killed, a reboot and an app update. A + * job ([PushJobService]) that reschedules itself on every run, backed by an alarm + * ([PushAlarmReceiver]), wakes the process every 15 seconds (60 when the app may use exact + * alarms or is exempt from battery optimisation). Each run holds a short wakelock and + * reconnects when the connection is gone or the default network changed, sends a heartbeat + * when one is due, and re-arms the job. While the process is alive between runs the socket + * stays open and messages arrive immediately; while it is dead the broker keeps the backlog + * for this client id and replays it when the next run subscribes again. + * + * With foreground mode enabled ([Push.setForeground]) a foreground service ([PushService]) also keeps + * the process at foreground priority, so delivery is immediate after a kill and during Doze. + * + * Messages go to the live in-app callbacks that match them. When none match (the process was + * started in the background), a declared [PushReceiver] gets the message, and a notification + * is posted unless it returns true. + */ +internal object PushBackground { + const val CHANNEL_ID = "appwrite-push" + const val NOTIFICATION_ID = 1911 + const val ACTION_TICK = "io.appwrite.push.TICK" + const val ACTION_MESSAGE = "io.appwrite.push.MESSAGE" + const val NOTIFICATION_ICON = "io.appwrite.push.notification_icon" + const val EXTRA_TOPIC = "io.appwrite.push.TOPIC" + const val EXTRA_PAYLOAD = "io.appwrite.push.PAYLOAD" + + private const val SERVICE_CHANNEL_ID = "appwrite-push-service" + private const val JOB_ID = 0x50555348 + private const val INTERVAL_MS = 15_000L + private const val PRIVILEGED_INTERVAL_MS = 60_000L + private const val RESTART_DELAY_MS = 3_000L + // An upper bound covering a run's connect (20 s) and its requests (10 s each); the wakelock is + // released as soon as the run finishes. + private const val WAKE_LOCK_MS = 60_000L + private const val KEEP_ALIVE_SECONDS = 300 + + // The heartbeat interval: below the broker's 1.5 x keep-alive expiry, measured on a clock that + // counts deep sleep. Networks that drop idle connections sooner (carrier NATs) make heartbeats + // fail; after HEARTBEAT_FAILURES in a row the interval shrinks by a quarter, down to + // MIN_HEARTBEAT_MS, and returns to the default HEARTBEAT_RESET_MS after the last shrink, in + // case the device moved to a better network. + const val HEARTBEAT_MS = 180_000L + private const val MIN_HEARTBEAT_MS = 60_000L + 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 CONNECT_TIMEOUT_SECONDS = 20L + + // How long a message waits for a listener that acknowledges it itself (the React Native and + // Flutter bridges, once their callback has run) before it is acknowledged anyway, so one stuck + // callback cannot hold back the acknowledgements after it. + private const val ACK_TIMEOUT_SECONDS = 10L + + // Unsubscribing a filter nothing subscribes to is a harmless round trip the broker always + // acknowledges, so it proves the connection is alive. The broker ignores client PUBLISHes + // without a PUBACK, so a publish cannot serve as the heartbeat. + private const val HEARTBEAT_FILTER = "\$heartbeat" + + @Volatile + var channelName: String = "Notifications" + + @Volatile + var ongoingTitle: String = "Appwrite" + + // Reports errors to the Push instance that hosts its subscriptions here. Returns whether the + // app's onError received the error (false when it registered none). + @Volatile + var errorHandler: ((Throwable) -> Boolean)? = null + + @Volatile + var serviceRunning = false + + // Whether a refused service start was reported, so a job that retries it every run does not + // report the same failure every 15 seconds. + @Volatile + private var serviceStartReported = false + + // The live in-app subscriptions hosted here, each with its callback. Empty after the process + // was restarted in the background, until the app subscribes again. + val listeners = CopyOnWriteArrayList() + + // Guards the saved state below and its copy on disk. + private val lock = Any() + private var loaded = false + private var config: PushConfig? = null + + // Background subscriptions saved by an earlier run that no live subscription has taken over. + private var saved: List = emptyList() + + // Every MQTT call runs on this thread, in order, so the connection state below needs no lock. + private val worker = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "AppwritePushBackground").apply { isDaemon = true } + } + private var mqtt: Mqtt5AsyncClient? = null + private var connectedConfig: PushConfig? = null + private var connectedNetwork: Network? = null + private var lastHeartbeat = 0L + private var heartbeatState: PushHeartbeat? = null + private val subscribed = mutableMapOf() + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + // Worker thread only. While a caller waits for a converge (host with a completion), its + // failures go to that caller, which reports them, instead of onError. + private var waiting = false + private var failure: Throwable? = null + + /** + * Host [newListeners] (the app's live subscriptions) on the background connection with + * [newConfig], save the background ones, and arm the scheduled wake-ups. Saved subscriptions + * from an earlier run stay unless a live subscription on the same filter takes them over + * (with background on or off), or [newConfig] is for another client id (another user), whose + * saved subscriptions are dropped. + * + * With [done], it is called once the connection is up and every filter is subscribed (SUBACK), + * or with the failure, which is then not reported to onError: the caller reports it. + */ + fun host( + context: Context, + newConfig: PushConfig, + newListeners: List, + done: ((Throwable?) -> Unit)? = null, + ) { + val app = context.applicationContext + synchronized(lock) { + load(app) + if (config != null && config?.clientId != newConfig.clientId) { + saved = emptyList() + } + // A live subscription on a saved filter takes it over, whether or not it wants background: + // one that turned background off, or is later unsubscribed, must not leave the saved + // entry delivering in the background. + saved = saved.filter { entry -> newListeners.none { entry.filter in it.topics } } + config = newConfig + listeners.clear() + listeners.addAll(newListeners) + persist(app) + } + activate(app, done) + } + + /** + * Stop hosting the app's live subscriptions, keeping the saved ones from an earlier run. + * Shuts down when none remain. + */ + fun release(context: Context) { + val app = context.applicationContext + val active = synchronized(lock) { + listeners.clear() + persist(app) + isActive() + } + if (active) { + worker.execute { converge(app, heartbeat = false) } + } else { + shutdown(app) + } + } + + /** Stop background delivery completely and forget every saved subscription. */ + fun stop(context: Context) { + val app = context.applicationContext + synchronized(lock) { + listeners.clear() + config = null + saved = emptyList() + loaded = true + PushStore.clearState(app) + } + shutdown(app) + } + + /** Whether subscriptions saved by an earlier run are still delivered in the background. */ + fun hasSaved(context: Context): Boolean = synchronized(lock) { + load(context.applicationContext) + saved.isNotEmpty() + } + + /** Persist the foreground-mode choice and start or stop the foreground service to match. */ + fun setForeground(context: Context, enabled: Boolean) { + val app = context.applicationContext + PushStore.setForeground(app, enabled) + serviceStartReported = false + ensureService(app) + } + + /** Whether the foreground service should run: foreground mode is enabled and there is work to do. */ + fun wantsService(context: Context): Boolean { + val app = context.applicationContext + return PushStore.foreground(app) && synchronized(lock) { + load(app) + isActive() + } + } + + /** Resume saved background delivery when the app starts, without waiting for the next run. */ + fun resume(context: Context) { + val app = context.applicationContext + val active = synchronized(lock) { + load(app) + isActive() + } + if (active) { + tick(app) {} + } + } + + /** + * One scheduled run: under a wakelock, reconnect if needed, send a heartbeat when one is due, + * re-arm the next run, and call [done] once finished. Shuts down when nothing is saved. + */ + fun tick(context: Context, done: () -> Unit) { + val app = context.applicationContext + val wakeLock = acquireWakeLock(app) + worker.execute { + try { + val active = synchronized(lock) { + load(app) + isActive() + } + if (active) { + // Arm the next run first, so a run cut short (killed, or a slow broker outlasting + // the wakelock) still leaves the chain armed. A refused credential in converge() + // shuts down, cancelling it again. + schedule(app, INTERVAL_MS) + ensureService(app) + registerNetworkCallback(app) + converge(app, heartbeat = true) + } else { + shutdown(app) + } + } catch (e: Exception) { + report(e) + } finally { + releaseWakeLock(wakeLock) + done() + } + } + } + + /** Run once more shortly after the task was removed, so an OEM kill that follows is undone. */ + fun scheduleRestart(context: Context) { + schedule(context.applicationContext, RESTART_DELAY_MS, jobToo = false) + } + + /** + * Forget everything held in memory, as the process dying does, keeping only what is saved. + * For tests of what happens after the app's process was killed. + */ + internal fun dropProcessState(context: Context) { + synchronized(lock) { + listeners.clear() + config = null + saved = emptyList() + loaded = false + } + errorHandler = null + unregisterNetworkCallback(context.applicationContext) + worker.submit { disconnect() }.get() + } + + /** Report an error to the hosting Push instance's onError; logged when there is none. */ + fun report(error: Throwable): Boolean { + val delivered = try { + errorHandler?.invoke(error) ?: false + } catch (callbackError: Exception) { + Log.e(LOG_TAG, "Push onError threw", callbackError) + true + } + if (!delivered) { + Log.e(LOG_TAG, "Push background error", error) + } + return delivered + } + + // Must hold [lock]. + private fun load(context: Context) { + if (loaded) { + return + } + loaded = true + val state = PushStore.loadState(context) ?: return + config = state.first + saved = state.second + } + + // Must hold [lock]. + private fun isActive(): Boolean = config != null && (saved.isNotEmpty() || listeners.isNotEmpty()) + + // Must hold [lock]. Every background subscription, live or saved, one per filter and title. + // Subscriptions sharing a filter and title are saved once, with QoS 1 when any of them wants it. + private fun entries(): List = + (saved + listeners.filter { it.background }.flatMap { listener -> listener.topics.map { PushEntry(it, listener.retry, listener.title) } }) + .groupBy { it.filter to it.title } + .map { (key, group) -> PushEntry(key.first, group.any { it.retry }, key.second) } + + // Must hold [lock]. + private fun persist(context: Context) { + val current = config + val entries = entries() + if (current == null || entries.isEmpty()) { + PushStore.clearState(context) + } else { + PushStore.saveState(context, current, entries) + } + } + + // Must hold [lock]. The broker subscriptions wanted, at the highest QoS any subscription on a + // filter asks for. + private fun wanted(): Map { + val qos = linkedMapOf() + fun want(filter: String, retry: Boolean) { + if (retry) { + qos[filter] = MqttQos.AT_LEAST_ONCE + } else if (filter !in qos) { + qos[filter] = MqttQos.AT_MOST_ONCE + } + } + for (listener in listeners) { + listener.topics.forEach { want(it, listener.retry) } + } + saved.forEach { want(it.filter, it.retry) } + return qos + } + + private fun activate(context: Context, done: ((Throwable?) -> Unit)? = null) { + registerNetworkCallback(context) + schedule(context, INTERVAL_MS) + ensureService(context) + worker.execute { converge(context, heartbeat = false, done) } + } + + private fun shutdown(context: Context) { + cancelSchedule(context) + unregisterNetworkCallback(context) + if (serviceRunning) { + context.stopService(Intent(context, PushService::class.java)) + } + worker.execute { disconnect() } + } + + // Worker thread only. Bring the connection to the wanted state: reconnect when the config or + // the default network changed or a due heartbeat fails, then subscribe and unsubscribe the + // difference. With [done], failures go to it (see [waiting]) instead of onError. + private fun converge(context: Context, heartbeat: Boolean, done: ((Throwable?) -> Unit)? = null) { + waiting = done != null + failure = null + try { + convergeOnce(context, heartbeat) + } catch (e: Exception) { + fail(e) + } finally { + waiting = false + done?.invoke(failure) + } + } + + // Worker thread only. Record a failure for the waiting caller, or report it. + private fun fail(error: Throwable) { + if (failure == null) { + failure = error + } + if (!waiting) { + report(error) + } + } + + private fun convergeOnce(context: Context, heartbeat: Boolean) { + val (current, wanted) = synchronized(lock) { config to wanted() } + if (current == null || wanted.isEmpty()) { + disconnect() + return + } + var client = mqtt + if (client != null && (current != connectedConfig || networkChanged(context))) { + disconnect() + client = null + } + if (client != null && heartbeat && SystemClock.elapsedRealtime() - lastHeartbeat >= heartbeatInterval(context)) { + val alive = sendHeartbeat(client) + recordHeartbeat(context, alive) + if (alive) { + lastHeartbeat = SystemClock.elapsedRealtime() + } else { + disconnect() + client = null + } + } + if (client == null) { + client = connect(context, current) ?: return + } + for (filter in subscribed.keys - wanted.keys) { + request { client.unsubscribeWith().topicFilter(filter).send().get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) } + subscribed.remove(filter) + } + for ((filter, qos) in wanted) { + if (subscribed[filter] != qos && subscribe(client, filter, qos)) { + subscribed[filter] = qos + } + } + } + + // Worker thread only. Connect with [config], or report why not and return null. A refused + // credential stops background delivery: retrying it would only be refused again. + private fun connect(context: Context, config: PushConfig): Mqtt5AsyncClient? { + // A failed first connect of a waited-for converge goes to the waiting caller only. + val quiet = waiting + val connected = AtomicBoolean(false) + val client = buildPushClient( + config.copy(keepAlive = KEEP_ALIVE_SECONDS), + onError = { error -> + if (!quiet || connected.get()) { + report(error) + } + }, + onAuthRefused = { error -> worker.execute { refused(context, error) } }, + ) + // Registered before connecting, so the backlog the broker replays right after each + // SUBSCRIBE reaches the app however the subscription was made (including the ones the + // client restores itself after an automatic reconnect). Acknowledged by hand, once the + // message was delivered (see [deliver]). + client.publishes(MqttGlobalPublishFilter.ALL, { publish -> deliver(context, publish) }, true) + try { + client.sendPushConnect(config.copy(keepAlive = KEEP_ALIVE_SECONDS), CONNECT_TIMEOUT_SECONDS) + } catch (e: Exception) { + val cause = (e as? ExecutionException)?.cause ?: e + client.disconnect() + val error = connectError(cause) + when { + isAuthRefusal(cause) -> refused(context, error) + waiting -> fail(error) + // A network failure already reached onError through the client's disconnect + // listener; a refused CONNECT and a timeout did not. + cause is Mqtt5ConnAckException || cause is TimeoutException -> report(error) + } + return null + } + connected.set(true) + mqtt = client + connectedConfig = config + connectedNetwork = activeNetwork(context) + lastHeartbeat = SystemClock.elapsedRealtime() + subscribed.clear() + return client + } + + // Worker thread only. + private fun subscribe(client: Mqtt5AsyncClient, filter: String, qos: MqttQos): Boolean = request { + val subAck = client.subscribeWith().topicFilter(filter).qos(qos).send().get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) + val code = subAck.reasonCodes.firstOrNull { it.isError } + if (code != null) { + throw AppwriteException( + subAck.reasonString.map { it.toString() }.orElse("Subscribing to $filter was refused (reason $code)"), + ) + } + } + + // Worker thread only. The current heartbeat interval, back at the default once a shrink is + // old enough. + private fun heartbeatInterval(context: Context): Long { + val state = heartbeatState ?: PushStore.heartbeat(context).also { heartbeatState = it } + if (state.intervalMs < HEARTBEAT_MS && System.currentTimeMillis() - state.shrunkAt >= HEARTBEAT_RESET_MS) { + saveHeartbeat(context, PushHeartbeat(HEARTBEAT_MS)) + return HEARTBEAT_MS + } + return state.intervalMs + } + + // Worker thread only. Count a failed heartbeat (a connection the network dropped silently), + // shrinking the interval after HEARTBEAT_FAILURES in a row; a delivered one ends the streak. + private fun recordHeartbeat(context: Context, alive: Boolean) { + val state = heartbeatState ?: PushStore.heartbeat(context) + val next = when { + alive -> state.copy(failures = 0) + state.failures + 1 < HEARTBEAT_FAILURES -> state.copy(failures = state.failures + 1) + else -> PushHeartbeat(maxOf(MIN_HEARTBEAT_MS, state.intervalMs * 3 / 4), 0, System.currentTimeMillis()) + } + if (next != state) { + saveHeartbeat(context, next) + } + } + + private fun saveHeartbeat(context: Context, state: PushHeartbeat) { + heartbeatState = state + PushStore.setHeartbeat(context, state) + } + + // Worker thread only. + private fun sendHeartbeat(client: Mqtt5AsyncClient): Boolean = try { + client.unsubscribeWith().topicFilter(HEARTBEAT_FILTER).send().get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) + true + } catch (e: Exception) { + false + } + + // Run a broker request, recording or reporting its failure. Returns whether it succeeded. + private fun request(block: () -> Unit): Boolean = try { + block() + true + } catch (e: Exception) { + fail((e as? ExecutionException)?.cause ?: e) + false + } + + // Worker thread only. + private fun disconnect() { + mqtt?.disconnect() + mqtt = null + connectedConfig = null + connectedNetwork = null + subscribed.clear() + } + + // Worker thread only. The broker refused the credential: report it and stop. With no onError + // registered the message is also saved, and the next onError registration receives it. A + // waiting caller receives it instead. + private fun refused(context: Context, error: Throwable) { + if (waiting) { + fail(error) + } else if (!report(error)) { + PushStore.setStoppedError(context, error.message ?: "Push connection refused") + } + stop(context) + } + + // Deliver a message, then acknowledge it: right away, or once every listener that acknowledges + // messages itself has done so (at most ACK_TIMEOUT_SECONDS later). + private fun deliver(context: Context, publish: Mqtt5Publish) { + val acknowledged = AtomicBoolean(false) + val acknowledge = { + if (acknowledged.compareAndSet(false, true)) { + runCatching { publish.acknowledge() } + } + } + val pending = AtomicInteger(1) + val settle = { + if (pending.decrementAndGet() == 0) { + acknowledge() + } + } + try { + deliverMessage(context, publish.toPushMessage()) { + pending.incrementAndGet() + val once = AtomicBoolean(false) + val ack: () -> Unit = { + if (once.compareAndSet(false, true)) { + settle() + } + } + ack + } + } finally { + settle() + } + if (!acknowledged.get()) { + worker.schedule({ acknowledge() }, ACK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + } + + // Deliver a message to the matching live callbacks; with none, to the declared PushReceiver + // and/or a notification per saved subscription that matches it. [deferAck] hands a listener + // that acknowledges messages itself its acknowledgement. + private fun deliverMessage(context: Context, message: PushMessage, deferAck: () -> () -> Unit) { + val matching = listeners.filter { listener -> listener.topics.any { matchesTopic(it, message.topic) } } + // Saved background subscriptions no live one has taken over; they deliver even when a live + // foreground-only subscription on an overlapping filter received the message too. + val entries = synchronized(lock) { saved }.filter { matchesTopic(it.filter, message.topic) } + if (matching.isEmpty() && entries.isEmpty()) { + return + } + for (listener in matching) { + try { + val acknowledging = listener.acknowledgingCallback + if (acknowledging != null) { + acknowledging(message, deferAck()) + } else { + listener.callback(message) + } + } catch (e: Exception) { + report(e) + } + } + val wakeLock = acquireWakeLock(context) + try { + // The app's PushReceiver gets only messages no live callback received. + val handled = matching.isEmpty() && deliverToReceivers(context, 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) } + } finally { + releaseWakeLock(wakeLock) + } + } + + // Call every PushReceiver the app declares. Returns whether one handled the message. + private fun deliverToReceivers(context: Context, message: PushMessage): Boolean { + val intent = Intent(ACTION_MESSAGE).setPackage(context.packageName) + val receivers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.queryBroadcastReceivers(intent, PackageManager.ResolveInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + context.packageManager.queryBroadcastReceivers(intent, 0) + } + var handled = false + for (info in receivers) { + val name = info.activityInfo?.name ?: continue + try { + val receiver = Class.forName(name, true, context.classLoader).getDeclaredConstructor().newInstance() + if (receiver is PushReceiver && receiver.onMessage(context, message)) { + handled = true + } + } catch (e: Exception) { + report(e) + } + } + 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) { + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) { + return + } + createChannels(context) + val id = 31 * message.hashCode() + title.hashCode() + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setContentText(message.data) + .setSmallIcon(notificationIcon(context)) + .setAutoCancel(true) + 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) + .putExtra(EXTRA_PAYLOAD, message.data) + builder.setContentIntent( + PendingIntent.getActivity(context, id, launch, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE), + ) + } + try { + manager.notify(id, builder.build()) + } catch (e: SecurityException) { + // POST_NOTIFICATIONS not granted; nothing to show. + } + } + + /** 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) } + .setContentTitle(ongoingTitle) + .setContentText("Listening for messages") + .setSmallIcon(notificationIcon(context)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .build() + + private fun createChannels(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val manager = context.getSystemService(NotificationManager::class.java) ?: return + manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_HIGH)) + manager.createNotificationChannel( + NotificationChannel(SERVICE_CHANNEL_ID, "Background connection", NotificationManager.IMPORTANCE_LOW), + ) + } + + // The icon named by the app's ``, else a + // generic one. + private fun notificationIcon(context: Context): Int { + val metaData = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getApplicationInfo(context.packageName, PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong())) + } else { + @Suppress("DEPRECATION") + context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) + }.metaData + }.getOrNull() + return metaData?.getInt(NOTIFICATION_ICON, 0)?.takeIf { it != 0 } ?: android.R.drawable.stat_notify_chat + } + + private fun ensureService(context: Context) { + if (!wantsService(context)) { + if (serviceRunning) { + context.stopService(Intent(context, PushService::class.java)) + } + return + } + if (serviceRunning) { + return + } + try { + ContextCompat.startForegroundService(context, Intent(context, PushService::class.java)) + } catch (e: Exception) { + // Android 12+ refuses a foreground-service start from the background; the scheduled + // runs keep delivering, and the next start from the foreground succeeds. + if (!serviceStartReported) { + serviceStartReported = true + report(e) + } + } + } + + // Arm the next run in [delayMs]: the job, and an alarm just after it in case the job is late. + private fun schedule(context: Context, delayMs: Long, jobToo: Boolean = true) { + val interval = if (delayMs == INTERVAL_MS && privileged(context)) PRIVILEGED_INTERVAL_MS else delayMs + if (jobToo) { + try { + val job = JobInfo.Builder(JOB_ID, ComponentName(context, PushJobService::class.java)) + .setMinimumLatency(interval) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPersisted(true) + .build() + context.getSystemService(JobScheduler::class.java)?.schedule(job) + } catch (e: Exception) { + report(e) + } + } + val alarms = context.getSystemService(AlarmManager::class.java) ?: return + val at = SystemClock.elapsedRealtime() + interval + 1_000L + val intent = tickIntent(context) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarms.canScheduleExactAlarms()) { + alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, at, intent) + } else { + alarms.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, at, intent) + } + } catch (e: SecurityException) { + alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, at, intent) + } + } + + private fun cancelSchedule(context: Context) { + context.getSystemService(JobScheduler::class.java)?.cancel(JOB_ID) + context.getSystemService(AlarmManager::class.java)?.cancel(tickIntent(context)) + } + + private fun tickIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(context, PushAlarmReceiver::class.java).setAction(ACTION_TICK), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + // Allowed exact alarms or exempt from battery optimisation: the alarm is reliable, so the + // runs can be spaced further apart. + private fun privileged(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + context.getSystemService(AlarmManager::class.java)?.canScheduleExactAlarms() == true || + context.getSystemService(PowerManager::class.java)?.isIgnoringBatteryOptimizations(context.packageName) == true + + private fun activeNetwork(context: Context): Network? = + runCatching { context.getSystemService(ConnectivityManager::class.java)?.activeNetwork }.getOrNull() + + private fun networkChanged(context: Context): Boolean { + val now = activeNetwork(context) + return now != null && connectedNetwork != null && now != connectedNetwork + } + + // Reconnect as soon as the default network changes (e.g. from cellular to Wi-Fi) while the + // process is alive, instead of waiting for the next run to notice. + private fun registerNetworkCallback(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + synchronized(lock) { + if (networkCallback != null) { + return + } + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + worker.execute { + if (mqtt != null && network != connectedNetwork) { + converge(context, heartbeat = false) + } + } + } + } + try { + context.getSystemService(ConnectivityManager::class.java)?.registerDefaultNetworkCallback(callback) + networkCallback = callback + } catch (e: Exception) { + report(e) + } + } + } + + private fun unregisterNetworkCallback(context: Context) { + val callback = synchronized(lock) { networkCallback.also { networkCallback = null } } ?: return + runCatching { context.getSystemService(ConnectivityManager::class.java)?.unregisterNetworkCallback(callback) } + } + + private fun acquireWakeLock(context: Context): PowerManager.WakeLock? = runCatching { + context.getSystemService(PowerManager::class.java) + ?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Appwrite:push") + ?.apply { + setReferenceCounted(false) + acquire(WAKE_LOCK_MS) + } + }.getOrNull() + + private fun releaseWakeLock(wakeLock: PowerManager.WakeLock?) { + runCatching { + if (wakeLock?.isHeld == true) { + wakeLock.release() + } + } + } +} + +/** + * Background push state in the app's no-backup directory, so a restored backup on another + * device never carries this install's id or credential: the saved subscriptions with their + * connection config (cleared when background delivery stops), and the settings that outlive + * it (the install id, the foreground-mode choice, a refusal waiting for onError). + */ +internal object PushStore { + private const val STATE_FILE = "appwrite_push_state.json" + private const val SETTINGS_FILE = "appwrite_push_settings.json" + + fun loadState(context: Context): Pair>? = synchronized(this) { + val json = read(context, STATE_FILE) ?: return null + runCatching { + val config = json.getJSONObject("config").let { + PushConfig( + host = it.getString("host"), + port = it.getInt("port"), + tls = it.getBoolean("tls"), + tlsInsecure = it.getBoolean("tlsInsecure"), + clientId = it.getString("clientId"), + keepAlive = it.getInt("keepAlive"), + authMethod = it.getString("authMethod"), + credential = it.getString("credential"), + project = it.getString("project"), + ) + } + val list = json.getJSONArray("entries") + val entries = (0 until list.length()).map { index -> + val entry = list.getJSONObject(index) + PushEntry( + filter = entry.getString("filter"), + retry = entry.getBoolean("retry"), + title = if (entry.isNull("title")) null else entry.getString("title"), + ) + } + config to entries + }.getOrNull() + } + + fun saveState(context: Context, config: PushConfig, entries: List) = synchronized(this) { + val json = JSONObject() + .put( + "config", + JSONObject() + .put("host", config.host) + .put("port", config.port) + .put("tls", config.tls) + .put("tlsInsecure", config.tlsInsecure) + .put("clientId", config.clientId) + .put("keepAlive", config.keepAlive) + .put("authMethod", config.authMethod) + .put("credential", config.credential) + .put("project", config.project), + ) + .put( + "entries", + JSONArray(entries.map { JSONObject().put("filter", it.filter).put("retry", it.retry).put("title", it.title ?: JSONObject.NULL) }), + ) + write(context, STATE_FILE, json) + } + + fun clearState(context: Context) = synchronized(this) { + file(context, STATE_FILE).delete() + } + + /** A random id for this install, created once, which keeps the broker's replay position. */ + fun installId(context: Context): String = synchronized(this) { + val settings = read(context, SETTINGS_FILE) ?: JSONObject() + settings.optString("installId").ifEmpty { + val id = UUID.randomUUID().toString() + write(context, SETTINGS_FILE, settings.put("installId", id)) + id + } + } + + fun foreground(context: Context): Boolean = synchronized(this) { + read(context, SETTINGS_FILE)?.optBoolean("foreground", false) ?: false + } + + fun setForeground(context: Context, enabled: Boolean) = synchronized(this) { + write(context, SETTINGS_FILE, (read(context, SETTINGS_FILE) ?: JSONObject()).put("foreground", enabled)) + } + + /** The saved adaptive heartbeat; the default interval until one was saved. */ + fun heartbeat(context: Context): PushHeartbeat = synchronized(this) { + val json = read(context, SETTINGS_FILE)?.optJSONObject("heartbeat") ?: return PushHeartbeat(PushBackground.HEARTBEAT_MS) + PushHeartbeat( + intervalMs = json.optLong("intervalMs", PushBackground.HEARTBEAT_MS), + failures = json.optInt("failures", 0), + shrunkAt = json.optLong("shrunkAt", 0L), + ) + } + + fun setHeartbeat(context: Context, state: PushHeartbeat) = synchronized(this) { + val heartbeat = JSONObject() + .put("intervalMs", state.intervalMs) + .put("failures", state.failures) + .put("shrunkAt", state.shrunkAt) + write(context, SETTINGS_FILE, (read(context, SETTINGS_FILE) ?: JSONObject()).put("heartbeat", heartbeat)) + } + + fun setStoppedError(context: Context, message: String) = synchronized(this) { + write(context, SETTINGS_FILE, (read(context, SETTINGS_FILE) ?: JSONObject()).put("stoppedError", message)) + } + + /** The refusal that stopped background delivery while no onError was registered, once. */ + fun takeStoppedError(context: Context): String? = synchronized(this) { + val settings = read(context, SETTINGS_FILE) ?: return null + val message = settings.optString("stoppedError").ifEmpty { return null } + settings.remove("stoppedError") + write(context, SETTINGS_FILE, settings) + message + } + + private fun file(context: Context, name: String) = AtomicFile(File(context.noBackupFilesDir, name)) + + private fun read(context: Context, name: String): JSONObject? = + runCatching { JSONObject(String(file(context, name).readFully(), Charsets.UTF_8)) }.getOrNull() + + private fun write(context: Context, name: String, json: JSONObject) { + val file = file(context, name) + val out = try { + file.startWrite() + } catch (e: IOException) { + Log.e(LOG_TAG, "Could not save push state", e) + return + } + try { + out.write(json.toString().toByteArray(Charsets.UTF_8)) + file.finishWrite(out) + } catch (e: IOException) { + file.failWrite(out) + Log.e(LOG_TAG, "Could not save push state", e) + } + } +} diff --git a/library/src/main/java/io/appwrite/services/PushCore.kt b/library/src/main/java/io/appwrite/services/PushCore.kt new file mode 100644 index 00000000..20c56242 --- /dev/null +++ b/library/src/main/java/io/appwrite/services/PushCore.kt @@ -0,0 +1,374 @@ +package io.appwrite.services + +import android.content.Context +import android.os.Build +import android.util.Base64 +import com.hivemq.client.mqtt.MqttClient +import com.hivemq.client.mqtt.datatypes.MqttUtf8String +import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedContext +import com.hivemq.client.mqtt.lifecycle.MqttDisconnectSource +import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient +import com.hivemq.client.mqtt.mqtt5.Mqtt5ClientConfig +import com.hivemq.client.mqtt.mqtt5.auth.Mqtt5EnhancedAuthMechanism +import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5ConnAckException +import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5DisconnectException +import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5Auth +import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5AuthBuilder +import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5EnhancedAuthBuilder +import com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect +import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAck +import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAckReasonCode +import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5Disconnect +import com.hivemq.client.mqtt.mqtt5.message.disconnect.Mqtt5DisconnectReasonCode +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish +import io.appwrite.exceptions.AppwriteException +import org.json.JSONObject +import java.security.KeyStore +import java.security.Provider +import java.security.cert.X509Certificate +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import javax.net.ssl.ManagerFactoryParameters +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.TrustManagerFactorySpi +import javax.net.ssl.X509TrustManager + +/** + * A message delivered on a subscribed topic — the MQTT push analog of a realtime event. + */ +data class PushMessage( + val topic: String, + val payload: ByteArray, + val qos: Int, +) { + /** The payload decoded as UTF-8 text, as it was sent. */ + val data: String + get() = String(payload, Charsets.UTF_8) +} + +/** Connection parameters shared between the in-process client and the foreground service. */ +internal data class PushConfig( + val host: String, + val port: Int, + val tls: Boolean, + val tlsInsecure: Boolean, + val clientId: String, + val keepAlive: Int, + val authMethod: String, + val credential: String, + val project: String, +) + +/** + * The userId claim from an Appwrite JWT, used as the default MQTT client id so the + * broker's replay cursor is stable per user. Returns "" when the token is not a decodable JWT + * (e.g. a session secret), so the caller can fall back to the raw credential. + */ +internal fun userIdFromJwt(jwt: String): String { + val parts = jwt.split('.') + if (parts.size < 2) { + return "" + } + return runCatching { + val json = String( + Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING), + Charsets.UTF_8, + ) + JSONObject(json).optString("userId", "") + }.getOrDefault("") +} + +/** The user `id` from an Appwrite session secret, or "" when it is not decodable. */ +internal fun userIdFromSession(session: String): String = runCatching { + val json = String(Base64.decode(session, Base64.DEFAULT), Charsets.UTF_8) + JSONObject(json).optString("id", "") +}.getOrDefault("") + +/** + * The default MQTT client id: one per user and install (`-`, or the install id + * alone when the credential names no user). The broker keeps the replay position per user and + * client id, so each device resumes its own backlog across restarts, and two devices of one user + * do not take over each other's session. + */ +internal fun defaultPushClientId(context: Context, authMethod: String, credential: String): String { + val userId = if (authMethod == "appwrite-session") userIdFromSession(credential) else userIdFromJwt(credential) + val installId = PushStore.installId(context) + return if (userId.isEmpty()) installId else "$userId-$installId" +} + +/** + * Build (but do not connect) an MQTT 5 client from [config]. [onError] receives every connection + * error (a lost connection, a failed automatic reconnect, a broker DISCONNECT) except a refused + * initial CONNECT, which [pushConnect] throws instead. + */ +internal fun buildPushClient( + config: PushConfig, + onConnected: (() -> Unit)? = null, + onDisconnected: (() -> Unit)? = null, + onError: ((Throwable) -> Unit)? = null, + onAuthRefused: ((Throwable) -> Unit)? = null, +): Mqtt5AsyncClient { + // config.clientId is stable per user (see buildConfig), so a reconnect rebuild reuses it + // and resumes the same session. + var builder = MqttClient.builder() + .useMqttVersion5() + .serverHost(config.host) + .serverPort(config.port) + // Recover from an unexpected disconnect (network drop, broker restart) with HiveMQ's + // exponential backoff. A user-initiated disconnect() (see close()) does not trigger it. + // Because clean start is off and the broker resumes the same session, subscriptions and + // the QoS-1 replay cursor persist, so delivery continues without resubscribing (which + // would stack a second per-subscription callback). + .automaticReconnectWithDefaultConfig() + + if (config.clientId.isNotEmpty()) { + builder = builder.identifier(config.clientId) + } + + // Whether this client has connected yet: until it has, a refused CONNECT is final. + val connected = AtomicBoolean(false) + builder = builder.addConnectedListener { + connected.set(true) + onConnected?.invoke() + } + builder = builder.addDisconnectedListener { context -> + // A refused first CONNECT (bad credential, rate limit) is final: stop the automatic + // reconnect so pushConnect fails with the broker's reason instead of retrying forever. + val refused = !connected.get() && context.cause is Mqtt5ConnAckException + // With [onAuthRefused], a credential refused later (a failed automatic reconnect or + // re-auth) is final too, instead of being retried with the same credential forever. + val authRefused = onAuthRefused != null && connected.get() && + context.source != MqttDisconnectSource.USER && isAuthRefusal(context.cause) + if (refused || authRefused) { + context.reconnector.reconnect(false) + } + onDisconnected?.invoke() + if (!refused) { + val error = pushDisconnectError(context) + if (authRefused && error != null) { + // Reported by onAuthRefused, which also stops for good. + onAuthRefused?.invoke(error) + } else { + error?.let { onError?.invoke(it) } + } + } + } + + if (config.tls) { + builder = if (config.tlsInsecure) { + builder.sslConfig() + .trustManagerFactory(PushInsecureTrustManagerFactory) + .hostnameVerifier { _, _ -> true } + .applySslConfig() + } else { + builder.sslWithDefaultConfig() + } + } + + return builder.buildAsync() +} + +/** + * Connect with Appwrite enhanced auth; blocks until CONNACK. Throws an + * [AppwriteException] on failure, whose message is the broker's MQTT 5 + * Reason String when it refused the CONNECT with one. + */ +internal fun Mqtt5AsyncClient.pushConnect(config: PushConfig) { + try { + sendPushConnect(config) + } catch (e: Exception) { + throw connectError((e as? ExecutionException)?.cause ?: e) + } +} + +/** The error a failed CONNECT reports: the broker's Reason String when it refused one. */ +internal fun connectError(cause: Throwable): AppwriteException = + if (cause is Mqtt5ConnAckException) { + connAckError(cause) + } else { + AppwriteException(cause.message ?: "MQTT connection failed") + } + +/** + * Whether [error] is the broker refusing the credential (a CONNECT or re-auth), which retrying + * with the same credential cannot fix, unlike a rate limit or a network failure. + */ +internal fun isAuthRefusal(error: Throwable?): Boolean = when (error) { + is Mqtt5ConnAckException -> error.mqttMessage.reasonCode in setOf( + Mqtt5ConnAckReasonCode.NOT_AUTHORIZED, + Mqtt5ConnAckReasonCode.BAD_USER_NAME_OR_PASSWORD, + Mqtt5ConnAckReasonCode.BAD_AUTHENTICATION_METHOD, + Mqtt5ConnAckReasonCode.BANNED, + ) + is Mqtt5DisconnectException -> error.mqttMessage.reasonCode == Mqtt5DisconnectReasonCode.NOT_AUTHORIZED + else -> false +} + +/** Send the CONNECT and wait for the CONNACK, at most [timeoutSeconds] when given. */ +internal fun Mqtt5AsyncClient.sendPushConnect(config: PushConfig, timeoutSeconds: Long? = null) { + val connAck = connectWith() + .enhancedAuth(PushAuthMechanism(config.authMethod, config.credential)) + // Clean start is always off, so the broker keeps this client's session and can + // redeliver missed messages to QoS-1 subscriptions on reconnect. + .cleanStart(false) + .keepAlive(config.keepAlive) + .userProperties() + .add("projectId", config.project) + .applyUserProperties() + .send() + if (timeoutSeconds == null) { + connAck.get() + } else { + connAck.get(timeoutSeconds, TimeUnit.SECONDS) + } +} + +/** + * The error a lost connection reports, or null when it is not an error (a disconnect the app + * asked for, or a broker DISCONNECT with a success reason code). The broker explains a refused + * CONNECT or a DISCONNECT it sends with an MQTT 5 Reason String, which becomes the message. + */ +internal fun pushDisconnectError(context: MqttClientDisconnectedContext): Throwable? { + val cause = context.cause + return when { + context.source == MqttDisconnectSource.USER -> null + cause is Mqtt5ConnAckException -> connAckError(cause) + cause is Mqtt5DisconnectException && context.source == MqttDisconnectSource.SERVER -> { + val disconnect = cause.mqttMessage + if (disconnect.reasonCode.isError) { + AppwriteException( + disconnect.reasonString.map { it.toString() } + .orElse("Disconnected by the broker (reason ${disconnect.reasonCode})"), + ) + } else { + null + } + } + else -> cause + } +} + +/** A refused CONNECT, with the CONNACK Reason String as its message when the broker sent one. */ +internal fun connAckError(e: Mqtt5ConnAckException): AppwriteException = + AppwriteException( + e.mqttMessage.reasonString.map { it.toString() }.orElse(e.message ?: "MQTT connection refused"), + ) + +internal fun Mqtt5Publish.toPushMessage(): PushMessage = + PushMessage( + topic = topic.toString(), + payload = payloadAsBytes, + qos = qos.code, + ) + +/** + * Single-step MQTT 5 enhanced auth: the method and credential are placed in the CONNECT + * packet and the broker accepts them in the CONNACK, with no AUTH round-trip. + */ +internal class PushAuthMechanism( + private val method: String, + private val credential: String, +) : Mqtt5EnhancedAuthMechanism { + + override fun getMethod(): MqttUtf8String = MqttUtf8String.of(method) + + override fun getTimeout(): Int = 30 + + override fun onAuth( + clientConfig: Mqtt5ClientConfig, + connect: Mqtt5Connect, + authBuilder: Mqtt5EnhancedAuthBuilder, + ): CompletableFuture { + authBuilder.data(credential.toByteArray(Charsets.UTF_8)) + return CompletableFuture.completedFuture(null) + } + + override fun onReAuth( + clientConfig: Mqtt5ClientConfig, + authBuilder: Mqtt5AuthBuilder, + ): CompletableFuture { + authBuilder.data(credential.toByteArray(Charsets.UTF_8)) + return CompletableFuture.completedFuture(null) + } + + override fun onContinue( + clientConfig: Mqtt5ClientConfig, + auth: Mqtt5Auth, + authBuilder: Mqtt5AuthBuilder, + ): CompletableFuture = CompletableFuture.completedFuture(true) + + override fun onAuthSuccess( + clientConfig: Mqtt5ClientConfig, + connAck: Mqtt5ConnAck, + ): CompletableFuture = CompletableFuture.completedFuture(true) + + override fun onReAuthSuccess( + clientConfig: Mqtt5ClientConfig, + auth: Mqtt5Auth, + ): CompletableFuture = CompletableFuture.completedFuture(true) + + override fun onAuthRejected(clientConfig: Mqtt5ClientConfig, connAck: Mqtt5ConnAck) = Unit + + override fun onReAuthRejected(clientConfig: Mqtt5ClientConfig, disconnect: Mqtt5Disconnect) = Unit + + override fun onAuthError(clientConfig: Mqtt5ClientConfig, cause: Throwable) = Unit + + override fun onReAuthError(clientConfig: Mqtt5ClientConfig, cause: Throwable) = Unit +} + +/** + * A [TrustManagerFactory] that accepts any certificate — used when `tlsInsecure` is set + * (for a broker whose certificate does not chain to a public root). + */ +internal object PushInsecureTrustManagerFactory : TrustManagerFactory( + object : TrustManagerFactorySpi() { + override fun engineInit(keyStore: KeyStore?) = Unit + + override fun engineInit(parameters: ManagerFactoryParameters?) = Unit + + override fun engineGetTrustManagers(): Array = arrayOf( + object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + + override fun checkServerTrusted(chain: Array?, authType: String?) = Unit + + override fun getAcceptedIssuers(): Array = emptyArray() + }, + ) + }, + @Suppress("DEPRECATION") object : Provider("AppwriteInsecure", 1.0, "insecure trust-all") {}, + "AppwriteInsecure", +) + +/** A live in-app subscription hosted in the background: its topics, callback, notification opt-in and QoS. */ +internal class PushListener( + val topics: Set, + val callback: (PushMessage) -> Unit, + val background: Boolean, + val title: String?, + val retry: Boolean, + // Called instead of [callback] when set, with the acknowledgement to run once the message was + // handled: the React Native and Flutter bridges acknowledge after their callback has run. + val acknowledgingCallback: ((PushMessage, () -> Unit) -> Unit)? = null, +) + +/** MQTT topic-filter match with '+' (single level) and '#' (multi level). */ +internal fun matchesTopic(filter: String, topic: String): Boolean { + val filterParts = filter.split('/') + val topicParts = topic.split('/') + for (i in filterParts.indices) { + if (filterParts[i] == "#") { + return true + } + if (i >= topicParts.size) { + return false + } + if (filterParts[i] != "+" && filterParts[i] != topicParts[i]) { + return false + } + } + return filterParts.size == topicParts.size +} diff --git a/library/src/main/java/io/appwrite/services/PushReceiver.kt b/library/src/main/java/io/appwrite/services/PushReceiver.kt new file mode 100644 index 00000000..92d04727 --- /dev/null +++ b/library/src/main/java/io/appwrite/services/PushReceiver.kt @@ -0,0 +1,33 @@ +package io.appwrite.services + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * Handles background push messages in code when no in-app callback is listening, e.g. after + * the process was started in the background to reconnect. Subclass it and declare it in your + * manifest with the push message action: + * + * ```xml + * + * + * + * + * + * ``` + * + * The SDK calls [onMessage] directly on a background thread, under a wakelock of about ten + * seconds, so hand longer work to WorkManager. Messages a live callback receives do not + * reach it. + */ +abstract class PushReceiver : BroadcastReceiver() { + /** + * Handle [message]. Return true when you handled it yourself, so the SDK does not post its + * default notification; return false to have it posted as well. + */ + abstract fun onMessage(context: Context, message: PushMessage): Boolean + + // Messages arrive through onMessage, never as a broadcast. + final override fun onReceive(context: Context, intent: Intent) = Unit +} diff --git a/library/src/main/java/io/appwrite/services/PushService.kt b/library/src/main/java/io/appwrite/services/PushService.kt new file mode 100644 index 00000000..e2a05f89 --- /dev/null +++ b/library/src/main/java/io/appwrite/services/PushService.kt @@ -0,0 +1,67 @@ +package io.appwrite.services + +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.ServiceCompat + +/** + * The foreground mode of background push delivery ([Push.setForeground]): a foreground service + * that keeps the process at foreground priority, so the connection held by the process stays + * up and delivers immediately, also during Doze. The system restarts it after a kill, and it + * restores the saved subscriptions. Started by the SDK; not meant to be started directly. + */ +class PushService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + PushBackground.serviceRunning = true + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!PushBackground.wantsService(this)) { + stopSelf() + return START_NOT_STICKY + } + try { + startForegroundCompat() + } catch (e: Exception) { + // E.g. a background start refused on Android 12+; the scheduled runs keep delivering. + stopSelf() + PushBackground.report(e) + return START_NOT_STICKY + } + PushBackground.tick(this) {} + // Recreated after a kill with a null intent; the saved subscriptions are restored then. + return START_STICKY + } + + private fun startForegroundCompat() { + val notification = PushBackground.ongoingNotification(this) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceCompat.startForeground( + this, + PushBackground.NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING, + ) + } else { + startForeground(PushBackground.NOTIFICATION_ID, notification) + } + } + + // Some devices kill the process shortly after its task is swiped away; a run a few seconds + // later restarts the service. + override fun onTaskRemoved(rootIntent: Intent?) { + PushBackground.scheduleRestart(this) + super.onTaskRemoved(rootIntent) + } + + override fun onDestroy() { + PushBackground.serviceRunning = false + super.onDestroy() + } +} diff --git a/library/src/main/java/io/appwrite/services/PushWakeups.kt b/library/src/main/java/io/appwrite/services/PushWakeups.kt new file mode 100644 index 00000000..091fd528 --- /dev/null +++ b/library/src/main/java/io/appwrite/services/PushWakeups.kt @@ -0,0 +1,46 @@ +package io.appwrite.services + +import android.app.job.JobParameters +import android.app.job.JobService +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * The scheduled run of background push delivery: reconnects and re-arms itself. Scheduled + * by the SDK; not meant to be started directly. + */ +class PushJobService : JobService() { + override fun onStartJob(params: JobParameters): Boolean { + PushBackground.tick(this) { jobFinished(params, false) } + return true + } + + // The run re-arms the next one itself, so a stopped run needs no retry. + override fun onStopJob(params: JobParameters): Boolean = false +} + +/** + * The alarm that backs up the scheduled job when it runs late. Scheduled by the SDK; not meant + * to be sent directly. + */ +class PushAlarmReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != PushBackground.ACTION_TICK) { + return + } + val pending = goAsync() + PushBackground.tick(context) { pending.finish() } + } +} + +/** Resumes background push delivery after a reboot or an app update. */ +class PushBootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED && intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) { + return + } + val pending = goAsync() + PushBackground.tick(context) { pending.finish() } + } +} diff --git a/library/src/main/java/io/appwrite/services/Realtime.kt b/library/src/main/java/io/appwrite/services/Realtime.kt index 01987db8..dd2720f3 100644 --- a/library/src/main/java/io/appwrite/services/Realtime.kt +++ b/library/src/main/java/io/appwrite/services/Realtime.kt @@ -432,7 +432,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { launch(IO) { if (isStale(webSocket)) return@launch try { - val message = text.fromJson() + val message = RealtimeResponse.from(text.fromJson>()) when (message.type) { TYPE_ERROR -> handleResponseError(message) TYPE_CONNECTED -> handleResponseConnected(message) @@ -458,13 +458,19 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } private fun handleResponseError(message: RealtimeResponse) { - val ex = message.data?.jsonCast() ?: RuntimeException("Data is not present") - throw ex + val data = message.data as? Map<*, *> ?: throw RuntimeException("Data is not present") + throw AppwriteException( + message = data["message"] as? String, + code = (data["code"] as? Number)?.toInt(), + type = data["type"] as? String, + response = data["response"] as? String, + ) } private suspend fun handleResponseEvent(message: RealtimeResponse) { - val messageData = message.data?.jsonCast>() ?: return - val event = messageData.jsonCast>() + @Suppress("UNCHECKED_CAST") + val messageData = message.data as? Map ?: return + val event = RealtimeResponseEvent.from(messageData) ?: return if (event.channels.isEmpty()) { return diff --git a/library/src/main/java/io/appwrite/services/Storage.kt b/library/src/main/java/io/appwrite/services/Storage.kt index cc4f8608..6fe1f966 100644 --- a/library/src/main/java/io/appwrite/services/Storage.kt +++ b/library/src/main/java/io/appwrite/services/Storage.kt @@ -12,7 +12,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import java.io.File /** - * The Storage service allows you to manage your project files. */ + * The Storage service allows you to manage your project files. + */ class Storage(client: Client) : Service(client) { /** * Get a list of all the user files. You can use the query params to filter your results. @@ -114,9 +115,9 @@ class Storage(client: Client) : Service(client) { apiParams, responseType = io.appwrite.models.File::class.java, converter, - paramName, - idParamName, - onProgress, + paramName = paramName, + idParamName = idParamName, + onProgress = onProgress, ) } diff --git a/library/src/main/java/io/appwrite/services/TablesDB.kt b/library/src/main/java/io/appwrite/services/TablesDB.kt index 7a8881df..34217c41 100644 --- a/library/src/main/java/io/appwrite/services/TablesDB.kt +++ b/library/src/main/java/io/appwrite/services/TablesDB.kt @@ -10,21 +10,25 @@ import okhttp3.Cookie import java.io.File /** - * The TablesDB service allows you to create structured tables of columns, query and filter lists of rows */ + * The TablesDB service allows you to create structured tables of columns, query and filter lists of rows + */ class TablesDB(client: Client) : Service(client) { /** * List transactions across all databases. * * @param queries Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.TransactionList] */ @JvmOverloads suspend fun listTransactions( queries: List? = null, + total: Boolean? = null, ): io.appwrite.models.TransactionList { val apiPath = "/tablesdb/transactions" val apiParams = mutableMapOf( "queries" to queries, + "total" to total, ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), diff --git a/library/src/main/java/io/appwrite/services/Teams.kt b/library/src/main/java/io/appwrite/services/Teams.kt index 7708fcca..7227eba6 100644 --- a/library/src/main/java/io/appwrite/services/Teams.kt +++ b/library/src/main/java/io/appwrite/services/Teams.kt @@ -10,7 +10,8 @@ import okhttp3.Cookie import java.io.File /** - * The Teams service allows you to group users of your project and to enable them to share read and write access to your project resources */ + * The Teams service allows you to group users of your project and to enable them to share read and write access to your project resources + */ class Teams(client: Client) : Service(client) { /** * Get a list of all the teams in which the current user is a member. You can use the parameters to filter your results. @@ -77,7 +78,7 @@ class Teams(client: Client) : Service(client) { * * @param teamId Team 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. * @param name Team name. Max length: 128 chars. - * @param roles Array of strings. Use this param to set the roles in the team for the user who created it. The default role is **owner**. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of 100 roles are allowed, each 32 characters long. + * @param roles Array of strings. Use this param to set the roles in the team for the user who created it. The default role is **owner**. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of 100 roles are allowed, each 81 characters long. * @return [io.appwrite.models.Team] */ @JvmOverloads @@ -117,7 +118,7 @@ class Teams(client: Client) : Service(client) { * * @param teamId Team 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. * @param name Team name. Max length: 128 chars. - * @param roles Array of strings. Use this param to set the roles in the team for the user who created it. The default role is **owner**. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of 100 roles are allowed, each 32 characters long. + * @param roles Array of strings. Use this param to set the roles in the team for the user who created it. The default role is **owner**. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of 100 roles are allowed, each 81 characters long. * @return [io.appwrite.models.Team] */ @JvmOverloads diff --git a/library/src/main/java/io/appwrite/services/VectorsDB.kt b/library/src/main/java/io/appwrite/services/VectorsDB.kt index 9fa2f291..9ad46da2 100644 --- a/library/src/main/java/io/appwrite/services/VectorsDB.kt +++ b/library/src/main/java/io/appwrite/services/VectorsDB.kt @@ -10,21 +10,25 @@ import okhttp3.Cookie import java.io.File /** - * */ + * + */ class VectorsDB(client: Client) : Service(client) { /** * List transactions across all databases. * * @param queries Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). + * @param total When set to false, the total count returned will be 0 and will not be calculated. * @return [io.appwrite.models.TransactionList] */ @JvmOverloads suspend fun listTransactions( queries: List? = null, + total: Boolean? = null, ): io.appwrite.models.TransactionList { val apiPath = "/vectorsdb/transactions" val apiParams = mutableMapOf( "queries" to queries, + "total" to total, ) val apiHeaders = mutableMapOf( "X-Appwrite-Project" to client.config["project"].orEmpty(), diff --git a/library/src/test/java/io/appwrite/ClientJavaInteropTest.java b/library/src/test/java/io/appwrite/ClientJavaInteropTest.java index 3019bb94..ea50bcff 100644 --- a/library/src/test/java/io/appwrite/ClientJavaInteropTest.java +++ b/library/src/test/java/io/appwrite/ClientJavaInteropTest.java @@ -22,4 +22,13 @@ public void endpointSettersAreFluentFromJava() { assertEquals("https://cloud.appwrite.io/v1", client.getEndpoint()); assertEquals("wss://cloud.appwrite.io/v1", client.getEndpointRealtime()); } + + @Test + public void positionalSelfSignedFollowsTheRealtimeEndpoint() { + Context context = ApplicationProvider.getApplicationContext(); + Client client = new Client(context, "https://cloud.appwrite.io/v1", "wss://cloud.appwrite.io/v1", true); + + assertEquals("https://cloud.appwrite.io/v1", client.getEndpoint()); + assertEquals("wss://cloud.appwrite.io/v1", client.getEndpointRealtime()); + } } diff --git a/library/src/test/java/io/appwrite/TopicTest.kt b/library/src/test/java/io/appwrite/TopicTest.kt new file mode 100644 index 00000000..0dd65bf0 --- /dev/null +++ b/library/src/test/java/io/appwrite/TopicTest.kt @@ -0,0 +1,53 @@ +package io.appwrite + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class TopicTest { + private fun assertPathFails(levels: List) { + assertThrows(IllegalArgumentException::class.java) { Topic.path(levels) } + assertThrows(IllegalArgumentException::class.java) { Topic.any().path(levels) } + } + + @Test + fun pathJoinsLevels() { + assertEquals("user/123/notification", Topic.path(listOf("user", "123", "notification")).toString()) + } + + @Test + fun pathAppendsLevels() { + assertEquals( + "org/42/user/123/notification", + Topic.path(listOf("org", "42", "user", "123")).path(listOf("notification")).toString(), + ) + } + + @Test + fun anyAppendsSingleLevelWildcard() { + assertEquals("user/+/notification", Topic.path(listOf("user")).any().path(listOf("notification")).toString()) + assertEquals("chat/+/+/message", Topic.path(listOf("chat")).any().any().path(listOf("message")).toString()) + } + + @Test + fun allEndsTopic() { + assertEquals("org/+/logs/#", Topic.path(listOf("org")).any().path(listOf("logs")).all().toString()) + } + + @Test + fun staticAnyAndAll() { + assertEquals("+/notification", Topic.any().path(listOf("notification")).toString()) + assertEquals("#", Topic.all().toString()) + } + + @Test + fun pathRejectsInvalidLevels() { + assertPathFails(emptyList()) + assertPathFails(listOf("user", "")) + assertPathFails(listOf("user/123")) + assertPathFails(listOf("+")) + assertPathFails(listOf("user", "a+b")) + assertPathFails(listOf("user", "#")) + assertPathFails(listOf("a#b")) + } +}