diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7145ba67..6230f5a8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,10 +11,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK 17 - uses: actions/setup-java@v1 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: + distribution: temurin java-version: 17 - name: Prepare environment env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f120643..3cc0dc98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,19 @@ # Change Log +## 24.1.0 + +* Added: Realtime `presences` channel and `RealtimePresence` types for presence subscriptions +* Added: `Advisor` and `Presences` services +* Added: `Insight`, `Presence`, and `Report` models with list variants +* Added: `fusionauth`, `keycloak`, and `kick` providers to `OAuthProvider` enum +* Updated: Migrated Gradle build files to Kotlin DSL (`build.gradle.kts`) +* Updated: `X-Appwrite-Response-Format` header to `1.9.5` + ## 24.0.0 -* Breaking: Added `unsubscribe()`, `update()`, and `close()` for Realtime subscription lifecycle. -* Added: Added `userPhone` to the `Membership` model. -* Updated: Updated `X-Appwrite-Response-Format` header to `1.9.2`. +* Breaking: Added `unsubscribe()`, `update()`, and `close()` to Realtime subscriptions +* Added: Added `userPhone` field to `Membership` model +* Updated: Updated `X-Appwrite-Response-Format` header to `1.9.2` ## 23.1.0 diff --git a/README.md b/README.md index 1e2528ba..d09a8ee9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Maven Central](https://img.shields.io/maven-central/v/io.appwrite/sdk-for-android.svg?color=green&style=flat-square) ![License](https://img.shields.io/github/license/appwrite/sdk-for-android.svg?style=flat-square) -![Version](https://img.shields.io/badge/api%20version-1.9.2-blue.svg?style=flat-square) +![Version](https://img.shields.io/badge/api%20version-1.9.5-blue.svg?style=flat-square) [![Build Status](https://img.shields.io/travis/com/appwrite/sdk-generator?style=flat-square)](https://travis-ci.com/appwrite/sdk-generator) [![Twitter Account](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite) [![Discord](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord) @@ -38,7 +38,7 @@ repositories { Next, add the dependency to your project's `build.gradle(.kts)` file: ```groovy -implementation("io.appwrite:sdk-for-android:24.0.0") +implementation("io.appwrite:sdk-for-android:24.1.0") ``` ### Maven @@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file: io.appwrite sdk-for-android - 24.0.0 + 24.1.0 ``` diff --git a/build.gradle b/build.gradle deleted file mode 100644 index a1957d4a..00000000 --- a/build.gradle +++ /dev/null @@ -1,33 +0,0 @@ -apply plugin: 'io.github.gradle-nexus.publish-plugin' - -// Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - ext.kotlin_version = "1.9.10" - - version System.getenv("SDK_VERSION") - - repositories { - maven { url "https://plugins.gradle.org/m2/" } - google() - mavenCentral() - } - dependencies { - classpath "com.android.tools.build:gradle:8.2.2" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "io.github.gradle-nexus:publish-plugin:1.3.0" - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -task clean(type: Delete) { - delete rootProject.buildDir -} - -apply from: "${rootDir}/scripts/publish-config.gradle" - diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..d5244701 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,43 @@ +import java.util.Properties + +plugins { + id("io.github.gradle-nexus.publish-plugin") version "2.0.0" + id("com.android.library") version "9.1.1" apply false + id("com.android.application") version "9.1.1" apply false +} + +version = System.getenv("SDK_VERSION") ?: "0.0.0" + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} + +val signingKeyId: String = System.getenv("SIGNING_KEY_ID") ?: "" +val signingPassword: String = System.getenv("SIGNING_PASSWORD") ?: "" +val signingSecretKeyRingFile: String = System.getenv("SIGNING_SECRET_KEY_RING_FILE") ?: "" +val ossrhUsername: String = System.getenv("OSSRH_USERNAME") ?: "" +val ossrhPassword: String = System.getenv("OSSRH_PASSWORD") ?: "" +val sonatypeStagingProfileId: String = System.getenv("SONATYPE_STAGING_PROFILE_ID") ?: "" + +val secretPropsFile = project.rootProject.file("local.properties") +val localProperties = Properties().apply { + if (secretPropsFile.exists()) { + secretPropsFile.inputStream().use { load(it) } + } +} + +extra["signing.keyId"] = signingKeyId.ifEmpty { localProperties.getProperty("signing.keyId", "") } +extra["signing.password"] = signingPassword.ifEmpty { localProperties.getProperty("signing.password", "") } +extra["signing.secretKeyRingFile"] = signingSecretKeyRingFile.ifEmpty { localProperties.getProperty("signing.secretKeyRingFile", "") } + +nexusPublishing { + repositories { + sonatype { + stagingProfileId.set(sonatypeStagingProfileId.ifEmpty { localProperties.getProperty("sonatypeStagingProfileId", "") }) + username.set(ossrhUsername.ifEmpty { localProperties.getProperty("ossrhUsername", "") }) + password.set(ossrhPassword.ifEmpty { localProperties.getProperty("ossrhPassword", "") }) + nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) + snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) + } + } +} diff --git a/docs/examples/java/advisor/get-insight.md b/docs/examples/java/advisor/get-insight.md new file mode 100644 index 00000000..9ca79e42 --- /dev/null +++ b/docs/examples/java/advisor/get-insight.md @@ -0,0 +1,25 @@ +```java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Advisor; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Advisor advisor = new Advisor(client); + +advisor.getInsight( + "", // reportId + "", // insightId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/advisor/get-report.md b/docs/examples/java/advisor/get-report.md new file mode 100644 index 00000000..6382cd33 --- /dev/null +++ b/docs/examples/java/advisor/get-report.md @@ -0,0 +1,24 @@ +```java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Advisor; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Advisor advisor = new Advisor(client); + +advisor.getReport( + "", // reportId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/advisor/list-insights.md b/docs/examples/java/advisor/list-insights.md new file mode 100644 index 00000000..dd18ce82 --- /dev/null +++ b/docs/examples/java/advisor/list-insights.md @@ -0,0 +1,26 @@ +```java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Advisor; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Advisor advisor = new Advisor(client); + +advisor.listInsights( + "", // reportId + 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/advisor/list-reports.md b/docs/examples/java/advisor/list-reports.md new file mode 100644 index 00000000..14f5b89b --- /dev/null +++ b/docs/examples/java/advisor/list-reports.md @@ -0,0 +1,25 @@ +```java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Advisor; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Advisor advisor = new Advisor(client); + +advisor.listReports( + 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/presences/delete.md b/docs/examples/java/presences/delete.md new file mode 100644 index 00000000..04dace36 --- /dev/null +++ b/docs/examples/java/presences/delete.md @@ -0,0 +1,24 @@ +```java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Presences; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Presences presences = new Presences(client); + +presences.delete( + "", // presenceId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/presences/get.md b/docs/examples/java/presences/get.md new file mode 100644 index 00000000..8417a566 --- /dev/null +++ b/docs/examples/java/presences/get.md @@ -0,0 +1,24 @@ +```java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Presences; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Presences presences = new Presences(client); + +presences.get( + "", // presenceId + 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 new file mode 100644 index 00000000..e23f49a2 --- /dev/null +++ b/docs/examples/java/presences/list.md @@ -0,0 +1,26 @@ +```java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Presences; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Presences presences = new Presences(client); + +presences.list( + List.of(), // queries (optional) + false, // total (optional) + 0, // ttl (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/presences/update.md b/docs/examples/java/presences/update.md new file mode 100644 index 00000000..b38a10f0 --- /dev/null +++ b/docs/examples/java/presences/update.md @@ -0,0 +1,31 @@ +```java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.Permission; +import io.appwrite.Role; +import io.appwrite.services.Presences; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Presences presences = new Presences(client); + +presences.update( + "", // presenceId + "", // status (optional) + "2020-10-15T06:38:00.000+00:00", // expiresAt (optional) + Map.of("a", "b"), // metadata (optional) + List.of(Permission.read(Role.any())), // permissions (optional) + false, // purge (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/java/presences/upsert.md b/docs/examples/java/presences/upsert.md new file mode 100644 index 00000000..3e1ef96b --- /dev/null +++ b/docs/examples/java/presences/upsert.md @@ -0,0 +1,30 @@ +```java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.Permission; +import io.appwrite.Role; +import io.appwrite.services.Presences; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Presences presences = new Presences(client); + +presences.upsert( + "", // presenceId + "", // status + List.of(Permission.read(Role.any())), // permissions (optional) + "2020-10-15T06:38:00.000+00:00", // expiresAt (optional) + Map.of("a", "b"), // metadata (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); + +``` diff --git a/docs/examples/kotlin/advisor/get-insight.md b/docs/examples/kotlin/advisor/get-insight.md new file mode 100644 index 00000000..6ff14eb9 --- /dev/null +++ b/docs/examples/kotlin/advisor/get-insight.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Advisor + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val advisor = Advisor(client) + +val result = advisor.getInsight( + reportId = "", + insightId = "", +) +``` diff --git a/docs/examples/kotlin/advisor/get-report.md b/docs/examples/kotlin/advisor/get-report.md new file mode 100644 index 00000000..5ab03055 --- /dev/null +++ b/docs/examples/kotlin/advisor/get-report.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Advisor + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val advisor = Advisor(client) + +val result = advisor.getReport( + reportId = "", +) +``` diff --git a/docs/examples/kotlin/advisor/list-insights.md b/docs/examples/kotlin/advisor/list-insights.md new file mode 100644 index 00000000..fb5dfb31 --- /dev/null +++ b/docs/examples/kotlin/advisor/list-insights.md @@ -0,0 +1,17 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Advisor + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val advisor = Advisor(client) + +val result = advisor.listInsights( + reportId = "", + queries = listOf(), // (optional) + total = false, // (optional) +) +``` diff --git a/docs/examples/kotlin/advisor/list-reports.md b/docs/examples/kotlin/advisor/list-reports.md new file mode 100644 index 00000000..9d230d37 --- /dev/null +++ b/docs/examples/kotlin/advisor/list-reports.md @@ -0,0 +1,16 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Advisor + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val advisor = Advisor(client) + +val result = advisor.listReports( + queries = listOf(), // (optional) + total = false, // (optional) +) +``` diff --git a/docs/examples/kotlin/presences/delete.md b/docs/examples/kotlin/presences/delete.md new file mode 100644 index 00000000..d74aae00 --- /dev/null +++ b/docs/examples/kotlin/presences/delete.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Presences + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val presences = Presences(client) + +val result = presences.delete( + presenceId = "", +) +``` diff --git a/docs/examples/kotlin/presences/get.md b/docs/examples/kotlin/presences/get.md new file mode 100644 index 00000000..c12a5182 --- /dev/null +++ b/docs/examples/kotlin/presences/get.md @@ -0,0 +1,15 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Presences + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val presences = Presences(client) + +val result = presences.get( + presenceId = "", +) +``` diff --git a/docs/examples/kotlin/presences/list.md b/docs/examples/kotlin/presences/list.md new file mode 100644 index 00000000..6496a3e2 --- /dev/null +++ b/docs/examples/kotlin/presences/list.md @@ -0,0 +1,17 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Presences + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val presences = Presences(client) + +val result = presences.list( + queries = listOf(), // (optional) + total = false, // (optional) + ttl = 0, // (optional) +) +``` diff --git a/docs/examples/kotlin/presences/update.md b/docs/examples/kotlin/presences/update.md new file mode 100644 index 00000000..c6626913 --- /dev/null +++ b/docs/examples/kotlin/presences/update.md @@ -0,0 +1,22 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Presences +import io.appwrite.Permission +import io.appwrite.Role + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val presences = Presences(client) + +val result = presences.update( + presenceId = "", + status = "", // (optional) + expiresAt = "2020-10-15T06:38:00.000+00:00", // (optional) + metadata = mapOf( "a" to "b" ), // (optional) + permissions = listOf(Permission.read(Role.any())), // (optional) + purge = false, // (optional) +) +``` diff --git a/docs/examples/kotlin/presences/upsert.md b/docs/examples/kotlin/presences/upsert.md new file mode 100644 index 00000000..d868a1dd --- /dev/null +++ b/docs/examples/kotlin/presences/upsert.md @@ -0,0 +1,21 @@ +```kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Presences +import io.appwrite.Permission +import io.appwrite.Role + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val presences = Presences(client) + +val result = presences.upsert( + presenceId = "", + status = "", + permissions = listOf(Permission.read(Role.any())), // (optional) + expiresAt = "2020-10-15T06:38:00.000+00:00", // (optional) + metadata = mapOf( "a" to "b" ), // (optional) +) +``` diff --git a/example/build.gradle b/example/build.gradle deleted file mode 100644 index 0ebf8b28..00000000 --- a/example/build.gradle +++ /dev/null @@ -1,65 +0,0 @@ -plugins { - id 'com.android.application' - id 'kotlin-android' - id 'kotlin-kapt' -} - -android { - namespace "io.appwrite.android" - - compileSdkVersion 34 - - defaultConfig { - applicationId "io.appwrite.android" - minSdkVersion 21 - targetSdkVersion 34 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildFeatures { - dataBinding true - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } -} - -dependencies { - implementation(project(path: ':library')) - - implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version") - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.11.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.navigation:navigation-fragment-ktx:2.7.7") - implementation("androidx.fragment:fragment-ktx:1.6.2") - implementation("androidx.navigation:navigation-ui-ktx:2.7.7") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") - implementation("androidx.navigation:navigation-fragment-ktx:2.7.7") - implementation("androidx.navigation:navigation-ui-ktx:2.7.7") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1") - - implementation(platform("com.google.firebase:firebase-bom:32.7.0")) - implementation("com.google.firebase:firebase-messaging") - - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") -} \ No newline at end of file diff --git a/example/build.gradle.kts b/example/build.gradle.kts new file mode 100644 index 00000000..2675a918 --- /dev/null +++ b/example/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "io.appwrite.android" + + compileSdk = 36 + + defaultConfig { + applicationId = "io.appwrite.android" + minSdk = 21 + @Suppress("DEPRECATION") + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + dataBinding = true + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(project(":library")) + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.2.0") + implementation("androidx.navigation:navigation-fragment-ktx:2.8.5") + implementation("androidx.fragment:fragment-ktx:1.8.5") + implementation("androidx.navigation:navigation-ui-ktx:2.8.5") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + + implementation(platform("com.google.firebase:firebase-bom:33.7.0")) + implementation("com.google.firebase:firebase-messaging") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ebd754f0..2f2958b9 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Tue Jun 01 15:55:54 IST 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/library/build.gradle b/library/build.gradle deleted file mode 100644 index cc21f310..00000000 --- a/library/build.gradle +++ /dev/null @@ -1,80 +0,0 @@ -plugins { - id("com.android.library") - id("kotlin-android") -} - -ext { - PUBLISH_GROUP_ID = 'io.appwrite' - PUBLISH_ARTIFACT_ID = 'sdk-for-android' - PUBLISH_VERSION = System.getenv('SDK_VERSION') - POM_URL = 'https://github.com/appwrite/sdk-for-android' - POM_SCM_URL = 'https://github.com/appwrite/sdk-for-android' - POM_ISSUE_URL = 'https://github.com/appwrite/sdk-for-android/issues' - POM_DESCRIPTION = 'Appwrite is an open-source backend as a service server that abstracts and simplifies complex and repetitive development tasks behind a very simple to use REST API. Appwrite aims to help you develop your apps faster and in a more secure way. Use the Android SDK to integrate your app with the Appwrite server to easily start interacting with all of Appwrite backend APIs and tools. For full API documentation and tutorials go to [https://appwrite.io/docs](https://appwrite.io/docs)' - POM_LICENSE_URL = 'https://opensource.org/licenses/GPL-3.0' - POM_LICENSE_NAME = "GPL-3.0" - POM_DEVELOPER_ID = 'appwrite' - POM_DEVELOPER_NAME = 'Appwrite Team' - POM_DEVELOPER_EMAIL = 'team@appwrite.io' - GITHUB_SCM_CONNECTION = 'scm:git:git://github.com/appwrite/sdk-for-android.git' -} - -version PUBLISH_VERSION - -android { - namespace PUBLISH_GROUP_ID - - compileSdkVersion(34) - - buildFeatures { - buildConfig true - } - - defaultConfig { - minSdkVersion(21) - targetSdkVersion(34) - versionCode = 1 - versionName = "1.0" - buildConfigField "String", "SDK_VERSION", "\"${PUBLISH_VERSION}\"" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - minifyEnabled false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - kotlinOptions { - jvmTarget = "1.8" - } -} - -dependencies { - implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version") - api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") - api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1") - - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("com.google.code.gson:gson:2.10.1") - - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") - implementation("androidx.lifecycle:lifecycle-common-java8:2.7.0") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("androidx.fragment:fragment-ktx:1.6.2") - implementation("androidx.activity:activity-ktx:1.8.2") - implementation("androidx.browser:browser:1.7.0") - implementation("androidx.core:core-ktx:1.12.0") - - testImplementation("junit:junit:4.13.2") - testImplementation("androidx.test.ext:junit-ktx:1.1.5") - testImplementation("androidx.test:core-ktx:1.5.0") - testImplementation("org.robolectric:robolectric:4.11.1") - testApi("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1") -} - -apply from: "${rootProject.projectDir}/scripts/publish-module.gradle" diff --git a/library/build.gradle.kts b/library/build.gradle.kts new file mode 100644 index 00000000..68c3d398 --- /dev/null +++ b/library/build.gradle.kts @@ -0,0 +1,143 @@ +import java.util.Properties + +plugins { + id("com.android.library") + `maven-publish` + signing +} + +val publishGroupId = "io.appwrite" +val publishArtifactId = "sdk-for-android" +val publishVersion: String = System.getenv("SDK_VERSION") ?: "0.0.0" +val pomUrl = "https://github.com/appwrite/sdk-for-android" +val pomScmUrl = "https://github.com/appwrite/sdk-for-android" +val pomDescription = "Appwrite is an open-source backend as a service server that abstracts and simplifies complex and repetitive development tasks behind a very simple to use REST API. Appwrite aims to help you develop your apps faster and in a more secure way. Use the Android SDK to integrate your app with the Appwrite server to easily start interacting with all of Appwrite backend APIs and tools. For full API documentation and tutorials go to [https://appwrite.io/docs](https://appwrite.io/docs)" +val pomLicenseUrl = "https://opensource.org/licenses/GPL-3.0" +val pomLicenseName = "GPL-3.0" +val pomDeveloperId = "appwrite" +val pomDeveloperName = "Appwrite Team" +val pomDeveloperEmail = "team@appwrite.io" +val githubScmConnection = "scm:git:git://github.com/appwrite/sdk-for-android.git" + +version = publishVersion + +val signingKeyId: String = System.getenv("SIGNING_KEY_ID") ?: "" +val signingPassword: String = System.getenv("SIGNING_PASSWORD") ?: "" +val signingSecretKeyRingFile: String = System.getenv("SIGNING_SECRET_KEY_RING_FILE") ?: "" + +val secretPropsFile = project.rootProject.file("local.properties") +val localProperties = Properties().apply { + if (secretPropsFile.exists()) { + secretPropsFile.inputStream().use { load(it) } + } +} + +extra["signing.keyId"] = signingKeyId.ifEmpty { localProperties.getProperty("signing.keyId", "") } +extra["signing.password"] = signingPassword.ifEmpty { localProperties.getProperty("signing.password", "") } +extra["signing.secretKeyRingFile"] = signingSecretKeyRingFile.ifEmpty { localProperties.getProperty("signing.secretKeyRingFile", "") } + +android { + namespace = publishGroupId + + compileSdk = 36 + + buildFeatures { + buildConfig = true + } + + defaultConfig { + minSdk = 21 + buildConfigField("String", "SDK_VERSION", "\"$publishVersion\"") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + testOptions { + unitTests.all { + it.systemProperty("robolectric.conscryptMode", "OFF") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} + +dependencies { + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + + api("com.squareup.okhttp3:okhttp:5.3.2") + implementation("com.google.code.gson:gson:2.11.0") + + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-common-java8:2.8.7") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.fragment:fragment-ktx:1.8.5") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.browser:browser:1.8.0") + implementation("androidx.core:core-ktx:1.15.0") + + testImplementation("junit:junit:4.13.2") + testImplementation("androidx.test.ext:junit-ktx:1.2.1") + testImplementation("androidx.test:core-ktx:1.6.1") + testImplementation("org.robolectric:robolectric:4.16.1") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") +} + +afterEvaluate { + publishing { + publications { + register("release") { + from(components["release"]) + + groupId = publishGroupId + artifactId = publishArtifactId + version = publishVersion + + pom { + name.set(publishArtifactId) + description.set(pomDescription) + url.set(pomUrl) + + licenses { + license { + name.set(pomLicenseName) + url.set(pomLicenseUrl) + } + } + + developers { + developer { + id.set(pomDeveloperId) + name.set(pomDeveloperName) + email.set(pomDeveloperEmail) + } + } + + scm { + connection.set(githubScmConnection) + url.set(pomScmUrl) + } + } + } + } + } + + signing { + sign(publishing.publications) + } +} diff --git a/library/src/main/java/io/appwrite/Channel.kt b/library/src/main/java/io/appwrite/Channel.kt index 5c067016..26907f28 100644 --- a/library/src/main/java/io/appwrite/Channel.kt +++ b/library/src/main/java/io/appwrite/Channel.kt @@ -14,6 +14,7 @@ public interface _Func public interface _Execution public interface _Team public interface _Membership +public interface _Presence public interface _Resolved // Union type for actionable channels @@ -81,6 +82,9 @@ class Channel private constructor( fun membership(id: String): Channel<_Membership> = Channel(listOf("memberships", normalize(id))) + fun presence(id: String): Channel<_Presence> = + Channel(listOf("presences", normalize(id))) + fun account(): String = "account" // Global events @@ -90,6 +94,7 @@ class Channel private constructor( fun executions(): String = "executions" fun teams(): String = "teams" fun memberships(): String = "memberships" + fun presences(): String = "presences" } } @@ -251,3 +256,31 @@ fun Channel<_Membership>.update(): Channel<_Resolved> = @JvmName("deleteMembership") fun Channel<_Membership>.delete(): Channel<_Resolved> = this.resolve("delete") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("createPresence") +fun Channel<_Presence>.create(): Channel<_Resolved> = + this.resolve("create") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("upsertPresence") +fun Channel<_Presence>.upsert(): Channel<_Resolved> = + this.resolve("upsert") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("updatePresence") +fun Channel<_Presence>.update(): Channel<_Resolved> = + this.resolve("update") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("deletePresence") +fun Channel<_Presence>.delete(): Channel<_Resolved> = + this.resolve("delete") diff --git a/library/src/main/java/io/appwrite/Client.kt b/library/src/main/java/io/appwrite/Client.kt index ccd93894..0fbb47a4 100644 --- a/library/src/main/java/io/appwrite/Client.kt +++ b/library/src/main/java/io/appwrite/Client.kt @@ -87,8 +87,8 @@ class Client @JvmOverloads constructor( "x-sdk-name" to "Android", "x-sdk-platform" to "client", "x-sdk-language" to "android", - "x-sdk-version" to "24.0.0", - "x-appwrite-response-format" to "1.9.2" + "x-sdk-version" to "24.1.0", + "x-appwrite-response-format" to "1.9.5" ) config = mutableMapOf() @@ -168,6 +168,21 @@ class Client @JvmOverloads constructor( return this } + /** + * Set Cookie + * + * The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes. + * + * @param {string} cookie + * + * @return this + */ + fun setCookie(value: String): Client { + config["cookie"] = value + addHeader("cookie", value) + return this + } + /** * Set ImpersonateUserId * diff --git a/library/src/main/java/io/appwrite/cookies/ListenableCookieJar.kt b/library/src/main/java/io/appwrite/cookies/ListenableCookieJar.kt index 863eb42e..d88a13a2 100644 --- a/library/src/main/java/io/appwrite/cookies/ListenableCookieJar.kt +++ b/library/src/main/java/io/appwrite/cookies/ListenableCookieJar.kt @@ -1,21 +1,25 @@ package io.appwrite.cookies +import android.util.Log import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl -import okhttp3.internal.cookieToString -import okhttp3.internal.delimiterOffset -import okhttp3.internal.platform.Platform -import okhttp3.internal.trimSubstring import java.io.IOException import java.net.CookieHandler -import java.net.HttpCookie +import java.text.SimpleDateFormat import java.util.Collections +import java.util.Date +import java.util.Locale +import java.util.TimeZone typealias CookieListener = (existing: List, new: List) -> Unit class ListenableCookieJar(private val cookieHandler: CookieHandler) : CookieJar { + private companion object { + private const val TAG = "ListenableCookieJar" + } + private val listeners: MutableMap = mutableMapOf() fun onSave(key: String, listener: CookieListener) { @@ -29,16 +33,13 @@ class ListenableCookieJar(private val cookieHandler: CookieHandler) : CookieJar val cookieStrings = mutableListOf() for (cookie in cookies) { - cookieStrings.add(cookieToString(cookie, true)) + cookieStrings.add(cookieToString(cookie)) } val multimap = mapOf("Set-Cookie" to cookieStrings) try { cookieHandler.put(url.toUri(), multimap) } catch (e: IOException) { - Platform.get().log( - "Saving cookies failed for " + url.resolve("/...")!!, - Platform.WARN, e - ) + Log.w(TAG, "Saving cookies failed for " + url.resolve("/..."), e) } } @@ -46,10 +47,7 @@ class ListenableCookieJar(private val cookieHandler: CookieHandler) : CookieJar val cookieHeaders = try { cookieHandler.get(url.toUri(), emptyMap>()) } catch (e: IOException) { - Platform.get().log( - "Loading cookies failed for " + url.resolve("/...")!!, - Platform.WARN, e - ) + Log.w(TAG, "Loading cookies failed for " + url.resolve("/..."), e) return emptyList() } @@ -75,10 +73,6 @@ class ListenableCookieJar(private val cookieHandler: CookieHandler) : CookieJar } } - /** - * Convert a request header to OkHttp's cookies via [HttpCookie]. That extra step handles - * multiple cookies in a single request header, which [Cookie.parse] doesn't support. - */ private fun decodeHeaderAsJavaNetCookies(url: HttpUrl, header: String): List { val result = mutableListOf() var pos = 0 @@ -93,14 +87,12 @@ class ListenableCookieJar(private val cookieHandler: CookieHandler) : CookieJar continue } - // We have either name=value or just a name. var value = if (equalsSign < pairEnd) { header.trimSubstring(equalsSign + 1, pairEnd) } else { "" } - // If the value is "quoted", drop the quotes. if (value.startsWith("\"") && value.endsWith("\"")) { value = value.substring(1, value.length - 1) } @@ -116,4 +108,55 @@ class ListenableCookieJar(private val cookieHandler: CookieHandler) : CookieJar } return result } -} \ No newline at end of file + + private fun cookieToString(cookie: Cookie): String = buildString { + append(cookie.name) + append('=') + append(cookie.value) + + if (cookie.persistent) { + if (cookie.expiresAt == Long.MIN_VALUE) { + append("; Max-Age=0") + } else { + append("; Expires=").append(formatHttpDate(cookie.expiresAt)) + } + } + + if (!cookie.hostOnly) { + // Leading "." marks the cookie as subdomain-matching per RFC 2965, which java.net.CookieHandler still relies on. + append("; Domain=.").append(cookie.domain) + } + + append("; Path=").append(cookie.path) + + if (cookie.secure) append("; Secure") + if (cookie.httpOnly) append("; HttpOnly") + } + + private fun formatHttpDate(epochMillis: Long): String = + SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + }.format(Date(epochMillis)) + + private fun String.delimiterOffset(delimiters: String, startIndex: Int, endIndex: Int): Int { + for (i in startIndex until endIndex) { + if (this[i] in delimiters) return i + } + return endIndex + } + + private fun String.delimiterOffset(delimiter: Char, startIndex: Int, endIndex: Int): Int { + for (i in startIndex until endIndex) { + if (this[i] == delimiter) return i + } + return endIndex + } + + private fun String.trimSubstring(startIndex: Int, endIndex: Int): String { + var start = startIndex + var end = endIndex + while (start < end && this[start].isWhitespace()) start++ + while (end > start && this[end - 1].isWhitespace()) end-- + return substring(start, end) + } +} diff --git a/library/src/main/java/io/appwrite/enums/OAuthProvider.kt b/library/src/main/java/io/appwrite/enums/OAuthProvider.kt index f5f3d39f..b5de3a4c 100644 --- a/library/src/main/java/io/appwrite/enums/OAuthProvider.kt +++ b/library/src/main/java/io/appwrite/enums/OAuthProvider.kt @@ -33,12 +33,18 @@ enum class OAuthProvider(val value: String) { FACEBOOK("facebook"), @SerializedName("figma") FIGMA("figma"), + @SerializedName("fusionauth") + FUSIONAUTH("fusionauth"), @SerializedName("github") GITHUB("github"), @SerializedName("gitlab") GITLAB("gitlab"), @SerializedName("google") GOOGLE("google"), + @SerializedName("keycloak") + KEYCLOAK("keycloak"), + @SerializedName("kick") + KICK("kick"), @SerializedName("linkedin") LINKEDIN("linkedin"), @SerializedName("microsoft") diff --git a/library/src/main/java/io/appwrite/models/Document.kt b/library/src/main/java/io/appwrite/models/Document.kt index c55e6d26..a192a1dd 100644 --- a/library/src/main/java/io/appwrite/models/Document.kt +++ b/library/src/main/java/io/appwrite/models/Document.kt @@ -99,7 +99,7 @@ data class Document( createdAt = map["\$createdAt"] as String, updatedAt = map["\$updatedAt"] as String, permissions = map["\$permissions"] as List, - data = map["data"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) + data = map["data"]?.jsonCast(to = nestedType) ?: emptyMap().jsonCast(to = nestedType) ) } } \ No newline at end of file diff --git a/library/src/main/java/io/appwrite/models/Insight.kt b/library/src/main/java/io/appwrite/models/Insight.kt new file mode 100644 index 00000000..66c2804a --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Insight.kt @@ -0,0 +1,158 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * Insight + */ +data class Insight( + /** + * Insight ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * Insight creation date in ISO 8601 format. + */ + @SerializedName("\$createdAt") + val createdAt: String, + + /** + * Insight update date in ISO 8601 format. + */ + @SerializedName("\$updatedAt") + val updatedAt: String, + + /** + * Parent report ID. Insights always belong to a report. + */ + @SerializedName("reportId") + val reportId: String, + + /** + * Insight type. One of databaseIndex (legacy), tablesDBIndex, documentsDBIndex, vectorsDBIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance. The index types are engine-specific so each CTA can pair the right service+method (databases.createIndex, tablesDB.createIndex, documentsDB.createIndex, or vectorsDB.createIndex). + */ + @SerializedName("type") + val type: String, + + /** + * Insight severity. One of info, warning, critical. + */ + @SerializedName("severity") + val severity: String, + + /** + * Insight status. One of active, dismissed. + */ + @SerializedName("status") + val status: String, + + /** + * Type of the resource the insight is about. Plural noun, e.g. databases, sites, functions. + */ + @SerializedName("resourceType") + val resourceType: String, + + /** + * ID of the resource the insight is about. + */ + @SerializedName("resourceId") + val resourceId: String, + + /** + * Plural noun for the parent resource that contains the insight's resource, e.g. an insight about a column index on a table → resourceType=indexes, parentResourceType=tables. Empty when the resource has no parent. + */ + @SerializedName("parentResourceType") + val parentResourceType: String, + + /** + * ID of the parent resource. Empty when the resource has no parent. + */ + @SerializedName("parentResourceId") + val parentResourceId: String, + + /** + * Insight title. + */ + @SerializedName("title") + val title: String, + + /** + * Short markdown summary describing the insight. + */ + @SerializedName("summary") + val summary: String, + + /** + * List of call-to-action buttons attached to this insight. + */ + @SerializedName("ctas") + val ctas: List, + + /** + * Time the insight was analyzed in ISO 8601 format. + */ + @SerializedName("analyzedAt") + var analyzedAt: String?, + + /** + * Time the insight was dismissed in ISO 8601 format. Empty when not dismissed. + */ + @SerializedName("dismissedAt") + var dismissedAt: String?, + + /** + * User ID that dismissed the insight. Empty when not dismissed. + */ + @SerializedName("dismissedBy") + var dismissedBy: String?, + +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + "\$createdAt" to createdAt as Any, + "\$updatedAt" to updatedAt as Any, + "reportId" to reportId as Any, + "type" to type as Any, + "severity" to severity as Any, + "status" to status as Any, + "resourceType" to resourceType as Any, + "resourceId" to resourceId as Any, + "parentResourceType" to parentResourceType as Any, + "parentResourceId" to parentResourceId as Any, + "title" to title as Any, + "summary" to summary as Any, + "ctas" to ctas.map { it.toMap() } as Any, + "analyzedAt" to analyzedAt as Any, + "dismissedAt" to dismissedAt as Any, + "dismissedBy" to dismissedBy as Any, + ) + + companion object { + + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Insight( + id = map["\$id"] as String, + createdAt = map["\$createdAt"] as String, + updatedAt = map["\$updatedAt"] as String, + reportId = map["reportId"] as String, + type = map["type"] as String, + severity = map["severity"] as String, + status = map["status"] as String, + resourceType = map["resourceType"] as String, + resourceId = map["resourceId"] as String, + parentResourceType = map["parentResourceType"] as String, + parentResourceId = map["parentResourceId"] as String, + title = map["title"] as String, + summary = map["summary"] as String, + ctas = (map["ctas"] as List>).map { InsightCTA.from(map = it) }, + analyzedAt = map["analyzedAt"] as? String, + dismissedAt = map["dismissedAt"] as? String, + dismissedBy = map["dismissedBy"] as? String, + ) + } +} \ No newline at end of file diff --git a/library/src/main/java/io/appwrite/models/InsightCTA.kt b/library/src/main/java/io/appwrite/models/InsightCTA.kt new file mode 100644 index 00000000..e2d889f9 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/InsightCTA.kt @@ -0,0 +1,54 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * InsightCTA + */ +data class InsightCTA( + /** + * Human-readable label for the CTA, used in UI. + */ + @SerializedName("label") + val label: String, + + /** + * Public API service (SDK namespace) the client should invoke. Must match the engine that owns the resource — for index suggestions: databases (legacy), tablesDB, documentsDB, or vectorsDB. + */ + @SerializedName("service") + val service: String, + + /** + * Public API method on the chosen service the client should invoke when this CTA is triggered. + */ + @SerializedName("method") + val method: String, + + /** + * Parameter map the client should pass to the service method when this CTA is triggered. Keys match the target API's parameter names (e.g. databaseId/tableId/columns for tablesDB, databaseId/collectionId/attributes for the legacy Databases API). + */ + @SerializedName("params") + val params: Any, + +) { + fun toMap(): Map = mapOf( + "label" to label as Any, + "service" to service as Any, + "method" to method as Any, + "params" to params as Any, + ) + + companion object { + + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = InsightCTA( + label = map["label"] as String, + service = map["service"] as String, + method = map["method"] as String, + params = map["params"] as Any, + ) + } +} \ No newline at end of file diff --git a/library/src/main/java/io/appwrite/models/InsightList.kt b/library/src/main/java/io/appwrite/models/InsightList.kt new file mode 100644 index 00000000..e04aa3fd --- /dev/null +++ b/library/src/main/java/io/appwrite/models/InsightList.kt @@ -0,0 +1,38 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * Insights List + */ +data class InsightList( + /** + * Total number of insights that matched your query. + */ + @SerializedName("total") + val total: Long, + + /** + * List of insights. + */ + @SerializedName("insights") + val insights: List, + +) { + fun toMap(): Map = mapOf( + "total" to total as Any, + "insights" to insights.map { it.toMap() } as Any, + ) + + companion object { + + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = InsightList( + total = (map["total"] as Number).toLong(), + insights = (map["insights"] as List>).map { Insight.from(map = it) }, + ) + } +} \ No newline at end of file diff --git a/library/src/main/java/io/appwrite/models/Presence.kt b/library/src/main/java/io/appwrite/models/Presence.kt new file mode 100644 index 00000000..d62ddc12 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Presence.kt @@ -0,0 +1,115 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * Presence + */ +data class Presence( + /** + * Presence ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * Presence creation date in ISO 8601 format. + */ + @SerializedName("\$createdAt") + val createdAt: String, + + /** + * Presence update date in ISO 8601 format. + */ + @SerializedName("\$updatedAt") + val updatedAt: String, + + /** + * Presence permissions. [Learn more about permissions](https://appwrite.io/docs/permissions). + */ + @SerializedName("\$permissions") + val permissions: List, + + /** + * User ID. + */ + @SerializedName("userId") + val userId: String, + + /** + * Presence status. + */ + @SerializedName("status") + var status: String?, + + /** + * Presence source. + */ + @SerializedName("source") + val source: String, + + /** + * Presence expiry date in ISO 8601 format. + */ + @SerializedName("expiresAt") + var expiresAt: String?, + + /** + * Additional properties + */ + @SerializedName("metadata") + val metadata: T +) { + fun toMap(): Map = mapOf( + "\$id" to id as Any, + "\$createdAt" to createdAt as Any, + "\$updatedAt" to updatedAt as Any, + "\$permissions" to permissions as Any, + "userId" to userId as Any, + "status" to status as Any, + "source" to source as Any, + "expiresAt" to expiresAt as Any, + "metadata" to metadata!!.jsonCast(to = Map::class.java) + ) + + companion object { + operator fun invoke( + id: String, + createdAt: String, + updatedAt: String, + permissions: List, + userId: String, + status: String?, + source: String, + expiresAt: String?, + metadata: Map + ) = Presence>( + id, + createdAt, + updatedAt, + permissions, + userId, + status, + source, + expiresAt, + metadata + ) + + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + nestedType: Class + ) = Presence( + id = map["\$id"] as String, + createdAt = map["\$createdAt"] as String, + updatedAt = map["\$updatedAt"] as String, + permissions = map["\$permissions"] as List, + userId = map["userId"] as String, + status = map["status"] as? String, + source = map["source"] as String, + expiresAt = map["expiresAt"] as? String, + metadata = map["metadata"]?.jsonCast(to = nestedType) ?: emptyMap().jsonCast(to = nestedType) + ) + } +} \ No newline at end of file diff --git a/library/src/main/java/io/appwrite/models/PresenceList.kt b/library/src/main/java/io/appwrite/models/PresenceList.kt new file mode 100644 index 00000000..00d2ac14 --- /dev/null +++ b/library/src/main/java/io/appwrite/models/PresenceList.kt @@ -0,0 +1,46 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * Presences List + */ +data class PresenceList( + /** + * Total number of presences that matched your query. + */ + @SerializedName("total") + val total: Long, + + /** + * List of presences. + */ + @SerializedName("presences") + val presences: List>, + +) { + fun toMap(): Map = mapOf( + "total" to total as Any, + "presences" to presences.map { it.toMap() } as Any, + ) + + companion object { + operator fun invoke( + total: Long, + presences: List>>, + ) = PresenceList>( + total, + presences, + ) + + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + nestedType: Class + ) = PresenceList( + total = (map["total"] as Number).toLong(), + presences = (map["presences"] as List>).map { Presence.from(map = it, nestedType) }, + ) + } +} \ No newline at end of file diff --git a/library/src/main/java/io/appwrite/models/Report.kt b/library/src/main/java/io/appwrite/models/Report.kt new file mode 100644 index 00000000..0e115e5e --- /dev/null +++ b/library/src/main/java/io/appwrite/models/Report.kt @@ -0,0 +1,118 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * Report + */ +data class Report( + /** + * Report ID. + */ + @SerializedName("\$id") + val id: String, + + /** + * Report creation date in ISO 8601 format. + */ + @SerializedName("\$createdAt") + val createdAt: String, + + /** + * Report update date in ISO 8601 format. + */ + @SerializedName("\$updatedAt") + val updatedAt: String, + + /** + * ID of the third-party app that submitted the report. + */ + @SerializedName("appId") + val appId: String, + + /** + * Analyzer that produced this report. e.g. lighthouse, audit, databaseAnalyzer. + */ + @SerializedName("type") + val type: String, + + /** + * Short, human-readable title for the report. + */ + @SerializedName("title") + val title: String, + + /** + * Markdown summary describing the report. + */ + @SerializedName("summary") + val summary: String, + + /** + * Plural noun describing what the report analyzes, e.g. databases, sites, urls. + */ + @SerializedName("targetType") + val targetType: String, + + /** + * Free-form target identifier (URL for lighthouse, resource ID for db). + */ + @SerializedName("target") + val target: String, + + /** + * Categories covered by the report, e.g. performance, accessibility. + */ + @SerializedName("categories") + val categories: List, + + /** + * Insights nested under this report. + */ + @SerializedName("insights") + val insights: List, + + /** + * Time the report was analyzed in ISO 8601 format. + */ + @SerializedName("analyzedAt") + var analyzedAt: 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, + "type" to type as Any, + "title" to title as Any, + "summary" to summary as Any, + "targetType" to targetType as Any, + "target" to target as Any, + "categories" to categories as Any, + "insights" to insights.map { it.toMap() } as Any, + "analyzedAt" to analyzedAt as Any, + ) + + companion object { + + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = Report( + id = map["\$id"] as String, + createdAt = map["\$createdAt"] as String, + updatedAt = map["\$updatedAt"] as String, + appId = map["appId"] as String, + type = map["type"] as String, + title = map["title"] as String, + summary = map["summary"] as String, + targetType = map["targetType"] as String, + target = map["target"] as String, + categories = map["categories"] as List, + insights = (map["insights"] as List>).map { Insight.from(map = it) }, + analyzedAt = map["analyzedAt"] as? String, + ) + } +} \ No newline at end of file diff --git a/library/src/main/java/io/appwrite/models/ReportList.kt b/library/src/main/java/io/appwrite/models/ReportList.kt new file mode 100644 index 00000000..a528a10a --- /dev/null +++ b/library/src/main/java/io/appwrite/models/ReportList.kt @@ -0,0 +1,38 @@ +package io.appwrite.models + +import com.google.gson.annotations.SerializedName +import io.appwrite.extensions.jsonCast + +/** + * Reports List + */ +data class ReportList( + /** + * Total number of reports that matched your query. + */ + @SerializedName("total") + val total: Long, + + /** + * List of reports. + */ + @SerializedName("reports") + val reports: List, + +) { + fun toMap(): Map = mapOf( + "total" to total as Any, + "reports" to reports.map { it.toMap() } as Any, + ) + + companion object { + + @Suppress("UNCHECKED_CAST") + fun from( + map: Map, + ) = ReportList( + total = (map["total"] as Number).toLong(), + reports = (map["reports"] as List>).map { Report.from(map = it) }, + ) + } +} \ No newline at end of file diff --git a/library/src/main/java/io/appwrite/models/Row.kt b/library/src/main/java/io/appwrite/models/Row.kt index 488ec7bc..c01672e7 100644 --- a/library/src/main/java/io/appwrite/models/Row.kt +++ b/library/src/main/java/io/appwrite/models/Row.kt @@ -99,7 +99,7 @@ data class Row( createdAt = map["\$createdAt"] as String, updatedAt = map["\$updatedAt"] as String, permissions = map["\$permissions"] as List, - data = map["data"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) + data = map["data"]?.jsonCast(to = nestedType) ?: emptyMap().jsonCast(to = nestedType) ) } } \ No newline at end of file diff --git a/library/src/main/java/io/appwrite/services/Account.kt b/library/src/main/java/io/appwrite/services/Account.kt index bb9bb4fd..6e2244c3 100644 --- a/library/src/main/java/io/appwrite/services/Account.kt +++ b/library/src/main/java/io/appwrite/services/Account.kt @@ -1456,7 +1456,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, auth0, authentik, autodesk, bitbucket, bitly, box, dailymotion, discord, disqus, dropbox, etsy, facebook, figma, github, gitlab, google, linkedin, microsoft, notion, oidc, okta, paypal, paypalSandbox, podio, salesforce, slack, spotify, stripe, tradeshift, tradeshiftBox, twitch, wordpress, x, yahoo, yammer, yandex, zoho, zoom. + * @param provider OAuth2 Provider. Currently, supported providers are: amazon, apple, auth0, authentik, autodesk, bitbucket, bitly, box, dailymotion, discord, disqus, dropbox, etsy, facebook, figma, fusionauth, github, gitlab, google, keycloak, kick, linkedin, microsoft, notion, oidc, okta, paypal, paypalSandbox, podio, salesforce, slack, spotify, stripe, tradeshift, tradeshiftBox, twitch, 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. @@ -1924,7 +1924,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, auth0, authentik, autodesk, bitbucket, bitly, box, dailymotion, discord, disqus, dropbox, etsy, facebook, figma, github, gitlab, google, linkedin, microsoft, notion, oidc, okta, paypal, paypalSandbox, podio, salesforce, slack, spotify, stripe, tradeshift, tradeshiftBox, twitch, wordpress, x, yahoo, yammer, yandex, zoho, zoom. + * @param provider OAuth2 Provider. Currently, supported providers are: amazon, apple, auth0, authentik, autodesk, bitbucket, bitly, box, dailymotion, discord, disqus, dropbox, etsy, facebook, figma, fusionauth, github, gitlab, google, keycloak, kick, linkedin, microsoft, notion, oidc, okta, paypal, paypalSandbox, podio, salesforce, slack, spotify, stripe, tradeshift, tradeshiftBox, twitch, 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/Advisor.kt b/library/src/main/java/io/appwrite/services/Advisor.kt new file mode 100644 index 00000000..b22bc19b --- /dev/null +++ b/library/src/main/java/io/appwrite/services/Advisor.kt @@ -0,0 +1,159 @@ +package io.appwrite.services + +import android.net.Uri +import io.appwrite.Client +import io.appwrite.Service +import io.appwrite.models.* +import io.appwrite.exceptions.AppwriteException +import io.appwrite.extensions.classOf +import okhttp3.Cookie +import java.io.File + +/** + * + */ +class Advisor(client: Client) : Service(client) { + + /** + * Get a list of all the project's analyzer reports. You can use the query params to filter your results. + * + * + * @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. You may filter on the following attributes: appId, type, targetType, target, analyzedAt + * @param total When set to false, the total count returned will be 0 and will not be calculated. + * @return [io.appwrite.models.ReportList] + */ + @JvmOverloads + suspend fun listReports( + queries: List? = null, + total: Boolean? = null, + ): io.appwrite.models.ReportList { + val apiPath = "/reports" + + val apiParams = mutableMapOf( + "queries" to queries, + "total" to total, + ) + val apiHeaders = mutableMapOf( + ) + val converter: (Any) -> io.appwrite.models.ReportList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.ReportList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.ReportList::class.java, + converter, + ) + } + + + /** + * Get an analyzer report by its unique ID. The response includes the report's metadata and the nested insights it produced. + * + * + * @param reportId Report ID. + * @return [io.appwrite.models.Report] + */ + suspend fun getReport( + reportId: String, + ): io.appwrite.models.Report { + val apiPath = "/reports/{reportId}" + .replace("{reportId}", reportId) + + val apiParams = mutableMapOf( + ) + val apiHeaders = mutableMapOf( + ) + val converter: (Any) -> io.appwrite.models.Report = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Report.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Report::class.java, + converter, + ) + } + + + /** + * List the insights produced under a single analyzer report. You can use the query params to filter your results further. + * + * + * @param reportId Parent report 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. You may filter on the following attributes: type, severity, status, resourceType, resourceId, parentResourceType, parentResourceId, analyzedAt, dismissedAt, dismissedBy + * @param total When set to false, the total count returned will be 0 and will not be calculated. + * @return [io.appwrite.models.InsightList] + */ + @JvmOverloads + suspend fun listInsights( + reportId: String, + queries: List? = null, + total: Boolean? = null, + ): io.appwrite.models.InsightList { + val apiPath = "/reports/{reportId}/insights" + .replace("{reportId}", reportId) + + val apiParams = mutableMapOf( + "queries" to queries, + "total" to total, + ) + val apiHeaders = mutableMapOf( + ) + val converter: (Any) -> io.appwrite.models.InsightList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.InsightList.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.InsightList::class.java, + converter, + ) + } + + + /** + * Get an insight by its unique ID, scoped to its parent report. + * + * + * @param reportId Parent report ID. + * @param insightId Insight ID. + * @return [io.appwrite.models.Insight] + */ + suspend fun getInsight( + reportId: String, + insightId: String, + ): io.appwrite.models.Insight { + val apiPath = "/reports/{reportId}/insights/{insightId}" + .replace("{reportId}", reportId) + .replace("{insightId}", insightId) + + val apiParams = mutableMapOf( + ) + val apiHeaders = mutableMapOf( + ) + val converter: (Any) -> io.appwrite.models.Insight = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Insight.from(map = it as Map) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = io.appwrite.models.Insight::class.java, + converter, + ) + } + + +} diff --git a/library/src/main/java/io/appwrite/services/Presences.kt b/library/src/main/java/io/appwrite/services/Presences.kt new file mode 100644 index 00000000..883fec4f --- /dev/null +++ b/library/src/main/java/io/appwrite/services/Presences.kt @@ -0,0 +1,307 @@ +package io.appwrite.services + +import android.net.Uri +import io.appwrite.Client +import io.appwrite.Service +import io.appwrite.models.* +import io.appwrite.exceptions.AppwriteException +import io.appwrite.extensions.classOf +import okhttp3.Cookie +import java.io.File + +/** + * + */ +class Presences(client: Client) : Service(client) { + + /** + * List presence logs. Expired entries are filtered out automatically. + * + * + * @param queries Array of query strings generated using the Query class provided by the SDK. + * @param total When set to false, the total count returned will be 0 and will not be calculated. + * @param ttl TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours). + * @return [io.appwrite.models.PresenceList] + */ + @JvmOverloads + suspend fun list( + queries: List? = null, + total: Boolean? = null, + ttl: Long? = null, + nestedType: Class, + ): io.appwrite.models.PresenceList { + val apiPath = "/presences" + + val apiParams = mutableMapOf( + "queries" to queries, + "total" to total, + "ttl" to ttl, + ) + val apiHeaders = mutableMapOf( + ) + val converter: (Any) -> io.appwrite.models.PresenceList = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.PresenceList.from(map = it as Map, nestedType) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = classOf(), + converter, + ) + } + + /** + * List presence logs. Expired entries are filtered out automatically. + * + * + * @param queries Array of query strings generated using the Query class provided by the SDK. + * @param total When set to false, the total count returned will be 0 and will not be calculated. + * @param ttl TTL (seconds) for caching list responses. Responses are stored in an in-memory key-value cache, keyed per project, collection, schema version (attributes and indexes), caller authorization roles, and the exact query — so users with different permissions never share cached entries. Schema changes invalidate cached entries automatically; document writes do not, so choose a TTL you are comfortable serving as stale data. Set to 0 to disable caching. Must be between 0 and 86400 (24 hours). + * @return [io.appwrite.models.PresenceList] + */ + @JvmOverloads + @Throws(AppwriteException::class) + suspend fun list( + queries: List? = null, + total: Boolean? = null, + ttl: Long? = null, + ): io.appwrite.models.PresenceList> = list( + queries, + total, + ttl, + nestedType = classOf(), + ) + + /** + * Get a presence log by its unique ID. Entries whose `expiresAt` is in the past are treated as not found. + * + * + * @param presenceId Presence unique ID. + * @return [io.appwrite.models.Presence] + */ + suspend fun get( + presenceId: String, + nestedType: Class, + ): io.appwrite.models.Presence { + val apiPath = "/presences/{presenceId}" + .replace("{presenceId}", presenceId) + + val apiParams = mutableMapOf( + ) + val apiHeaders = mutableMapOf( + ) + val converter: (Any) -> io.appwrite.models.Presence = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Presence.from(map = it as Map, nestedType) + } + return client.call( + "GET", + apiPath, + apiHeaders, + apiParams, + responseType = classOf(), + converter, + ) + } + + /** + * Get a presence log by its unique ID. Entries whose `expiresAt` is in the past are treated as not found. + * + * + * @param presenceId Presence unique ID. + * @return [io.appwrite.models.Presence] + */ + @Throws(AppwriteException::class) + suspend fun get( + presenceId: String, + ): io.appwrite.models.Presence> = get( + presenceId, + nestedType = classOf(), + ) + + /** + * Create or update a presence log by its user ID. + * + * + * @param presenceId Presence unique ID. + * @param status Presence status. + * @param permissions An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions). + * @param expiresAt Presence expiry datetime. + * @param metadata Presence metadata object. + * @return [io.appwrite.models.Presence] + */ + @JvmOverloads + suspend fun upsert( + presenceId: String, + status: String, + permissions: List? = null, + expiresAt: String? = null, + metadata: Any? = null, + nestedType: Class, + ): io.appwrite.models.Presence { + val apiPath = "/presences/{presenceId}" + .replace("{presenceId}", presenceId) + + val apiParams = mutableMapOf( + "status" to status, + "permissions" to permissions, + "expiresAt" to expiresAt, + "metadata" to metadata, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Presence = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Presence.from(map = it as Map, nestedType) + } + return client.call( + "PUT", + apiPath, + apiHeaders, + apiParams, + responseType = classOf(), + converter, + ) + } + + /** + * Create or update a presence log by its user ID. + * + * + * @param presenceId Presence unique ID. + * @param status Presence status. + * @param permissions An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions). + * @param expiresAt Presence expiry datetime. + * @param metadata Presence metadata object. + * @return [io.appwrite.models.Presence] + */ + @JvmOverloads + @Throws(AppwriteException::class) + suspend fun upsert( + presenceId: String, + status: String, + permissions: List? = null, + expiresAt: String? = null, + metadata: Any? = null, + ): io.appwrite.models.Presence> = upsert( + presenceId, + status, + permissions, + expiresAt, + metadata, + nestedType = classOf(), + ) + + /** + * Update a presence log by its unique ID. Using the patch method you can pass only specific fields that will get updated. + * + * + * @param presenceId Presence unique ID. + * @param status Presence status. + * @param expiresAt Presence expiry datetime. + * @param metadata Presence metadata object. + * @param permissions An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions). + * @param purge When true, purge cached responses used by list presences endpoint. + * @return [io.appwrite.models.Presence] + */ + @JvmOverloads + suspend fun update( + presenceId: String, + status: String? = null, + expiresAt: String? = null, + metadata: Any? = null, + permissions: List? = null, + purge: Boolean? = null, + nestedType: Class, + ): io.appwrite.models.Presence { + val apiPath = "/presences/{presenceId}" + .replace("{presenceId}", presenceId) + + val apiParams = mutableMapOf( + "status" to status, + "expiresAt" to expiresAt, + "metadata" to metadata, + "permissions" to permissions, + "purge" to purge, + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + ) + val converter: (Any) -> io.appwrite.models.Presence = { + @Suppress("UNCHECKED_CAST") + io.appwrite.models.Presence.from(map = it as Map, nestedType) + } + return client.call( + "PATCH", + apiPath, + apiHeaders, + apiParams, + responseType = classOf(), + converter, + ) + } + + /** + * Update a presence log by its unique ID. Using the patch method you can pass only specific fields that will get updated. + * + * + * @param presenceId Presence unique ID. + * @param status Presence status. + * @param expiresAt Presence expiry datetime. + * @param metadata Presence metadata object. + * @param permissions An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions). + * @param purge When true, purge cached responses used by list presences endpoint. + * @return [io.appwrite.models.Presence] + */ + @JvmOverloads + @Throws(AppwriteException::class) + suspend fun update( + presenceId: String, + status: String? = null, + expiresAt: String? = null, + metadata: Any? = null, + permissions: List? = null, + purge: Boolean? = null, + ): io.appwrite.models.Presence> = update( + presenceId, + status, + expiresAt, + metadata, + permissions, + purge, + nestedType = classOf(), + ) + + /** + * Delete a presence log by its unique ID. + * + * + * @param presenceId Presence unique ID. + * @return [Any] + */ + suspend fun delete( + presenceId: String, + ): Any { + val apiPath = "/presences/{presenceId}" + .replace("{presenceId}", presenceId) + + val apiParams = mutableMapOf( + ) + val apiHeaders = mutableMapOf( + "content-type" to "application/json", + ) + return client.call( + "DELETE", + apiPath, + apiHeaders, + apiParams, + responseType = Any::class.java, + ) + } + + +} diff --git a/library/src/main/java/io/appwrite/services/Realtime.kt b/library/src/main/java/io/appwrite/services/Realtime.kt index 841fe279..bc96ef9c 100644 --- a/library/src/main/java/io/appwrite/services/Realtime.kt +++ b/library/src/main/java/io/appwrite/services/Realtime.kt @@ -17,9 +17,6 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener -import okhttp3.internal.concurrent.TaskRunner -import okhttp3.internal.ws.RealWebSocket -import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import android.util.Log @@ -37,12 +34,13 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private const val TYPE_ERROR = "error" private const val TYPE_EVENT = "event" private const val TYPE_PONG = "pong" - private const val TYPE_RESPONSE = "response" private const val HEARTBEAT_INTERVAL = 20_000L // 20 seconds - private var socket: RealWebSocket? = null + private var socket: WebSocket? = null private val activeSubscriptions = ConcurrentHashMap() private val pendingSubscribes = LinkedHashMap>() + private var pendingPresence: Map? = null + @Volatile private var appConnected = false private var reconnectAttempts = 0 private val socketGeneration = AtomicInteger(0) @@ -50,51 +48,46 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var heartbeatJob: Job? = null private val subscriptionLock = Any() + private val presenceLock = Any() } private fun createSocket() { - // Serialize socket recreation so subscribe(A), subscribe(B), subscribe(C) - // deterministically results in A -> A+B -> A+B+C (sequential updates). - val request: Request - val newSocket: RealWebSocket? + // Serialize socket recreation. With the subscriptionLock held, both + // subscribe() and upsertPresence() can call into createSocket() + // without producing duplicate sockets — the first call wins, the + // second sees `socket != null` and bails. synchronized(subscriptionLock) { - if (activeSubscriptions.isEmpty()) { + val hasPendingPresence = synchronized(presenceLock) { pendingPresence != null } + if (activeSubscriptions.isEmpty() && !hasPendingPresence) { reconnect = false closeSocket() return } + // Single-flight guard: a socket is already open (or in flight via + // OkHttp's newWebSocket). Don't tear it down and rebuild. + if (socket != null) { + return + } + val encodedProject = java.net.URLEncoder.encode(client.config["project"].toString(), "UTF-8") val queryParams = "project=$encodedProject" val url = "${client.endpointRealtime}/realtime?$queryParams" - request = Request.Builder().url(url).build() - - if (socket != null) { - reconnect = false - closeSocket() - // Don't rely on old socket callbacks to flip reconnect back. - reconnect = true - } + val request = Request.Builder().url(url).build() val generation = socketGeneration.incrementAndGet() - newSocket = RealWebSocket( - taskRunner = TaskRunner.INSTANCE, - originalRequest = request, - listener = AppwriteWebSocketListener(generation), - random = Random(), - pingIntervalMillis = client.http.pingIntervalMillis.toLong(), - extensions = null, - minimumDeflateSize = client.http.minWebSocketMessageToCompress - ) - socket = newSocket + socket = client.http.newWebSocket(request, AppwriteWebSocketListener(generation)) } - - newSocket?.connect(client.http) } private fun closeSocket() { stopHeartbeat() socket?.close(RealtimeCode.POLICY_VIOLATION.value, null) + // Drop the reference so the single-flight guard in createSocket() + // doesn't keep bailing out on a dead socket — a subsequent subscribe() + // or scheduled reconnect needs to be able to open a fresh one. Callers + // hold `subscriptionLock` whenever closeSocket() runs. + socket = null } private fun sendUnsubscribeMessage(subscriptionIds: List) { @@ -142,10 +135,22 @@ class Realtime(client: Client) : Service(client), CoroutineScope { reconnect = false closeSocket() } + synchronized(presenceLock) { + pendingPresence = null + } + appConnected = false } private fun sendPendingSubscribes() { val ws = socket ?: return + // The OkHttp socket transitions to "open" before the server has + // emitted its application-level `connected` event. Sending a + // subscribe frame in that window triggers a policy-violation close + // on real Appwrite, which reconnects and re-sends, looping forever. + // handleResponseConnected re-enqueues every active subscription and + // calls this method again once it's safe, so queued rows are never + // lost — just deferred. + if (!appConnected) return val rows: List> synchronized(subscriptionLock) { if (pendingSubscribes.isEmpty()) { @@ -191,6 +196,58 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } } + /** + * Fire-and-forget presence upsert. Records the latest payload in state so + * that — if the WebSocket isn't open yet, or later reconnects — the most + * recent presence is automatically (re)sent on the next `connected` event. + * When the socket is already open, the frame is sent immediately. + * + * @param status Presence status (required). + * @param presenceId Presence ID (required). + * @param permissions Optional permission list to attach to the presence document. + * @param metadata Optional metadata payload. + */ + fun upsertPresence( + status: String, + presenceId: String, + permissions: List? = null, + metadata: Map? = null, + ) { + val data = mutableMapOf( + "status" to status, + "presenceId" to presenceId, + ) + permissions?.let { data["permissions"] = it } + metadata?.let { data["metadata"] = it } + + synchronized(presenceLock) { + pendingPresence = data + } + + // Both subscribe() and upsertPresence() may need to open the socket. + // createSocket() is single-flight (guarded by subscriptionLock + the + // `socket != null` check), so calling it from presence is safe. + val shouldCreateSocket: Boolean + synchronized(subscriptionLock) { + shouldCreateSocket = (socket == null) + } + if (shouldCreateSocket) { + createSocket() + } + + // Opportunistic send for when the socket is already past `connected`. + // The appConnected gate inside flushPendingPresence keeps this a no-op + // until the application-level handshake completes. + flushPendingPresence() + } + + private fun flushPendingPresence() { + if (!appConnected) return + val data = synchronized(presenceLock) { pendingPresence } ?: return + val ws = socket ?: return + ws.send(mapOf("type" to "presence", "data" to data).toJson()) + } + fun subscribe( vararg channels: Channel<*>, callback: (RealtimeResponseEvent) -> Unit, @@ -340,7 +397,6 @@ class Realtime(client: Client) : Service(client), CoroutineScope { when (message.type) { TYPE_ERROR -> handleResponseError(message) TYPE_CONNECTED -> handleResponseConnected(message) - TYPE_RESPONSE -> handleResponseAction(message) TYPE_EVENT -> handleResponseEvent(message) TYPE_PONG -> {} } @@ -353,17 +409,14 @@ class Realtime(client: Client) : Service(client), CoroutineScope { synchronized(subscriptionLock) { activeSubscriptions.keys.forEach { enqueuePendingSubscribeLocked(it) } } + appConnected = true sendPendingSubscribes() - } - - private fun handleResponseAction(message: RealtimeResponse) { - // The SDK generates subscriptionIds client-side and sends them on every - // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state - // the SDK needs to reconcile. + flushPendingPresence() } private fun handleResponseError(message: RealtimeResponse) { - throw message.data?.jsonCast() ?: RuntimeException("Data is not present") + val ex = message.data?.jsonCast() ?: RuntimeException("Data is not present") + throw ex } private suspend fun handleResponseEvent(message: RealtimeResponse) { @@ -394,7 +447,13 @@ class Realtime(client: Client) : Service(client), CoroutineScope { override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { super.onClosing(webSocket, code, reason) if (isStale(webSocket)) return + appConnected = false stopHeartbeat() + // Release the companion ref so createSocket() (called below or by + // a concurrent subscribe()/upsertPresence()) can open a new one. + synchronized(subscriptionLock) { + if (socket === webSocket) socket = null + } if (!reconnect || code == RealtimeCode.POLICY_VIOLATION.value) { reconnect = true return @@ -418,7 +477,14 @@ class Realtime(client: Client) : Service(client), CoroutineScope { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { super.onFailure(webSocket, t, response) if (isStale(webSocket)) return + appConnected = false stopHeartbeat() + // Same cleanup as onClosing — without this, the next subscribe() + // or upsertPresence() would hit the `socket != null` guard in + // createSocket() and silently refuse to reconnect. + synchronized(subscriptionLock) { + if (socket === webSocket) socket = null + } t.printStackTrace() } } diff --git a/scripts/publish-config.gradle b/scripts/publish-config.gradle deleted file mode 100644 index 5934f920..00000000 --- a/scripts/publish-config.gradle +++ /dev/null @@ -1,37 +0,0 @@ -// Create variables with empty default values -ext["signing.keyId"] = '' -ext["signing.password"] = '' -ext["signing.secretKeyRingFile"] = '' -ext["ossrhUsername"] = '' -ext["ossrhPassword"] = '' -ext["sonatypeStagingProfileId"] = '' - -File secretPropsFile = project.rootProject.file('local.properties') -if (secretPropsFile.exists()) { - // Read local.properties file first if it exists - Properties p = new Properties() - new FileInputStream(secretPropsFile).withCloseable { is -> p.load(is) } - p.each { name, value -> ext[name] = value } -} - -// Use system environment variables -ext["ossrhUsername"] = System.getenv('OSSRH_USERNAME') ?: ext["ossrhUsername"] -ext["ossrhPassword"] = System.getenv('OSSRH_PASSWORD') ?: ext["ossrhPassword"] -ext["sonatypeStagingProfileId"] = System.getenv('SONATYPE_STAGING_PROFILE_ID') ?: ext["sonatypeStagingProfileId"] -ext["signing.keyId"] = System.getenv('SIGNING_KEY_ID') ?: ext["signing.keyId"] -ext["signing.password"] = System.getenv('SIGNING_PASSWORD') ?: ext["signing.password"] -ext["signing.secretKeyRingFile"] = System.getenv('SIGNING_SECRET_KEY_RING_FILE') ?: ext["signing.secretKeyRingFile"] - - -// Set up Sonatype repository -nexusPublishing { - repositories { - sonatype { - stagingProfileId = sonatypeStagingProfileId - username = ossrhUsername - password = ossrhPassword - nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) - snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) - } - } -} \ No newline at end of file diff --git a/scripts/publish-module.gradle b/scripts/publish-module.gradle deleted file mode 100644 index ca97b164..00000000 --- a/scripts/publish-module.gradle +++ /dev/null @@ -1,88 +0,0 @@ -apply(plugin: "maven-publish") -apply(plugin: "signing") - -tasks.register("sourcesJar", Jar) { - archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.srcDirs) -} - -tasks.register("javadoc", Javadoc) { - source = android.sourceSets.getByName("main").java.srcDirs - setDestinationDir(file("../javadoc/")) - failOnError false -} - -tasks.register("javadocJar", Jar) { - dependsOn(javadoc) - archiveClassifier.set("javadoc") - from(javadoc) -} - -afterEvaluate { - tasks.javadoc.classpath += files(project.android.getBootClasspath()) -} - -publishing { - publications { - release(MavenPublication) { - groupId PUBLISH_GROUP_ID - artifactId PUBLISH_ARTIFACT_ID - version PUBLISH_VERSION - - artifacts { - artifact(sourcesJar) - artifact(javadocJar) - artifact("$buildDir/outputs/aar/${project.name}-release.aar") - } - - pom { - name = PUBLISH_ARTIFACT_ID - description = POM_DESCRIPTION - url = POM_URL - - licenses { - license { - name = POM_LICENSE_NAME - url = POM_LICENSE_URL - } - } - - developers { - developer { - id = POM_DEVELOPER_ID - name = POM_DEVELOPER_NAME - email = POM_DEVELOPER_EMAIL - } - } - - scm { - connection = GITHUB_SCM_CONNECTION - url = POM_SCM_URL - } - - withXml { - def dependencies = asNode().appendNode("dependencies") - - configurations - .getByName("releaseCompileClasspath") - .resolvedConfiguration - .firstLevelModuleDependencies - .forEach { - def dependency = dependencies.appendNode("dependency") - dependency.appendNode("groupId", it.moduleGroup) - dependency.appendNode("artifactId", it.moduleName) - dependency.appendNode("version", it.moduleVersion) - } - } - } - } - } -} - -ext["signing.keyId"] = rootProject.ext["signing.keyId"] -ext["signing.password"] = rootProject.ext["signing.password"] -ext["signing.secretKeyRingFile"] = rootProject.ext["signing.secretKeyRingFile"] - -signing { - sign publishing.publications -} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 423c64ef..00000000 --- a/settings.gradle +++ /dev/null @@ -1,3 +0,0 @@ -rootProject.name = "Appwrite Android SDK" -include ':example' -include ':library' \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..e023795f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Appwrite Android SDK" +include(":example") +include(":library")