From 449afdb58ee7d3e6e95b59ac778e1f9a79345ca7 Mon Sep 17 00:00:00 2001 From: Aaron LaBeau <80424345+biozal@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:45:47 -0600 Subject: [PATCH 1/8] feat: update to v5 of the API --- .../QuickStartTasks/app/build.gradle.kts | 12 +-- .../ditto/quickstart/tasks/DittoHandler.kt | 13 ++- .../ditto/quickstart/tasks/MainActivity.kt | 2 +- .../quickstart/tasks/TasksApplication.kt | 97 +++++++++++-------- .../tasks/edit/EditScreenViewModel.kt | 54 +++++++---- .../tasks/list/TasksListScreenViewModel.kt | 68 +++++++++---- .../QuickStartTasks/gradle/libs.versions.toml | 4 +- 7 files changed, 162 insertions(+), 88 deletions(-) diff --git a/android-kotlin/QuickStartTasks/app/build.gradle.kts b/android-kotlin/QuickStartTasks/app/build.gradle.kts index c10dea861..9c31fc687 100644 --- a/android-kotlin/QuickStartTasks/app/build.gradle.kts +++ b/android-kotlin/QuickStartTasks/app/build.gradle.kts @@ -45,7 +45,7 @@ androidComponents { buildConfigFields.forEach { (key, description) -> it.buildConfigFields.put( key, - BuildConfigField("String", "\"${prop[key]}\"", description) + BuildConfigField("String", "${prop[key]}", description) ) } } @@ -61,7 +61,7 @@ android { defaultConfig { applicationId = "live.ditto.quickstart.tasks" - minSdk = 23 + minSdk = 24 targetSdk = 35 versionCode = 1 versionName = "1.0" @@ -83,12 +83,12 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } buildFeatures { @@ -132,7 +132,7 @@ dependencies { implementation(libs.koin.androidx.compose.navigation) // Ditto SDK - implementation(libs.live.ditto) + implementation(libs.com.ditto) // Testing testImplementation(libs.junit) diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/DittoHandler.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/DittoHandler.kt index 40ff8e8b2..8b660b75a 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/DittoHandler.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/DittoHandler.kt @@ -1,9 +1,20 @@ package live.ditto.quickstart.tasks -import live.ditto.* +import com.ditto.kotlin.* class DittoHandler { companion object { lateinit var ditto: Ditto + private set + + fun initialize(config: DittoConfig) { + if (::ditto.isInitialized) { + throw IllegalStateException("Ditto is already initialized") + } + ditto = DittoFactory.create(config = config) + } + + val isInitialized: Boolean + get() = ::ditto.isInitialized } } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt index 0ac8b933c..08339e595 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt @@ -3,7 +3,7 @@ package live.ditto.quickstart.tasks import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import live.ditto.transports.DittoSyncPermissions +import com.ditto.kotlin.transports.DittoSyncPermissions import android.os.StrictMode class MainActivity : ComponentActivity() { diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt index a05cae2f5..f95cf93bd 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt @@ -6,20 +6,22 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import live.ditto.Ditto -import live.ditto.DittoIdentity -import live.ditto.android.DefaultAndroidDittoDependencies +import com.ditto.kotlin.Ditto +import com.ditto.kotlin.DittoAuthenticationProvider +import com.ditto.kotlin.DittoConfig +import com.ditto.kotlin.DittoConnection +import com.ditto.kotlin.DittoFactory +import com.ditto.kotlin.DittoLog +import com.ditto.kotlin.error.DittoException import live.ditto.quickstart.tasks.DittoHandler.Companion.ditto class TasksApplication : Application() { // Create a CoroutineScope // Use SupervisorJob so if one coroutine launched in this scope fails, it doesn't cancel the scope - // - // https://developer.android.com/kotlin/coroutines/coroutines-adv - // Dispatchers.IO - This dispatcher is optimized to perform disk or network I/O outside of the main thread. - private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - + // SDKS-1294: Don't create Ditto in a scope using Dispatchers.IO + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val tag = "TaskApplication" companion object { private var instance: TasksApplication? = null @@ -35,43 +37,58 @@ class TasksApplication : Application() { override fun onCreate() { super.onCreate() - ioScope.launch { - setupDitto() + + // Initialize Ditto synchronously - completes before UI loads + initializeDitto() + + // Perform authentication asynchronously - can happen in background + scope.launch { + performAuthentication() } } - private suspend fun setupDitto() { - val androidDependencies = DefaultAndroidDittoDependencies(applicationContext) - - //read values from build.gradle.kts (Module:app) which reads from environment file - val appId = BuildConfig.DITTO_APP_ID - val token = BuildConfig.DITTO_PLAYGROUND_TOKEN - val authUrl = BuildConfig.DITTO_AUTH_URL - val webSocketURL = BuildConfig.DITTO_WEBSOCKET_URL - - val enableDittoCloudSync = false - - /* - * Setup Ditto Identity - * https://docs.ditto.live/sdk/latest/install-guides/kotlin#integrating-and-initializing - */ - val identity = DittoIdentity.OnlinePlayground( - dependencies = androidDependencies, - appId = appId, - token = token, - customAuthUrl = authUrl, - enableDittoCloudSync = enableDittoCloudSync // This is required to be set to false to use the correct URLs - ) - - ditto = Ditto(androidDependencies, identity) - ditto.updateTransportConfig { config -> - // Set the Ditto Websocket URL - config.connect.websocketUrls.add(webSocketURL) + private fun initializeDitto() { + try { + val appId = BuildConfig.DITTO_APP_ID + val authUrl = BuildConfig.DITTO_AUTH_URL + + val config = DittoConfig( + databaseId = appId, + connect = DittoConfig.Connect.Server(url = authUrl) + ) + + DittoHandler.initialize(config) + DittoLog.d(tag, "Ditto instance created successfully") + + } catch (ex: Throwable) { + DittoLog.e(tag, "Failed to initialize Ditto: $ex") + ex.printStackTrace() + throw ex } + } + + private suspend fun performAuthentication() { + try { + val token = BuildConfig.DITTO_PLAYGROUND_TOKEN - ditto.store.execute("ALTER SYSTEM SET DQL_STRICT_MODE = false") + DittoHandler.ditto.auth?.setExpirationHandler { ditto, _ -> + try { + val clientInfo = ditto.auth?.login( + token = token, + provider = DittoAuthenticationProvider.development() + ) + DittoLog.d(tag, "Auth response: $clientInfo") + } catch (ex: Throwable) { + DittoLog.e(tag, "Authentication failed: $ex") + ex.printStackTrace() + } + } - // disable sync with v3 peers, required for DQL - ditto.disableSyncWithV3() + DittoLog.d(tag, "Ditto authentication setup complete") + + } catch (ex: Throwable) { + DittoLog.e(tag, "Failed to setup authentication: $ex") + ex.printStackTrace() + } } } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt index 4c3dfb96d..e2327c746 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt @@ -5,10 +5,14 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import live.ditto.DittoError +import com.ditto.kotlin.error.DittoException +import com.ditto.kotlin.serialization.toDittoCbor import live.ditto.quickstart.tasks.DittoHandler.Companion.ditto import live.ditto.quickstart.tasks.data.Task +import com.ditto.kotlin.serialization.DittoCborSerializable +import com.ditto.kotlin.serialization.DittoCborSerializable.Utf8String + class EditScreenViewModel : ViewModel() { companion object { @@ -22,6 +26,10 @@ class EditScreenViewModel : ViewModel() { var canDelete = MutableLiveData(false) fun setupWithTask(id: String?) { + check(live.ditto.quickstart.tasks.DittoHandler.isInitialized) { + "Ditto must be initialized before ViewModels are created" + } + canDelete.postValue(id != null) val taskId: String = id ?: return @@ -29,14 +37,14 @@ class EditScreenViewModel : ViewModel() { try { val item = ditto.store.execute( "SELECT * FROM tasks WHERE _id = :_id AND NOT deleted", - mapOf("_id" to taskId) + mapOf("_id" to taskId).toDittoCbor() ).items.first() val task = Task.fromJson(item.jsonString()) _id = task._id title.postValue(task.title) done.postValue(task.done) - } catch (e: DittoError) { + } catch (e: DittoException) { Log.e(TAG, "Unable to setup view task data", e) } } @@ -45,23 +53,35 @@ class EditScreenViewModel : ViewModel() { fun save() { viewModelScope.launch { try { + val titleValue = title.value ?: "" + val doneValue = done.value ?: false if (_id == null) { // Add tasks into the ditto collection using DQL INSERT statement // https://docs.ditto.live/sdk/latest/crud/write#inserting-documents + val addMap = DittoCborSerializable.Dictionary( + mapOf( + Utf8String("title") to Utf8String(titleValue), + Utf8String("done") to DittoCborSerializable.BooleanValue(doneValue), + Utf8String("deleted") to DittoCborSerializable.BooleanValue(false) + ) + ) ditto.store.execute( "INSERT INTO tasks DOCUMENTS (:doc)", - mapOf( - "doc" to mapOf( - "title" to title.value, - "done" to done.value, - "deleted" to false - ) + DittoCborSerializable.Dictionary( + mapOf(Utf8String("doc") to addMap) ) ) } else { // Update tasks into the ditto collection using DQL UPDATE statement // https://docs.ditto.live/sdk/latest/crud/update#updating _id?.let { id -> + val editMap = DittoCborSerializable.Dictionary( + mapOf( + Utf8String("title") to Utf8String(titleValue), + Utf8String("done") to DittoCborSerializable.BooleanValue(doneValue), + Utf8String("id") to DittoCborSerializable.Utf8String(id) + ) + ) ditto.store.execute( """ UPDATE tasks @@ -71,15 +91,11 @@ class EditScreenViewModel : ViewModel() { WHERE _id = :id AND NOT deleted """, - mapOf( - "title" to title.value, - "done" to done.value, - "id" to id - ) + arguments = editMap ) } } - } catch (e: DittoError) { + } catch (e: Throwable) { Log.e(TAG, "Unable to save task", e) } } @@ -93,10 +109,14 @@ class EditScreenViewModel : ViewModel() { _id?.let { id -> ditto.store.execute( "UPDATE tasks SET deleted = true WHERE _id = :id", - mapOf("id" to id) + DittoCborSerializable.Dictionary( + mapOf( + Utf8String("id") to Utf8String(id) + ) + ) ) } - } catch (e: DittoError) { + } catch (e: Throwable) { Log.e(TAG, "Unable to set deleted=true", e) } } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt index c716696bf..819fe8ee5 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt @@ -9,15 +9,18 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.ditto.kotlin.DittoSyncSubscription import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import live.ditto.DittoError -import live.ditto.DittoSyncSubscription import live.ditto.quickstart.tasks.DittoHandler.Companion.ditto import live.ditto.quickstart.tasks.TasksApplication import live.ditto.quickstart.tasks.data.Task +import com.ditto.kotlin.serialization.DittoCborSerializable +import com.ditto.kotlin.serialization.DittoCborSerializable.Utf8String +import okio.Utf8 + // The value of the Sync switch is stored in persistent settings private val Context.preferencesDataStore by preferencesDataStore("tasks_list_settings") private val SYNC_ENABLED_KEY = booleanPreferencesKey("sync_enabled") @@ -27,7 +30,7 @@ class TasksListScreenViewModel : ViewModel() { companion object { private const val TAG = "TasksListScreenViewModel" - private const val QUERY = "SELECT * FROM tasks WHERE NOT deleted ORDER BY title ASC" + private const val QUERY = "SELECT * FROM tasks WHERE NOT deleted" } private val preferencesDataStore = TasksApplication.applicationContext().preferencesDataStore @@ -55,7 +58,7 @@ class TasksListScreenViewModel : ViewModel() { // Register a subscription, which determines what data syncs to this peer // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions syncSubscription = ditto.sync.registerSubscription(QUERY) - } catch (e: DittoError) { + } catch (e: Throwable) { Log.e(TAG, "Unable to start sync", e) } } else if (ditto.isSyncActive) { @@ -63,7 +66,7 @@ class TasksListScreenViewModel : ViewModel() { syncSubscription?.close() syncSubscription = null ditto.stopSync() - } catch (e: DittoError) { + } catch (e: Throwable) { Log.e(TAG, "Unable to stop sync", e) } } @@ -71,13 +74,20 @@ class TasksListScreenViewModel : ViewModel() { } init { + // Defensive check - should never fail with synchronous initialization + check(live.ditto.quickstart.tasks.DittoHandler.isInitialized) { + "Ditto must be initialized before ViewModels are created" + } + viewModelScope.launch { populateTasksCollection() // Register observer, which runs against the local database on this peer // https://docs.ditto.live/sdk/latest/crud/observing-data-changes#setting-up-store-observers ditto.store.registerObserver(QUERY) { result -> - val list = result.items.map { item -> Task.fromJson(item.jsonString()) } + val list = result.items.map { + item -> Task.fromJson(item.jsonString()) + } tasks.postValue(list) } @@ -101,18 +111,24 @@ class TasksListScreenViewModel : ViewModel() { try { // Add tasks into the ditto collection using DQL INSERT statement // https://docs.ditto.live/sdk/latest/crud/write#inserting-documents + val addMap = DittoCborSerializable.Dictionary( + mapOf( + Utf8String("_id") to Utf8String(task._id), + Utf8String("title") to Utf8String(task.title), + Utf8String("done") to DittoCborSerializable.BooleanValue(task.done), + Utf8String("deleted") to DittoCborSerializable.BooleanValue(task.deleted) + + ) + ) ditto.store.execute( "INSERT INTO tasks INITIAL DOCUMENTS (:task)", - mapOf( - "task" to mapOf( - "_id" to task._id, - "title" to task.title, - "done" to task.done, - "deleted" to task.deleted, + DittoCborSerializable.Dictionary( + mapOf( + Utf8String("task") to addMap ) ) ) - } catch (e: DittoError) { + } catch (e: Throwable) { Log.e(TAG, "Unable to insert initial document", e) } } @@ -124,21 +140,27 @@ class TasksListScreenViewModel : ViewModel() { try { val doc = ditto.store.execute( "SELECT * FROM tasks WHERE _id = :_id AND NOT deleted", - mapOf("_id" to taskId) + DittoCborSerializable.Dictionary( + mapOf( + Utf8String("_id") to Utf8String(taskId) + ) + ) ).items.first() - val done = doc.value["done"] as Boolean + val done = doc.value["done"].boolean // Update tasks into the ditto collection using DQL UPDATE statement // https://docs.ditto.live/sdk/latest/crud/update#updating ditto.store.execute( "UPDATE tasks SET done = :toggled WHERE _id = :_id AND NOT deleted", - mapOf( - "toggled" to !done, - "_id" to taskId + DittoCborSerializable.Dictionary( + mapOf( + Utf8String("toggled") to DittoCborSerializable.BooleanValue(!done), + Utf8String("_id") to Utf8String(taskId) + ) ) ) - } catch (e: DittoError) { + } catch (e: Throwable) { Log.e(TAG, "Unable to toggle done state", e) } } @@ -151,9 +173,13 @@ class TasksListScreenViewModel : ViewModel() { // https://docs.ditto.live/sdk/latest/crud/delete#soft-delete-pattern ditto.store.execute( "UPDATE tasks SET deleted = true WHERE _id = :id", - mapOf("id" to taskId) + DittoCborSerializable.Dictionary( + mapOf( + Utf8String("id") to Utf8String(taskId) + ) + ) ) - } catch (e: DittoError) { + } catch (e: Throwable) { Log.e(TAG, "Unable to set deleted=true", e) } } diff --git a/android-kotlin/QuickStartTasks/gradle/libs.versions.toml b/android-kotlin/QuickStartTasks/gradle/libs.versions.toml index 608b52f08..9be277c03 100644 --- a/android-kotlin/QuickStartTasks/gradle/libs.versions.toml +++ b/android-kotlin/QuickStartTasks/gradle/libs.versions.toml @@ -14,7 +14,7 @@ appcompat = "1.7.1" datastorePreferences = "1.1.7" koin-bom = "4.1.0" coroutines-tests = "1.10.2" -ditto = "4.13.1" +ditto = "5.0.0-dev-weekly.20260126.180" monitor = "1.7.2" [libraries] @@ -41,7 +41,7 @@ koin-core = { group = "io.insert-koin", name = "koin-core" } koin-android = { group = "io.insert-koin", name = "koin-android" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose" } koin-androidx-compose-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation" } -live-ditto = { group = "live.ditto", name = "ditto", version.ref = "ditto" } +com-ditto = { group = "com.ditto", name = "ditto-kotlin", version.ref = "ditto" } kotlinx-coroutines = {group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines-tests" } androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" } From 922c5cd4ca3668a795f14d586d110c040783bcbf Mon Sep 17 00:00:00 2001 From: Aaron LaBeau <80424345+biozal@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:00:26 -0500 Subject: [PATCH 2/8] Updated for RC3 support and Android best practices --- .../QuickStartTasks/app/build.gradle.kts | 8 +- .../ditto/quickstart/tasks/MainActivity.kt | 18 +-- .../quickstart/tasks/TasksApplication.kt | 57 +++----- .../live/ditto/quickstart/tasks/data/Task.kt | 7 + .../ditto/quickstart/tasks/edit/EditScreen.kt | 12 +- .../tasks/edit/EditScreenViewModel.kt | 89 ++++++------ .../quickstart/tasks/list/TasksListScreen.kt | 6 +- .../tasks/list/TasksListScreenViewModel.kt | 129 +++++++----------- .../ditto/quickstart/tasks/ui/theme/Theme.kt | 20 --- .../QuickStartTasks/gradle/libs.versions.toml | 18 +-- .../xcshareddata/swiftpm/Package.resolved | 4 +- 11 files changed, 144 insertions(+), 224 deletions(-) diff --git a/android-kotlin/QuickStartTasks/app/build.gradle.kts b/android-kotlin/QuickStartTasks/app/build.gradle.kts index 9c31fc687..a4bbf9c15 100644 --- a/android-kotlin/QuickStartTasks/app/build.gradle.kts +++ b/android-kotlin/QuickStartTasks/app/build.gradle.kts @@ -53,7 +53,7 @@ androidComponents { android { namespace = "live.ditto.quickstart.tasks" - compileSdk = 35 + compileSdk = 36 lint { baseline = file("lint-baseline.xml") @@ -96,10 +96,6 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.14" - } - packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -111,6 +107,7 @@ dependencies { // Core Android implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) implementation(libs.androidx.datastore.preferences) @@ -122,7 +119,6 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.runtime.livedata) // Dependency Injection implementation(platform(libs.koin.bom)) diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt index 08339e595..500685545 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt @@ -3,23 +3,14 @@ package live.ditto.quickstart.tasks import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import com.ditto.kotlin.transports.DittoSyncPermissions -import android.os.StrictMode class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - StrictMode.ThreadPolicy.Builder() - .detectDiskReads() - .detectDiskWrites() - .penaltyLog() // Log violations to logcat - .build() - ) - } - setContent { Root() } @@ -28,7 +19,7 @@ class MainActivity : ComponentActivity() { } private fun requestMissingPermissions() { - // requesting permissions at runtime + // Requesting permissions at runtime // https://docs.ditto.live/sdk/latest/install-guides/kotlin#requesting-permissions-at-runtime val missingPermissions = DittoSyncPermissions(this).missingPermissions() if (missingPermissions.isNotEmpty()) { @@ -36,6 +27,3 @@ class MainActivity : ComponentActivity() { } } } - - - diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt index f95cf93bd..4745805cb 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt @@ -2,25 +2,13 @@ package live.ditto.quickstart.tasks import android.app.Application import android.content.Context -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import com.ditto.kotlin.Ditto import com.ditto.kotlin.DittoAuthenticationProvider import com.ditto.kotlin.DittoConfig -import com.ditto.kotlin.DittoConnection import com.ditto.kotlin.DittoFactory import com.ditto.kotlin.DittoLog -import com.ditto.kotlin.error.DittoException -import live.ditto.quickstart.tasks.DittoHandler.Companion.ditto class TasksApplication : Application() { - // Create a CoroutineScope - // Use SupervisorJob so if one coroutine launched in this scope fails, it doesn't cancel the scope - // SDKS-1294: Don't create Ditto in a scope using Dispatchers.IO - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val tag = "TaskApplication" companion object { @@ -37,58 +25,47 @@ class TasksApplication : Application() { override fun onCreate() { super.onCreate() - - // Initialize Ditto synchronously - completes before UI loads initializeDitto() - - // Perform authentication asynchronously - can happen in background - scope.launch { - performAuthentication() - } + setupAuthentication() } private fun initializeDitto() { try { - val appId = BuildConfig.DITTO_APP_ID - val authUrl = BuildConfig.DITTO_AUTH_URL - val config = DittoConfig( - databaseId = appId, - connect = DittoConfig.Connect.Server(url = authUrl) + databaseId = BuildConfig.DITTO_APP_ID, + connect = DittoConfig.Connect.Server(url = BuildConfig.DITTO_AUTH_URL) ) DittoHandler.initialize(config) DittoLog.d(tag, "Ditto instance created successfully") - } catch (ex: Throwable) { DittoLog.e(tag, "Failed to initialize Ditto: $ex") - ex.printStackTrace() throw ex } } - private suspend fun performAuthentication() { + private fun setupAuthentication() { try { val token = BuildConfig.DITTO_PLAYGROUND_TOKEN - DittoHandler.ditto.auth?.setExpirationHandler { ditto, _ -> - try { - val clientInfo = ditto.auth?.login( - token = token, - provider = DittoAuthenticationProvider.development() - ) - DittoLog.d(tag, "Auth response: $clientInfo") - } catch (ex: Throwable) { - DittoLog.e(tag, "Authentication failed: $ex") - ex.printStackTrace() + // Set the expiration handler before starting sync + // https://docs.ditto.live/sdk/latest/sync/authentication + DittoHandler.ditto.auth?.let { auth -> + auth.expirationHandler = { ditto, _ -> + try { + val clientInfo = ditto.auth?.login( + token = token, + provider = DittoAuthenticationProvider.development() + ) + DittoLog.d(tag, "Auth response: $clientInfo") + } catch (ex: Throwable) { + DittoLog.e(tag, "Authentication failed: $ex") + } } } - DittoLog.d(tag, "Ditto authentication setup complete") - } catch (ex: Throwable) { DittoLog.e(tag, "Failed to setup authentication: $ex") - ex.printStackTrace() } } } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/data/Task.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/data/Task.kt index db4b8bf32..43b5ccc30 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/data/Task.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/data/Task.kt @@ -11,6 +11,13 @@ data class Task( val done: Boolean = false, val deleted: Boolean = false, ) { + fun toMap(): Map = mapOf( + "_id" to _id, + "title" to title, + "done" to done, + "deleted" to deleted + ) + companion object { private const val TAG = "Task" diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreen.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreen.kt index 01d1b3f32..f3c122d89 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreen.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreen.kt @@ -10,10 +10,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import live.ditto.quickstart.tasks.R @@ -26,9 +26,9 @@ fun EditScreen(navController: NavController, taskId: String?) { val topBarTitle = if (taskId == null) "New Task" else "Edit Task" - val title: String by editScreenViewModel.title.observeAsState("") - val done: Boolean by editScreenViewModel.done.observeAsState(initial = false) - val canDelete: Boolean by editScreenViewModel.canDelete.observeAsState(initial = false) + val title: String by editScreenViewModel.title.collectAsStateWithLifecycle() + val done: Boolean by editScreenViewModel.done.collectAsStateWithLifecycle() + val canDelete: Boolean by editScreenViewModel.canDelete.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -46,9 +46,9 @@ fun EditScreen(navController: NavController, taskId: String?) { EditForm( canDelete = canDelete, title = title, - onTitleTextChange = { editScreenViewModel.title.value = it }, + onTitleTextChange = { editScreenViewModel.setTitle(it) }, done = done, - onDoneChanged = { editScreenViewModel.done.value = it }, + onDoneChanged = { editScreenViewModel.setDone(it) }, onSaveButtonClicked = { editScreenViewModel.save() navController.popBackStack() diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt index e2327c746..5d61e71b6 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt @@ -1,18 +1,15 @@ package live.ditto.quickstart.tasks.edit import android.util.Log -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import com.ditto.kotlin.error.DittoException -import com.ditto.kotlin.serialization.toDittoCbor import live.ditto.quickstart.tasks.DittoHandler.Companion.ditto import live.ditto.quickstart.tasks.data.Task -import com.ditto.kotlin.serialization.DittoCborSerializable -import com.ditto.kotlin.serialization.DittoCborSerializable.Utf8String - class EditScreenViewModel : ViewModel() { companion object { @@ -21,30 +18,46 @@ class EditScreenViewModel : ViewModel() { private var _id: String? = null - var title = MutableLiveData("") - var done = MutableLiveData(false) - var canDelete = MutableLiveData(false) + private val _title = MutableStateFlow("") + val title: StateFlow = _title.asStateFlow() + + private val _done = MutableStateFlow(false) + val done: StateFlow = _done.asStateFlow() + + private val _canDelete = MutableStateFlow(false) + val canDelete: StateFlow = _canDelete.asStateFlow() + + fun setTitle(value: String) { + _title.value = value + } + + fun setDone(value: Boolean) { + _done.value = value + } fun setupWithTask(id: String?) { check(live.ditto.quickstart.tasks.DittoHandler.isInitialized) { "Ditto must be initialized before ViewModels are created" } - canDelete.postValue(id != null) + _canDelete.value = id != null val taskId: String = id ?: return viewModelScope.launch { try { - val item = ditto.store.execute( + val task = ditto.store.execute( "SELECT * FROM tasks WHERE _id = :_id AND NOT deleted", - mapOf("_id" to taskId).toDittoCbor() - ).items.first() + mapOf("_id" to taskId) + ) { result -> + result.items.firstOrNull()?.let { Task.fromJson(it.jsonString()) } + } - val task = Task.fromJson(item.jsonString()) - _id = task._id - title.postValue(task.title) - done.postValue(task.done) - } catch (e: DittoException) { + task?.let { + _id = it._id + _title.value = it.title + _done.value = it.done + } + } catch (e: Throwable) { Log.e(TAG, "Unable to setup view task data", e) } } @@ -53,35 +66,25 @@ class EditScreenViewModel : ViewModel() { fun save() { viewModelScope.launch { try { - val titleValue = title.value ?: "" - val doneValue = done.value ?: false + val titleValue = _title.value + val doneValue = _done.value if (_id == null) { // Add tasks into the ditto collection using DQL INSERT statement // https://docs.ditto.live/sdk/latest/crud/write#inserting-documents - val addMap = DittoCborSerializable.Dictionary( - mapOf( - Utf8String("title") to Utf8String(titleValue), - Utf8String("done") to DittoCborSerializable.BooleanValue(doneValue), - Utf8String("deleted") to DittoCborSerializable.BooleanValue(false) - ) - ) ditto.store.execute( "INSERT INTO tasks DOCUMENTS (:doc)", - DittoCborSerializable.Dictionary( - mapOf(Utf8String("doc") to addMap) + mapOf( + "doc" to mapOf( + "title" to titleValue, + "done" to doneValue, + "deleted" to false + ) ) ) } else { - // Update tasks into the ditto collection using DQL UPDATE statement + // Update tasks in the ditto collection using DQL UPDATE statement // https://docs.ditto.live/sdk/latest/crud/update#updating _id?.let { id -> - val editMap = DittoCborSerializable.Dictionary( - mapOf( - Utf8String("title") to Utf8String(titleValue), - Utf8String("done") to DittoCborSerializable.BooleanValue(doneValue), - Utf8String("id") to DittoCborSerializable.Utf8String(id) - ) - ) ditto.store.execute( """ UPDATE tasks @@ -91,7 +94,11 @@ class EditScreenViewModel : ViewModel() { WHERE _id = :id AND NOT deleted """, - arguments = editMap + mapOf( + "title" to titleValue, + "done" to doneValue, + "id" to id + ) ) } } @@ -109,11 +116,7 @@ class EditScreenViewModel : ViewModel() { _id?.let { id -> ditto.store.execute( "UPDATE tasks SET deleted = true WHERE _id = :id", - DittoCborSerializable.Dictionary( - mapOf( - Utf8String("id") to Utf8String(id) - ) - ) + mapOf("id" to id) ) } } catch (e: Throwable) { diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreen.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreen.kt index 502156daa..1a59dc39e 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreen.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -39,6 +38,7 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import live.ditto.quickstart.tasks.BuildConfig @@ -50,8 +50,8 @@ import java.util.UUID @Composable fun TasksListScreen(navController: NavController) { val tasksListViewModel: TasksListScreenViewModel = viewModel() - val tasks: List by tasksListViewModel.tasks.observeAsState(emptyList()) - val syncEnabled: Boolean by tasksListViewModel.syncEnabled.observeAsState(true) + val tasks: List by tasksListViewModel.tasks.collectAsStateWithLifecycle() + val syncEnabled: Boolean by tasksListViewModel.syncEnabled.collectAsStateWithLifecycle() var showDeleteDialog by remember { mutableStateOf(false) } var deleteDialogTaskId by remember { mutableStateOf("") } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt index 819fe8ee5..b41e36fdd 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt @@ -5,22 +5,21 @@ import android.util.Log import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStore -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ditto.kotlin.DittoSyncSubscription +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import live.ditto.quickstart.tasks.DittoHandler.Companion.ditto import live.ditto.quickstart.tasks.TasksApplication import live.ditto.quickstart.tasks.data.Task -import com.ditto.kotlin.serialization.DittoCborSerializable -import com.ditto.kotlin.serialization.DittoCborSerializable.Utf8String -import okio.Utf8 - // The value of the Sync switch is stored in persistent settings private val Context.preferencesDataStore by preferencesDataStore("tasks_list_settings") private val SYNC_ENABLED_KEY = booleanPreferencesKey("sync_enabled") @@ -35,10 +34,14 @@ class TasksListScreenViewModel : ViewModel() { private val preferencesDataStore = TasksApplication.applicationContext().preferencesDataStore - val tasks: MutableLiveData> = MutableLiveData(emptyList()) + // Use StateFlow with store.observe() for reactive updates + // https://docs.ditto.live/sdk/latest/crud/observing-data-changes#setting-up-store-observers + val tasks: StateFlow> = ditto.store.observe(QUERY) { result -> + result.items.map { item -> Task.fromJson(item.jsonString()) } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - private val _syncEnabled = MutableLiveData(true) - val syncEnabled: LiveData = _syncEnabled + private val _syncEnabled = MutableStateFlow(true) + val syncEnabled: StateFlow = _syncEnabled.asStateFlow() private var syncSubscription: DittoSyncSubscription? = null @@ -49,11 +52,11 @@ class TasksListScreenViewModel : ViewModel() { } _syncEnabled.value = enabled - if (enabled && !ditto.isSyncActive) { + if (enabled && !ditto.sync.isActive) { try { - // starting sync + // Starting sync // https://docs.ditto.live/sdk/latest/sync/start-and-stop-sync - ditto.startSync() + ditto.sync.start() // Register a subscription, which determines what data syncs to this peer // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions @@ -61,11 +64,11 @@ class TasksListScreenViewModel : ViewModel() { } catch (e: Throwable) { Log.e(TAG, "Unable to start sync", e) } - } else if (ditto.isSyncActive) { + } else if (!enabled && ditto.sync.isActive) { try { syncSubscription?.close() syncSubscription = null - ditto.stopSync() + ditto.sync.stop() } catch (e: Throwable) { Log.e(TAG, "Unable to stop sync", e) } @@ -74,7 +77,6 @@ class TasksListScreenViewModel : ViewModel() { } init { - // Defensive check - should never fail with synchronous initialization check(live.ditto.quickstart.tasks.DittoHandler.isInitialized) { "Ditto must be initialized before ViewModels are created" } @@ -82,15 +84,6 @@ class TasksListScreenViewModel : ViewModel() { viewModelScope.launch { populateTasksCollection() - // Register observer, which runs against the local database on this peer - // https://docs.ditto.live/sdk/latest/crud/observing-data-changes#setting-up-store-observers - ditto.store.registerObserver(QUERY) { result -> - val list = result.items.map { - item -> Task.fromJson(item.jsonString()) - } - tasks.postValue(list) - } - setSyncEnabled( preferencesDataStore.data.map { prefs -> prefs[SYNC_ENABLED_KEY] ?: true }.first() ) @@ -98,39 +91,24 @@ class TasksListScreenViewModel : ViewModel() { } // Add initial tasks to the collection if they have not already been added. - private fun populateTasksCollection() { - viewModelScope.launch { - val tasks = listOf( - Task("50191411-4C46-4940-8B72-5F8017A04FA7", "Buy groceries"), - Task("6DA283DA-8CFE-4526-A6FA-D385089364E5", "Clean the kitchen"), - Task("5303DDF8-0E72-4FEB-9E82-4B007E5797F0", "Schedule dentist appointment"), - Task("38411F1B-6B49-4346-90C3-0B16CE97E174", "Pay bills") - ) - - tasks.forEach { task -> - try { - // Add tasks into the ditto collection using DQL INSERT statement - // https://docs.ditto.live/sdk/latest/crud/write#inserting-documents - val addMap = DittoCborSerializable.Dictionary( - mapOf( - Utf8String("_id") to Utf8String(task._id), - Utf8String("title") to Utf8String(task.title), - Utf8String("done") to DittoCborSerializable.BooleanValue(task.done), - Utf8String("deleted") to DittoCborSerializable.BooleanValue(task.deleted) - - ) - ) - ditto.store.execute( - "INSERT INTO tasks INITIAL DOCUMENTS (:task)", - DittoCborSerializable.Dictionary( - mapOf( - Utf8String("task") to addMap - ) - ) - ) - } catch (e: Throwable) { - Log.e(TAG, "Unable to insert initial document", e) - } + private suspend fun populateTasksCollection() { + val tasks = listOf( + Task("50191411-4C46-4940-8B72-5F8017A04FA7", "Buy groceries"), + Task("6DA283DA-8CFE-4526-A6FA-D385089364E5", "Clean the kitchen"), + Task("5303DDF8-0E72-4FEB-9E82-4B007E5797F0", "Schedule dentist appointment"), + Task("38411F1B-6B49-4346-90C3-0B16CE97E174", "Pay bills") + ) + + tasks.forEach { task -> + try { + // Add tasks into the ditto collection using DQL INSERT statement + // https://docs.ditto.live/sdk/latest/crud/write#inserting-documents + ditto.store.execute( + "INSERT INTO tasks INITIAL DOCUMENTS (:task)", + mapOf("task" to task.toMap()) + ) + } catch (e: Throwable) { + Log.e(TAG, "Unable to insert initial document", e) } } } @@ -138,28 +116,21 @@ class TasksListScreenViewModel : ViewModel() { fun toggle(taskId: String) { viewModelScope.launch { try { - val doc = ditto.store.execute( + val task = ditto.store.execute( "SELECT * FROM tasks WHERE _id = :_id AND NOT deleted", - DittoCborSerializable.Dictionary( - mapOf( - Utf8String("_id") to Utf8String(taskId) - ) - ) - ).items.first() - - val done = doc.value["done"].boolean + mapOf("_id" to taskId) + ) { result -> + result.items.firstOrNull()?.let { Task.fromJson(it.jsonString()) } + } - // Update tasks into the ditto collection using DQL UPDATE statement - // https://docs.ditto.live/sdk/latest/crud/update#updating - ditto.store.execute( - "UPDATE tasks SET done = :toggled WHERE _id = :_id AND NOT deleted", - DittoCborSerializable.Dictionary( - mapOf( - Utf8String("toggled") to DittoCborSerializable.BooleanValue(!done), - Utf8String("_id") to Utf8String(taskId) - ) + task?.let { + // Update tasks in the ditto collection using DQL UPDATE statement + // https://docs.ditto.live/sdk/latest/crud/update#updating + ditto.store.execute( + "UPDATE tasks SET done = :toggled WHERE _id = :_id AND NOT deleted", + mapOf("toggled" to !it.done, "_id" to taskId) ) - ) + } } catch (e: Throwable) { Log.e(TAG, "Unable to toggle done state", e) } @@ -173,11 +144,7 @@ class TasksListScreenViewModel : ViewModel() { // https://docs.ditto.live/sdk/latest/crud/delete#soft-delete-pattern ditto.store.execute( "UPDATE tasks SET deleted = true WHERE _id = :id", - DittoCborSerializable.Dictionary( - mapOf( - Utf8String("id") to Utf8String(taskId) - ) - ) + mapOf("id" to taskId) ) } catch (e: Throwable) { Log.e(TAG, "Unable to set deleted=true", e) diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Theme.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Theme.kt index 877540455..6cdb3b02d 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Theme.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package live.ditto.quickstart.tasks.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -9,12 +8,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( primary = Purple80, @@ -55,20 +49,6 @@ fun QuickStartTasksTheme( else -> LightColorScheme } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - // Set the status bar color to match the theme - window.statusBarColor = Color.Transparent.toArgb() - // Set the navigation bar color to match the theme - window.navigationBarColor = if (darkTheme) Color.Black.toArgb() else Color.White.toArgb() - // Set the system bar icons to be light or dark based on the theme - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme - WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme - } - } - MaterialTheme( colorScheme = colorScheme, typography = Typography, diff --git a/android-kotlin/QuickStartTasks/gradle/libs.versions.toml b/android-kotlin/QuickStartTasks/gradle/libs.versions.toml index 9be277c03..d6a0204d4 100644 --- a/android-kotlin/QuickStartTasks/gradle/libs.versions.toml +++ b/android-kotlin/QuickStartTasks/gradle/libs.versions.toml @@ -2,20 +2,21 @@ agp = "8.9.3" kotlin = "2.1.0" coreKtx = "1.16.0" +lifecycleRuntimeCompose = "2.10.0" junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" -lifecycleRuntimeKtx = "2.9.2" -activityCompose = "1.10.1" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.13.0" composeBom = "2025.07.00" navigationCompose = "2.9.2" -runtimeLivedata = "1.8.3" +runtimeLivedata = "1.10.6" appcompat = "1.7.1" -datastorePreferences = "1.1.7" +datastorePreferences = "1.2.1" koin-bom = "4.1.0" coroutines-tests = "1.10.2" -ditto = "5.0.0-dev-weekly.20260126.180" -monitor = "1.7.2" +ditto = "5.0.0-rc.3" +monitor = "1.8.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -36,6 +37,7 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } androidx-appcompat = { module = "androidx.appcompat:appcompat", name = "appcompat", version.ref = "appcompat" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", name = "datastore-preferences", version.ref = "datastorePreferences" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin-bom" } koin-core = { group = "io.insert-koin", name = "koin-core" } koin-android = { group = "io.insert-koin", name = "koin-android" } diff --git a/swift/Tasks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/Tasks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9d0559ad6..389c720ed 100644 --- a/swift/Tasks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/swift/Tasks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getditto/DittoSwiftPackage", "state" : { - "revision" : "c68c60c68ca4783248466781fd7607e1e59af198", - "version" : "4.13.1" + "revision" : "61b203cffc6a85c1c0d0987d1833d62eca7c549c", + "version" : "4.14.3" } } ], From 8baf8c0b5c1e14b07a4f8d51b164037562edf378 Mon Sep 17 00:00:00 2001 From: Aaron LaBeau <80424345+biozal@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:45:56 -0500 Subject: [PATCH 3/8] Updated to try and fix CI/CD Pipeline --- android-kotlin/QuickStartTasks/app/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android-kotlin/QuickStartTasks/app/build.gradle.kts b/android-kotlin/QuickStartTasks/app/build.gradle.kts index a4bbf9c15..c2a027a23 100644 --- a/android-kotlin/QuickStartTasks/app/build.gradle.kts +++ b/android-kotlin/QuickStartTasks/app/build.gradle.kts @@ -43,9 +43,10 @@ androidComponents { ) buildConfigFields.forEach { (key, description) -> + val rawValue = prop[key]?.toString()?.trim('"') ?: "" it.buildConfigFields.put( key, - BuildConfigField("String", "${prop[key]}", description) + BuildConfigField("String", "\"$rawValue\"", description) ) } } From 633807b7775844907690f592e78106ecf83143df Mon Sep 17 00:00:00 2001 From: Aaron LaBeau Date: Tue, 5 May 2026 16:01:00 -0500 Subject: [PATCH 4/8] updated to v5 and improved code based on feedback --- android-kotlin/QuickStartTasks/.gitignore | 1 + .../QuickStartTasks/app/build.gradle.kts | 27 ++++-- .../ditto/quickstart/tasks/TasksUITest.kt | 82 +++++++++--------- .../ditto/quickstart/tasks/MainActivity.kt | 29 +++++-- .../live/ditto/quickstart/tasks/data/Task.kt | 10 +-- .../ditto/quickstart/tasks/edit/EditForm.kt | 44 +++++----- .../ditto/quickstart/tasks/edit/EditScreen.kt | 33 ++++++-- .../tasks/edit/EditScreenViewModel.kt | 11 ++- .../ditto/quickstart/tasks/list/TaskRow.kt | 7 +- .../quickstart/tasks/list/TasksListScreen.kt | 68 +++++++++------ .../tasks/list/TasksListScreenViewModel.kt | 84 ++++++++++--------- .../ditto/quickstart/tasks/ui/theme/Color.kt | 9 +- .../ditto/quickstart/tasks/ui/theme/Theme.kt | 39 ++++----- .../app/src/main/res/values/colors.xml | 4 +- .../app/src/main/res/values/strings.xml | 27 +++++- .../ditto/quickstart/tasks/ExampleUnitTest.kt | 17 ---- .../ditto/quickstart/tasks/data/TaskTest.kt | 72 ++++++++++++++++ .../QuickStartTasks/gradle/libs.versions.toml | 17 ++-- android-kotlin/README.md | 10 ++- 19 files changed, 381 insertions(+), 210 deletions(-) delete mode 100644 android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/ExampleUnitTest.kt create mode 100644 android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/data/TaskTest.kt diff --git a/android-kotlin/QuickStartTasks/.gitignore b/android-kotlin/QuickStartTasks/.gitignore index aa724b770..a5b729879 100644 --- a/android-kotlin/QuickStartTasks/.gitignore +++ b/android-kotlin/QuickStartTasks/.gitignore @@ -7,6 +7,7 @@ /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml +.kotlin/ .DS_Store /build /captures diff --git a/android-kotlin/QuickStartTasks/app/build.gradle.kts b/android-kotlin/QuickStartTasks/app/build.gradle.kts index c2a027a23..076ec1820 100644 --- a/android-kotlin/QuickStartTasks/app/build.gradle.kts +++ b/android-kotlin/QuickStartTasks/app/build.gradle.kts @@ -1,6 +1,7 @@ import com.android.build.api.variant.BuildConfigField import java.io.FileInputStream import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) @@ -84,19 +85,28 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } - - kotlinOptions { - jvmTarget = "11" + + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } } - + buildFeatures { buildConfig = true compose = true } - + + testOptions { + unitTests { + // Lets host-JVM tests call android.util.Log without "Method not mocked" errors. + isReturnDefaultValues = true + } + } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -119,6 +129,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.navigation.compose) // Dependency Injection @@ -134,6 +145,8 @@ dependencies { // Testing testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines) + // Real org.json on the host JVM — Android's stub throws "not mocked". + testImplementation(libs.json) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt b/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt index 80b74e28a..31b8c20fe 100644 --- a/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt +++ b/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt @@ -1,16 +1,20 @@ package live.ditto.quickstart.tasks -import androidx.compose.ui.test.* +import androidx.compose.ui.test.assertExists +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNode import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.assertTrue /** * UI tests for the Tasks application targeting BrowserStack device testing. + * + * Must run against an emulator or physical device. The test does NOT silently pass + * when run without a compose hierarchy — that would mask real failures in CI. */ @RunWith(AndroidJUnit4::class) class TasksUITest { @@ -20,50 +24,44 @@ class TasksUITest { @Test fun testDocumentSyncAndVerification() { - // Get test document title from BrowserStack instrumentationOptions, BuildConfig, or fallback - val args = InstrumentationRegistry.getArguments() - val fromInstrumentation = args?.getString("DITTO_CLOUD_TASK_TITLE") - val fromBuildConfig = try { - BuildConfig.TEST_DOCUMENT_TITLE - } catch (e: NoSuchFieldError) { - null - } catch (e: ExceptionInInitializerError) { - null + val testDocumentTitle = resolveTestDocumentTitle() + + composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = SYNC_TIMEOUT_MS) { + composeTestRule + .onAllNodes(hasText(testDocumentTitle)) + .fetchSemanticsNodes() + .isNotEmpty() } - val testDocumentTitle = fromInstrumentation?.takeIf { it.isNotEmpty() } - ?: fromBuildConfig?.takeIf { it.isNotEmpty() } - ?: throw IllegalStateException("No test document title provided. Expected via instrumentationOptions 'DITTO_CLOUD_TASK_TITLE' or BuildConfig.TEST_DOCUMENT_TITLE") + composeTestRule + .onNode(hasText(testDocumentTitle)) + .assertExists("Document with title '$testDocumentTitle' should exist in the task list") + } - try { - // Wait for app initialization and Ditto sync with intelligent polling - composeTestRule.waitForIdle() - composeTestRule.waitUntil( - condition = { - composeTestRule.onAllNodes(hasText(testDocumentTitle)).fetchSemanticsNodes().isNotEmpty() - }, - timeoutMillis = 18000 // Wait up to 18 seconds for app init and Ditto sync - ) + /** + * Resolves the document title we expect Ditto to sync down. Prefer the + * instrumentation argument (set by BrowserStack), then BuildConfig fallback. + */ + private fun resolveTestDocumentTitle(): String { + val fromInstrumentation = InstrumentationRegistry.getArguments() + ?.getString(INSTRUMENTATION_ARG) + ?.takeIf { it.isNotEmpty() } + if (fromInstrumentation != null) return fromInstrumentation - // Final verification that document exists - composeTestRule - .onNode(hasText(testDocumentTitle)) - .assertExists("Document with title '$testDocumentTitle' should exist in the task list") + val fromBuildConfig = runCatching { BuildConfig.TEST_DOCUMENT_TITLE } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + if (fromBuildConfig != null) return fromBuildConfig - println("✅ DOCUMENT FOUND: '$testDocumentTitle'") + throw IllegalStateException( + "No test document title provided. Expected via instrumentationOptions " + + "'$INSTRUMENTATION_ARG' or BuildConfig.TEST_DOCUMENT_TITLE" + ) + } - } catch (e: IllegalStateException) { - if (e.message?.contains("No compose hierarchies found") == true) { - // Local environment fallback - validate parameter passing works - println("⚠️ Local environment: UI not available, validating parameter passing") - assertTrue("Environment variable retrieval should work", testDocumentTitle.isNotEmpty()) - println("✅ DOCUMENT PARAMETER VALIDATED: '$testDocumentTitle'") - } else { - throw e - } - } catch (e: AssertionError) { - println("❌ DOCUMENT NOT FOUND: '$testDocumentTitle'") - throw e - } + companion object { + private const val INSTRUMENTATION_ARG = "DITTO_CLOUD_TASK_TITLE" + private const val SYNC_TIMEOUT_MS = 18_000L } -} \ No newline at end of file +} diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt index 500685545..297e82e9b 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/MainActivity.kt @@ -4,9 +4,24 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import com.ditto.kotlin.DittoLog import com.ditto.kotlin.transports.DittoSyncPermissions class MainActivity : ComponentActivity() { + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + val denied = results.filterValues { granted -> !granted }.keys + if (denied.isNotEmpty()) { + DittoLog.w( + TAG, + "Sync transport permissions denied: $denied. P2P discovery may be limited." + ) + } + } + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -18,12 +33,16 @@ class MainActivity : ComponentActivity() { requestMissingPermissions() } + // Requesting permissions at runtime + // https://docs.ditto.live/sdk/latest/install-guides/kotlin#requesting-permissions-at-runtime private fun requestMissingPermissions() { - // Requesting permissions at runtime - // https://docs.ditto.live/sdk/latest/install-guides/kotlin#requesting-permissions-at-runtime - val missingPermissions = DittoSyncPermissions(this).missingPermissions() - if (missingPermissions.isNotEmpty()) { - this.requestPermissions(missingPermissions, 0) + val missing = DittoSyncPermissions(this).missingPermissions() + if (missing.isNotEmpty()) { + permissionLauncher.launch(missing) } } + + companion object { + private const val TAG = "MainActivity" + } } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/data/Task.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/data/Task.kt index 43b5ccc30..b9b48e59d 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/data/Task.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/data/Task.kt @@ -25,14 +25,14 @@ data class Task( return try { val json = JSONObject(jsonString) Task( - _id = json["_id"].toString(), - title = json["title"].toString(), - done = json["done"] as Boolean, - deleted = json["deleted"] as Boolean + _id = json.optString("_id", UUID.randomUUID().toString()), + title = json.optString("title", ""), + done = json.optBoolean("done", false), + deleted = json.optBoolean("deleted", false) ) } catch (e: JSONException) { Log.e(TAG, "Unable to convert JSON to Task", e) - Task(title = "", done = false, deleted = false) + Task(title = "") } } } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditForm.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditForm.kt index 4f808905b..89183b7ad 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditForm.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditForm.kt @@ -1,13 +1,23 @@ package live.ditto.quickstart.tasks.edit -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import live.ditto.quickstart.tasks.R @Composable fun EditForm( @@ -20,7 +30,7 @@ fun EditForm( onDeleteButtonClicked: (() -> Unit)? = null, ) { Column(modifier = Modifier.padding(16.dp)) { - Text(text = "Title:") + Text(text = stringResource(id = R.string.edit_field_title)) TextField( value = title, onValueChange = { onTitleTextChange?.invoke(it) }, @@ -32,37 +42,33 @@ fun EditForm( modifier = Modifier .fillMaxWidth() .padding(bottom = 12.dp), - Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween ) { - Text(text = "Is Complete:") + Text(text = stringResource(id = R.string.edit_field_is_complete)) Switch(checked = done, onCheckedChange = { onDoneChanged?.invoke(it) }) } Button( - onClick = { - onSaveButtonClicked?.invoke() - }, + onClick = { onSaveButtonClicked?.invoke() }, modifier = Modifier .padding(bottom = 12.dp) .fillMaxWidth(), ) { Text( - text = "Save", + text = stringResource(id = R.string.action_save), modifier = Modifier.padding(8.dp) ) } if (canDelete) { Button( - onClick = { - onDeleteButtonClicked?.invoke() - }, + onClick = { onDeleteButtonClicked?.invoke() }, colors = ButtonDefaults.buttonColors( - containerColor = Color.Red, - contentColor = Color.White), - modifier = Modifier - .fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ), + modifier = Modifier.fillMaxWidth(), ) { Text( - text = "Delete", + text = stringResource(id = R.string.action_delete), modifier = Modifier.padding(8.dp) ) } @@ -76,5 +82,5 @@ fun EditForm( ) @Composable fun EditFormPreview() { - EditForm(canDelete = true, "Hello") + EditForm(canDelete = true, title = "Hello") } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreen.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreen.kt index f3c122d89..aad9777c2 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreen.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreen.kt @@ -4,15 +4,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController @@ -22,9 +23,15 @@ import live.ditto.quickstart.tasks.R @Composable fun EditScreen(navController: NavController, taskId: String?) { val editScreenViewModel: EditScreenViewModel = viewModel() - editScreenViewModel.setupWithTask(id = taskId) - val topBarTitle = if (taskId == null) "New Task" else "Edit Task" + // Run side effects only when taskId actually changes, not on every recomposition. + LaunchedEffect(taskId) { + editScreenViewModel.setupWithTask(id = taskId) + } + + val topBarTitle = stringResource( + id = if (taskId == null) R.string.edit_title_new else R.string.edit_title_edit + ) val title: String by editScreenViewModel.title.collectAsStateWithLifecycle() val done: Boolean by editScreenViewModel.done.collectAsStateWithLifecycle() @@ -33,16 +40,24 @@ fun EditScreen(navController: NavController, taskId: String?) { Scaffold( topBar = { TopAppBar( - title = { Text(topBarTitle, color = Color.White) }, + title = { + Text( + text = topBarTitle, + color = MaterialTheme.colorScheme.onPrimary + ) + }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = colorResource(id = R.color.blue_700) + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary ) ) }, content = { padding -> - Column(modifier = Modifier - .fillMaxSize() - .padding(padding)) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { EditForm( canDelete = canDelete, title = title, diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt index 5d61e71b6..c261d7bb9 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/edit/EditScreenViewModel.kt @@ -7,11 +7,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import live.ditto.quickstart.tasks.DittoHandler import live.ditto.quickstart.tasks.DittoHandler.Companion.ditto import live.ditto.quickstart.tasks.data.Task class EditScreenViewModel : ViewModel() { + init { + check(DittoHandler.isInitialized) { + "Ditto must be initialized before ViewModels are created" + } + } + companion object { private const val TAG = "EditScreenViewModel" } @@ -36,10 +43,6 @@ class EditScreenViewModel : ViewModel() { } fun setupWithTask(id: String?) { - check(live.ditto.quickstart.tasks.DittoHandler.isInitialized) { - "Ditto must be initialized before ViewModels are created" - } - _canDelete.value = id != null val taskId: String = id ?: return diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TaskRow.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TaskRow.kt index c43885895..af3837076 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TaskRow.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TaskRow.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview @@ -51,7 +52,7 @@ fun TaskRow( ImageVector.vectorResource( id = iconId ), - "Toggle", + contentDescription = stringResource(id = R.string.cd_toggle_done), colorFilter = ColorFilter.tint(colorResource(id = color)), modifier = Modifier .clickable { onToggle?.invoke(task) }, @@ -63,13 +64,13 @@ fun TaskRow( IconButton(onClick = { onClickEdit?.invoke(task) }) { Icon( imageVector = Icons.Outlined.Edit, - contentDescription = "Delete" + contentDescription = stringResource(id = R.string.action_edit) ) } IconButton(onClick = { onClickDelete?.invoke(task) }) { Icon( imageVector = Icons.Filled.Delete, - contentDescription = "Delete", + contentDescription = stringResource(id = R.string.action_delete) ) } } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreen.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreen.kt index 1a59dc39e..505fa3622 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreen.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreen.kt @@ -31,13 +31,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController @@ -67,31 +64,39 @@ fun TasksListScreen(navController: NavController) { ) { Column { Text( - text = "Ditto Tasks", + text = stringResource(id = R.string.tasks_title), style = MaterialTheme.typography.titleMedium ) - Text( - text = "App ID: ${BuildConfig.DITTO_APP_ID}", - style = TextStyle(fontSize = 10.sp) - ) - Text( - text = "Token: ${BuildConfig.DITTO_PLAYGROUND_TOKEN}", - style = TextStyle(fontSize = 10.sp) - ) + if (BuildConfig.DEBUG) { + Text( + text = stringResource( + id = R.string.tasks_app_id, + BuildConfig.DITTO_APP_ID + ), + style = MaterialTheme.typography.labelSmall + ) + Text( + text = stringResource( + id = R.string.tasks_token, + BuildConfig.DITTO_PLAYGROUND_TOKEN + ), + style = MaterialTheme.typography.labelSmall + ) + } } } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = colorResource(id = R.color.blue_700), - titleContentColor = Color.White + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary ), actions = { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "Sync", + text = stringResource(id = R.string.tasks_sync_label), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(end = 10.dp), - color = Color.White + color = MaterialTheme.colorScheme.onPrimary ) Switch( checked = syncEnabled, @@ -105,11 +110,22 @@ fun TasksListScreen(navController: NavController) { }, floatingActionButton = { ExtendedFloatingActionButton( - icon = { Icon(Icons.Filled.Add, "", tint = Color.White) }, - text = { Text(text = "New Task", color = Color.White) }, + icon = { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + }, + text = { + Text( + text = stringResource(id = R.string.tasks_new), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + }, onClick = { navController.navigate("tasks/edit") }, elevation = FloatingActionButtonDefaults.elevation(8.dp), - containerColor = colorResource(id = R.color.blue_500) + containerColor = MaterialTheme.colorScheme.primaryContainer ) }, floatingActionButtonPosition = FabPosition.End, @@ -141,18 +157,18 @@ fun TasksListScreen(navController: NavController) { icon = { Icon( imageVector = Icons.Filled.Warning, - contentDescription = "Warning", + contentDescription = stringResource(id = R.string.cd_warning), tint = MaterialTheme.colorScheme.error ) }, title = { Text( - text = "Confirm Deletion", + text = stringResource(id = R.string.tasks_confirm_delete_title), style = MaterialTheme.typography.titleLarge ) }, text = { - Text(text = "Are you sure you want to delete this item?") + Text(text = stringResource(id = R.string.tasks_confirm_delete_message)) }, confirmButton = { TextButton( @@ -161,12 +177,12 @@ fun TasksListScreen(navController: NavController) { tasksListViewModel.delete(deleteDialogTaskId) } ) { - Text("Delete") + Text(stringResource(id = R.string.action_delete)) } }, dismissButton = { TextButton(onClick = { showDeleteDialog = false }) { - Text("Cancel") + Text(stringResource(id = R.string.action_cancel)) } } ) @@ -181,7 +197,7 @@ fun TasksList( onClickDelete: ((taskId: String) -> Unit)? = null, ) { LazyColumn { - items(tasks) { task -> + items(tasks, key = { it._id }) { task -> TaskRow( task = task, onToggle = { onToggle?.invoke(it._id) }, diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt index b41e36fdd..90787b247 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/list/TasksListScreenViewModel.kt @@ -8,14 +8,13 @@ import androidx.datastore.preferences.preferencesDataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ditto.kotlin.DittoSyncSubscription -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import live.ditto.quickstart.tasks.DittoHandler import live.ditto.quickstart.tasks.DittoHandler.Companion.ditto import live.ditto.quickstart.tasks.TasksApplication import live.ditto.quickstart.tasks.data.Task @@ -26,6 +25,13 @@ private val SYNC_ENABLED_KEY = booleanPreferencesKey("sync_enabled") class TasksListScreenViewModel : ViewModel() { + // Verify Ditto readiness before any property initializer below touches it. + init { + check(DittoHandler.isInitialized) { + "Ditto must be initialized before ViewModels are created" + } + } + companion object { private const val TAG = "TasksListScreenViewModel" @@ -40,53 +46,55 @@ class TasksListScreenViewModel : ViewModel() { result.items.map { item -> Task.fromJson(item.jsonString()) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - private val _syncEnabled = MutableStateFlow(true) - val syncEnabled: StateFlow = _syncEnabled.asStateFlow() + // Derive sync state directly from DataStore so the UI reflects the persisted value + // as soon as it loads, instead of flashing a hardcoded default first. + val syncEnabled: StateFlow = preferencesDataStore.data + .map { prefs -> prefs[SYNC_ENABLED_KEY] ?: true } + .stateIn(viewModelScope, SharingStarted.Eagerly, true) private var syncSubscription: DittoSyncSubscription? = null + init { + viewModelScope.launch { populateTasksCollection() } + + // Apply the persisted sync preference whenever it changes. + viewModelScope.launch { + preferencesDataStore.data + .map { prefs -> prefs[SYNC_ENABLED_KEY] ?: true } + .distinctUntilChanged() + .collect { enabled -> applySyncState(enabled) } + } + } + fun setSyncEnabled(enabled: Boolean) { viewModelScope.launch { preferencesDataStore.edit { settings -> settings[SYNC_ENABLED_KEY] = enabled } - _syncEnabled.value = enabled - - if (enabled && !ditto.sync.isActive) { - try { - // Starting sync - // https://docs.ditto.live/sdk/latest/sync/start-and-stop-sync - ditto.sync.start() - - // Register a subscription, which determines what data syncs to this peer - // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions - syncSubscription = ditto.sync.registerSubscription(QUERY) - } catch (e: Throwable) { - Log.e(TAG, "Unable to start sync", e) - } - } else if (!enabled && ditto.sync.isActive) { - try { - syncSubscription?.close() - syncSubscription = null - ditto.sync.stop() - } catch (e: Throwable) { - Log.e(TAG, "Unable to stop sync", e) - } - } } } - init { - check(live.ditto.quickstart.tasks.DittoHandler.isInitialized) { - "Ditto must be initialized before ViewModels are created" - } - - viewModelScope.launch { - populateTasksCollection() + private fun applySyncState(enabled: Boolean) { + if (enabled && !ditto.sync.isActive) { + try { + // Starting sync + // https://docs.ditto.live/sdk/latest/sync/start-and-stop-sync + ditto.sync.start() - setSyncEnabled( - preferencesDataStore.data.map { prefs -> prefs[SYNC_ENABLED_KEY] ?: true }.first() - ) + // Register a subscription, which determines what data syncs to this peer + // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions + syncSubscription = ditto.sync.registerSubscription(QUERY) + } catch (e: Throwable) { + Log.e(TAG, "Unable to start sync", e) + } + } else if (!enabled && ditto.sync.isActive) { + try { + syncSubscription?.close() + syncSubscription = null + ditto.sync.stop() + } catch (e: Throwable) { + Log.e(TAG, "Unable to stop sync", e) + } } } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Color.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Color.kt index ddfb06fe6..4cacfa672 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Color.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Color.kt @@ -8,4 +8,11 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +// Brand blues — keep in sync with res/values/colors.xml +val Blue200 = Color(0xFF93C5FD) +val Blue500 = Color(0xFF3B82F6) +val Blue700 = Color(0xFF1D4ED8) +val Blue900 = Color(0xFF0B255B) +val NeutralGray = Color(0xFF95A5A6) diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Theme.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Theme.kt index 6cdb3b02d..6f385867c 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Theme.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/ui/theme/Theme.kt @@ -8,35 +8,32 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), + primary = Blue700, onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ + primaryContainer = Blue500, + onPrimaryContainer = Color.White, + secondary = Blue500, + onSecondary = Color.White +) + +private val DarkColorScheme = darkColorScheme( + primary = Blue200, + onPrimary = Blue900, + primaryContainer = Blue700, + onPrimaryContainer = Blue200, + secondary = Blue200, + onSecondary = Blue900 ) @Composable fun QuickStartTasksTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, + // Dynamic color (Android 12+) is opt-in; brand colors win by default. + dynamicColor: Boolean = false, content: @Composable () -> Unit ) { val colorScheme = when { @@ -54,4 +51,4 @@ fun QuickStartTasksTheme( typography = Typography, content = content ) -} \ No newline at end of file +} diff --git a/android-kotlin/QuickStartTasks/app/src/main/res/values/colors.xml b/android-kotlin/QuickStartTasks/app/src/main/res/values/colors.xml index f6bff0fa6..dbb99436f 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/res/values/colors.xml +++ b/android-kotlin/QuickStartTasks/app/src/main/res/values/colors.xml @@ -11,6 +11,6 @@ #93C5FD #3B82F6 - #3B82F6 - #95a5a6 + #1D4ED8 + #95A5A6 diff --git a/android-kotlin/QuickStartTasks/app/src/main/res/values/strings.xml b/android-kotlin/QuickStartTasks/app/src/main/res/values/strings.xml index e737a7ae9..31da33a0a 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/res/values/strings.xml +++ b/android-kotlin/QuickStartTasks/app/src/main/res/values/strings.xml @@ -1,3 +1,28 @@ QS Tasks - \ No newline at end of file + + + Ditto Tasks + App ID: %1$s + Token: %1$s + Sync + New Task + Confirm Deletion + Are you sure you want to delete this item? + + + New Task + Edit Task + Title: + Is Complete: + + + Save + Delete + Cancel + Edit + + + Warning + Toggle done + diff --git a/android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/ExampleUnitTest.kt b/android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/ExampleUnitTest.kt deleted file mode 100644 index b7e96934b..000000000 --- a/android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package live.ditto.quickstart.tasks - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/data/TaskTest.kt b/android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/data/TaskTest.kt new file mode 100644 index 000000000..1ff2c2d67 --- /dev/null +++ b/android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/data/TaskTest.kt @@ -0,0 +1,72 @@ +package live.ditto.quickstart.tasks.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TaskTest { + + @Test + fun fromJson_roundTrips_throughToMap() { + val original = Task( + _id = "abc-123", + title = "Buy milk", + done = true, + deleted = false + ) + + val json = mapToJsonString(original.toMap()) + val parsed = Task.fromJson(json) + + assertEquals(original, parsed) + } + + @Test + fun fromJson_handlesMalformedJson_withFallback() { + val parsed = Task.fromJson("not json at all") + + // Falls back to an empty-titled task with a generated id rather than throwing. + assertEquals("", parsed.title) + assertFalse(parsed.done) + assertFalse(parsed.deleted) + assertTrue(parsed._id.isNotEmpty()) + } + + @Test + fun fromJson_missingFields_useDefaults() { + // Previously, missing "done"/"deleted" raised ClassCastException via `as Boolean`, + // bypassing the JSONException catch. Now optBoolean returns the default. + val parsed = Task.fromJson("""{"_id":"x","title":"only-title"}""") + + assertEquals("x", parsed._id) + assertEquals("only-title", parsed.title) + assertFalse(parsed.done) + assertFalse(parsed.deleted) + } + + @Test + fun fromJson_typeMismatch_doesNotCrash() { + // "done" delivered as the string "true" — optBoolean coerces, so we don't crash. + val parsed = Task.fromJson( + """{"_id":"x","title":"t","done":"true","deleted":"false"}""" + ) + + assertEquals("x", parsed._id) + assertEquals("t", parsed.title) + assertTrue(parsed.done) + assertFalse(parsed.deleted) + } + + @Test + fun defaultId_isUnique() { + val a = Task(title = "first") + val b = Task(title = "second") + + assertNotEquals(a._id, b._id) + } + + private fun mapToJsonString(map: Map): String = + org.json.JSONObject(map).toString() +} diff --git a/android-kotlin/QuickStartTasks/gradle/libs.versions.toml b/android-kotlin/QuickStartTasks/gradle/libs.versions.toml index d6a0204d4..e14417738 100644 --- a/android-kotlin/QuickStartTasks/gradle/libs.versions.toml +++ b/android-kotlin/QuickStartTasks/gradle/libs.versions.toml @@ -1,22 +1,23 @@ [versions] agp = "8.9.3" -kotlin = "2.1.0" -coreKtx = "1.16.0" +kotlin = "2.3.20" +coreKtx = "1.18.0" lifecycleRuntimeCompose = "2.10.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" -composeBom = "2025.07.00" -navigationCompose = "2.9.2" -runtimeLivedata = "1.10.6" +composeBom = "2026.04.01" +navigationCompose = "2.9.8" +runtimeLivedata = "1.11.0" appcompat = "1.7.1" datastorePreferences = "1.2.1" -koin-bom = "4.1.0" +koin-bom = "4.2.1" coroutines-tests = "1.10.2" -ditto = "5.0.0-rc.3" +ditto = "5.0.0" monitor = "1.8.0" +json = "20240303" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -33,6 +34,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", name = "navigation-compose", version.ref = "navigationCompose" } androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } androidx-appcompat = { module = "androidx.appcompat:appcompat", name = "appcompat", version.ref = "appcompat" } @@ -46,6 +48,7 @@ koin-androidx-compose-navigation = { group = "io.insert-koin", name = "koin-andr com-ditto = { group = "com.ditto", name = "ditto-kotlin", version.ref = "ditto" } kotlinx-coroutines = {group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines-tests" } androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" } +json = { group = "org.json", name = "json", version.ref = "json" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android-kotlin/README.md b/android-kotlin/README.md index 5b7379d14..3b3a448be 100644 --- a/android-kotlin/README.md +++ b/android-kotlin/README.md @@ -13,7 +13,7 @@ After you have completed the [common prerequisites] you will need the following: ## Documentation - [Kotlin Install Guide](https://docs.ditto.live/install-guides/kotlin) -- [Kotlin API Reference](https://software.ditto.live/android/Ditto/4.11.1/api-reference/) +- [Kotlin API Reference](https://software.ditto.live/android/Ditto/5.0.0/api-reference/) - [Kotlin SDK Release Notes](https://docs.ditto.live/release-notes/kotlin) [common prerequisites]: https://github.com/getditto/quickstart#common-prerequisites @@ -23,8 +23,8 @@ After you have completed the [common prerequisites] you will need the following: Assuming you have Android Studio and other prerequisites installed, you can build and run the app by following these steps: -1. Create an application at . Make note of the app ID and online playground token. -2. Copy the `.env.sample` file at the top level of the `quickstart` repo to `.env` and add your App ID, Online Playground Token, Auth URL, and Websocket URL. +1. Create an application at . Make note of the database ID (used to be called app ID) and online playground token. +2. Copy the `.env.sample` file at the top level of the `quickstart` repo to `.env` and add your Database ID (used to be called App ID), Online Playground Token, and Auth URL. 3. Launch Android Studio and open the `quickstart/android-kotlin` directory. 4. In Android Studio, select a connected Android device, or create and launch an Android emulator and select it as the destination, then choose the **Run > Run 'app'** menu item. @@ -101,3 +101,7 @@ It is implemented in associated view model is in `app/src/main/java/live/ditto/quickstart/tasks/list/edit/EditScreenViewModel.kt`, and this contains the associated code for manipulating data in the Ditto store. + + +### Android CLI, Android Skills, and AI +You can use the Android CLI, and Android Skills to programmatically interact with the Android device or emulator and with theDitto SDK running on it. See the [Android CLI Overview](https://developer.android.com/tools/agents/android-cli) for more information. Android Skills can be found [here](https://github.com/android/skills). \ No newline at end of file From e1a56ebc2a8f92f779000256541bb050fc49a2bd Mon Sep 17 00:00:00 2001 From: Aaron LaBeau <80424345+biozal@users.noreply.github.com> Date: Tue, 5 May 2026 16:10:15 -0500 Subject: [PATCH 5/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- android-kotlin/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android-kotlin/README.md b/android-kotlin/README.md index 3b3a448be..515577b59 100644 --- a/android-kotlin/README.md +++ b/android-kotlin/README.md @@ -104,4 +104,4 @@ and this contains the associated code for manipulating data in the Ditto store. ### Android CLI, Android Skills, and AI -You can use the Android CLI, and Android Skills to programmatically interact with the Android device or emulator and with theDitto SDK running on it. See the [Android CLI Overview](https://developer.android.com/tools/agents/android-cli) for more information. Android Skills can be found [here](https://github.com/android/skills). \ No newline at end of file +You can use the Android CLI, and Android Skills to programmatically interact with the Android device or emulator and with the Ditto SDK running on it. See the [Android CLI Overview](https://developer.android.com/tools/agents/android-cli) for more information. Android Skills can be found [here](https://github.com/android/skills). \ No newline at end of file From 67736ccee0130b6d69a79f67f3b1512bae04ef25 Mon Sep 17 00:00:00 2001 From: Aaron LaBeau <80424345+biozal@users.noreply.github.com> Date: Tue, 5 May 2026 16:10:42 -0500 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../main/java/live/ditto/quickstart/tasks/TasksApplication.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt index 4745805cb..29393e39c 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt @@ -4,7 +4,6 @@ import android.app.Application import android.content.Context import com.ditto.kotlin.DittoAuthenticationProvider import com.ditto.kotlin.DittoConfig -import com.ditto.kotlin.DittoFactory import com.ditto.kotlin.DittoLog class TasksApplication : Application() { From 1c64349df12c822f4d064c4e496b9f18a2a38b34 Mon Sep 17 00:00:00 2001 From: Aaron LaBeau <80424345+biozal@users.noreply.github.com> Date: Tue, 5 May 2026 16:11:39 -0500 Subject: [PATCH 7/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../QuickStartTasks/app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android-kotlin/QuickStartTasks/app/src/main/res/values/strings.xml b/android-kotlin/QuickStartTasks/app/src/main/res/values/strings.xml index 31da33a0a..8b6ca7bd1 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/res/values/strings.xml +++ b/android-kotlin/QuickStartTasks/app/src/main/res/values/strings.xml @@ -3,7 +3,7 @@ Ditto Tasks - App ID: %1$s + Database ID: %1$s Token: %1$s Sync New Task From 74a4305507f915ecbb838615ff1842d8010839aa Mon Sep 17 00:00:00 2001 From: Aaron LaBeau Date: Tue, 5 May 2026 16:17:16 -0500 Subject: [PATCH 8/8] updated to fix PR comments --- android-kotlin/QuickStartTasks/app/build.gradle.kts | 8 +++----- .../java/live/ditto/quickstart/tasks/TasksUITest.kt | 2 -- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/android-kotlin/QuickStartTasks/app/build.gradle.kts b/android-kotlin/QuickStartTasks/app/build.gradle.kts index 076ec1820..6f7782f93 100644 --- a/android-kotlin/QuickStartTasks/app/build.gradle.kts +++ b/android-kotlin/QuickStartTasks/app/build.gradle.kts @@ -17,10 +17,9 @@ fun loadEnvProperties(): Properties { FileInputStream(envFile).use { properties.load(it) } } else { val requiredEnvVars = listOf( - "DITTO_APP_ID", - "DITTO_PLAYGROUND_TOKEN", - "DITTO_AUTH_URL", - "DITTO_WEBSOCKET_URL" + "DITTO_APP_ID", + "DITTO_PLAYGROUND_TOKEN", + "DITTO_AUTH_URL" ) for (envVar in requiredEnvVars) { @@ -39,7 +38,6 @@ androidComponents { "DITTO_APP_ID" to "Ditto application ID", "DITTO_PLAYGROUND_TOKEN" to "Ditto playground token", "DITTO_AUTH_URL" to "Ditto authentication URL", - "DITTO_WEBSOCKET_URL" to "Ditto websocket URL", "TEST_DOCUMENT_TITLE" to "Test document title for BrowserStack verification" ) diff --git a/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt b/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt index 31b8c20fe..77def0216 100644 --- a/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt +++ b/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt @@ -1,9 +1,7 @@ package live.ditto.quickstart.tasks -import androidx.compose.ui.test.assertExists import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNode import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Rule