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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android-kotlin/QuickStartTasks/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.kotlin/
.DS_Store
/build
/captures
Expand Down
48 changes: 28 additions & 20 deletions android-kotlin/QuickStartTasks/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -16,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) {
Expand All @@ -38,30 +38,30 @@ 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"
)

buildConfigFields.forEach { (key, description) ->
val rawValue = prop[key]?.toString()?.trim('"') ?: ""
it.buildConfigFields.put(
key,
BuildConfigField("String", "\"${prop[key]}\"", description)
BuildConfigField("String", "\"$rawValue\"", description)
)
}
}
}

android {
namespace = "live.ditto.quickstart.tasks"
compileSdk = 35
compileSdk = 36

lint {
baseline = file("lint-baseline.xml")
}

defaultConfig {
applicationId = "live.ditto.quickstart.tasks"
minSdk = 23
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
Expand All @@ -83,23 +83,28 @@ android {
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = "1.8"

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

buildFeatures {
buildConfig = true
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"

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}"
Expand All @@ -111,6 +116,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)
Expand All @@ -121,8 +127,8 @@ 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)
implementation(libs.androidx.runtime.livedata)

// Dependency Injection
implementation(platform(libs.koin.bom))
Expand All @@ -132,11 +138,13 @@ dependencies {
implementation(libs.koin.androidx.compose.navigation)

// Ditto SDK
implementation(libs.live.ditto)
implementation(libs.com.ditto)

// 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package live.ditto.quickstart.tasks

import androidx.compose.ui.test.*
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
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 {
Expand All @@ -20,50 +22,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()
Comment thread
biozal marked this conversation as resolved.
}

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
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,28 @@ package live.ditto.quickstart.tasks
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import live.ditto.transports.DittoSyncPermissions
import android.os.StrictMode
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import com.ditto.kotlin.DittoLog
import com.ditto.kotlin.transports.DittoSyncPermissions

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.penaltyLog() // Log violations to logcat
.build()
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)

setContent {
Root()
Expand All @@ -27,15 +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"
}
}
Loading
Loading