Skip to content
Draft
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
24 changes: 24 additions & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ plugins {
id("io.github.takahirom.roborazzi")
alias(libs.plugins.compose.compiler)
alias(libs.plugins.screenshot)
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.kapt")
}

configureCompilerOptions()
Expand Down Expand Up @@ -53,6 +55,13 @@ android {
versionName = versionName()

resourceConfigurations += listOf("en", "fr")

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

ksp {
arg("appfunctions:aggregateAppFunctions", "true")
arg("appfunctions:generateMetadataFromSchema", "true")
}
}

signingConfigs {
Expand Down Expand Up @@ -198,6 +207,15 @@ dependencies {
implementation(libs.googleid)
implementation(libs.androidx.credentials.play.services.auth)

implementation(libs.androidx.appfunctions)
implementation(libs.androidx.appfunctions.service)
ksp(libs.androidx.appfunctions.compiler)
implementation(libs.androidx.appsearch)
implementation(libs.androidx.appsearch.ktx)
implementation(libs.androidx.appsearch.platform.storage)
implementation(libs.kotlinx.coroutines.guava)
kapt(libs.androidx.appsearch.compiler)

testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.compose.ui.test.junit4)
Expand All @@ -209,4 +227,10 @@ dependencies {
debugImplementation(libs.compose.ui.manifest)

screenshotTestImplementation(libs.androidx.compose.ui.tooling)

androidTestImplementation("androidx.test:core-ktx:1.6.1")
androidTestImplementation("androidx.test:rules:1.6.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
androidTestImplementation("androidx.test.ext:truth:1.6.0")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package dev.johnoreilly.confetti.appfunctions;

import android.content.Context
import androidx.appfunctions.AppFunctionData
import androidx.appfunctions.AppFunctionManagerCompat
import androidx.appfunctions.AppFunctionSearchSpec
import androidx.appfunctions.ExecuteAppFunctionRequest
import androidx.appfunctions.ExecuteAppFunctionResponse
import androidx.appsearch.app.GlobalSearchSession
import androidx.appsearch.app.SearchSpec
import androidx.appsearch.platformstorage.PlatformStorage
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assume.assumeNotNull
import org.junit.Before
import org.junit.Test
import kotlin.time.Duration.Companion.seconds

class IntegrationTest {
private val context = InstrumentationRegistry.getInstrumentation().context
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var appFunctionManager: AppFunctionManagerCompat
private val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation

@Before
fun setup(): Unit = runBlocking {
val appFunctionManagerCompatOrNull = AppFunctionManagerCompat.getInstance(targetContext)
assumeNotNull(appFunctionManagerCompatOrNull)
appFunctionManager = checkNotNull(appFunctionManagerCompatOrNull)

uiAutomation.apply {
adoptShellPermissionIdentity("android.permission.EXECUTE_APP_FUNCTIONS")
}
}

@Test
fun listAppSearchDocuments(): Unit = runBlocking {
val searchSession = createSearchSession(context)

repeat(10) {
val searchResults = searchSession.search(
"",
SearchSpec.Builder()
.addFilterPackageNames("dev.johnoreilly.confetti")
.build(),
)
var nextPage = searchResults.nextPageAsync.await()
if (nextPage.isNotEmpty()) {
while (nextPage.isNotEmpty()) {
for (result in nextPage) {
println(result.genericDocument)
}

nextPage = searchResults.nextPageAsync.await()
}
return@repeat
} else {
delay(1.seconds)
}
}

searchSession.close()
}

private suspend fun createSearchSession(context: Context): GlobalSearchSession {
return PlatformStorage.createGlobalSearchSessionAsync(
PlatformStorage.GlobalSearchContext.Builder(context).build()
)
.await()
}

@After
fun tearDown() {
uiAutomation.dropShellPermissionIdentity()
}

@Test
fun executeAppFunction_success(): Unit = runBlocking {
val result = appFunctionManager.observeAppFunctions(AppFunctionSearchSpec()).first()

result.forEach {
println(it)
}

val response =
appFunctionManager.executeAppFunction(
request =
ExecuteAppFunctionRequest(
targetContext.packageName,
"dev.johnoreilly.confetti.appfunctions.ConferenceAppFunctions#conferenceInfo",
AppFunctionData.Builder("").build()
)
)

when (response) {
is ExecuteAppFunctionResponse.Success -> {
val returnValue = response.returnValue
val genericDocument = returnValue.genericDocument.getPropertyDocument("androidAppfunctionsReturnValue")!!
println(genericDocument.getPropertyStringArray("title")?.toList())
println(genericDocument.getProperty("dates"))
println(genericDocument.propertyNames)
}
is ExecuteAppFunctionResponse.Error -> throw response.error
}
}
}
1 change: 0 additions & 1 deletion androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,5 @@
</activity>

<activity android:name=".car.signin.SignInWithGoogleActivity" />

</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
package dev.johnoreilly.confetti

import android.app.Application
import android.os.Build
import androidx.appfunctions.service.AppFunctionConfiguration
import androidx.appsearch.platformstorage.PlatformStorage
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.work.WorkManager
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.crashlytics.setCustomKeys
import com.google.firebase.ktx.Firebase
import dev.johnoreilly.confetti.appsearch.AppSearchManager
import dev.johnoreilly.confetti.di.appModule
import dev.johnoreilly.confetti.di.initKoin
import dev.johnoreilly.confetti.work.SessionNotificationSender
import dev.johnoreilly.confetti.work.SessionNotificationWorker
import dev.johnoreilly.confetti.work.setupDailyRefresh
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.workmanager.koin.workManagerFactory

class ConfettiApplication : Application() {
class ConfettiApplication : Application(), AppFunctionConfiguration.Provider {

private val isFirebaseInstalled
get() = try {
Expand Down Expand Up @@ -57,5 +62,12 @@ class ConfettiApplication : Application() {
ProcessLifecycleOwner.get().lifecycleScope.launch {
get<SessionNotificationSender>().updateSchedule()
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
get<AppSearchManager>().scheduleImmediate()
}
}

override val appFunctionConfiguration: AppFunctionConfiguration
get() = get<AppFunctionConfiguration>()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package dev.johnoreilly.confetti.appfunctions

import android.app.PendingIntent
import androidx.appfunctions.AppFunctionContext
import androidx.appfunctions.AppFunctionSchemaCapability
import androidx.appfunctions.AppFunctionSchemaDefinition
import androidx.appfunctions.AppFunctionSerializable
import androidx.appfunctions.service.AppFunction
import com.apollographql.cache.normalized.FetchPolicy
import dev.johnoreilly.confetti.ConfettiRepository
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.time.LocalDateTime


@AppFunctionSchemaCapability
interface OpenIntent {
val intentToOpen: PendingIntent?
}

@AppFunctionSchemaDefinition(name = "conferenceInfo", version = 1, category = "Conference")
interface ConferenceInfoSchemaDefinition {
suspend fun conferenceInfo(
appFunctionContext: AppFunctionContext,
): AppFunctionConference?
}

@AppFunctionSchemaDefinition(name = "findSessions", version = 1, category = "Sessions")
interface ConferenceSessionsSchemaDefinition {
suspend fun findSessions(
appFunctionContext: AppFunctionContext,
findSessionsParams: FindSessionsParams,
): List<AppFunctionSession>?
}

@AppFunctionSchemaDefinition(name = "listSpeakers", version = 1, category = "Speakers")
interface ConferenceSpeakersSchemaDefinition {
suspend fun listSpeakers(
appFunctionContext: AppFunctionContext,
): List<AppFunctionSpeaker>?
}

@AppFunctionSerializable
data class FindSessionsParams(
val speaker: String? = null,
)

@AppFunctionSerializable
data class AppFunctionSession(
val id: String,
val title: String,
val room: String,
val speakers: List<String>,
val time: LocalDateTime,
override val intentToOpen: PendingIntent? = null
): OpenIntent

@AppFunctionSerializable
data class AppFunctionSpeaker(
val id: String,
val name: String,
override val intentToOpen: PendingIntent? = null
): OpenIntent

@AppFunctionSerializable
data class AppFunctionConference(
val id: String,
val title: String,
val dates: List<LocalDateTime>,
override val intentToOpen: PendingIntent? = null
): OpenIntent

class ConferenceAppFunctions : KoinComponent, ConferenceInfoSchemaDefinition, ConferenceSessionsSchemaDefinition,
ConferenceSpeakersSchemaDefinition {
private val confettiRepository: ConfettiRepository by inject()

@AppFunction
override suspend fun conferenceInfo(appFunctionContext: AppFunctionContext): AppFunctionConference {
try {
val conference = "kotlinconf2025"//confettiRepository.getConference()
println("conferenceInfo, conference = $conference")
val details =
confettiRepository.conferenceData(conference, fetchPolicy = FetchPolicy.CacheFirst).data!!

return AppFunctionConference(
details.config.id,
details.config.name,
details.config.days.map { it.toJavaLocalDate().atStartOfDay() })
} catch (e: Exception) {
e.printStackTrace()
throw e
}
}

@AppFunction
override suspend fun findSessions(
appFunctionContext: AppFunctionContext,
findSessionsParams: FindSessionsParams
): List<AppFunctionSession> {
val conference = confettiRepository.getConference()
val sessions = confettiRepository.sessions(conference, null, null, FetchPolicy.CacheOnly).data?.sessions?.nodes?.map { it.sessionDetails }.orEmpty()

return sessions.map { session ->
AppFunctionSession(
session.id,
session.title,
session.room?.name ?: "",
session.speakers.map { it.speakerDetails.name },
session.startsAt.toJavaLocalDateTime(),
)
}
}

@AppFunction
override suspend fun listSpeakers(appFunctionContext: AppFunctionContext): List<AppFunctionSpeaker> {
val conference = confettiRepository.getConference()
val results =
confettiRepository.conferenceData(conference, fetchPolicy = FetchPolicy.CacheOnly).data?.speakers?.nodes?.map { it.speakerDetails }.orEmpty()

return results.map { speaker ->
AppFunctionSpeaker(
speaker.id,
speaker.name
)
}
}
}
Loading