Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
40 changes: 25 additions & 15 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 Down Expand Up @@ -43,25 +44,26 @@ androidComponents {
)

buildConfigFields.forEach { (key, description) ->
val rawValue = prop[key]?.toString()?.trim('"') ?: ""
it.buildConfigFields.put(
key,
BuildConfigField("String", "\"${prop[key]}\"", description)
BuildConfigField("String", "\"$rawValue\"", description)
)
}
Comment thread
biozal marked this conversation as resolved.
}
}

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 +85,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 +118,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 +129,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 +140,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,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 {
Expand All @@ -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()
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