diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 55a276a24a07b969b95ccd0d138232f8da5f836c..d3263795769e99961bf06ea8e363ab70a2ceba82 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -141,6 +141,8 @@ telemetry = { module = "foundation.e.lib:telemetry", version.ref = "telemetry" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }
viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
+ui = { group = "androidx.compose.ui", name = "ui" }
+ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workTesting" }
diff --git a/install-app-lib/installappdemo/.gitignore b/install-app-lib/installappdemo/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..42afabfd2abebf31384ca7797186a27a4b7dbee8
--- /dev/null
+++ b/install-app-lib/installappdemo/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/install-app-lib/installappdemo/build.gradle.kts b/install-app-lib/installappdemo/build.gradle.kts
new file mode 100644
index 0000000000000000000000000000000000000000..753c589430cca0d1b342605ecb1541134de8e392
--- /dev/null
+++ b/install-app-lib/installappdemo/build.gradle.kts
@@ -0,0 +1,63 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.compose.compiler)
+}
+
+android {
+ namespace = "foundation.e.apps.installapp.demo"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId = "foundation.e.apps.installapp.demo"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ signingConfigs {
+ create("platformConfig") {
+ storeFile = file("../../app/keystore/platform.jks")
+ storePassword = "platform"
+ keyAlias = "platform"
+ keyPassword = "platform"
+ }
+ }
+
+ buildTypes {
+ debug {
+ signingConfig = signingConfigs.get("platformConfig")
+ }
+
+ release {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get())
+ targetCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get())
+ }
+ kotlinOptions {
+ jvmTarget = libs.versions.jvmTarget.get()
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(project(":install-app-lib"))
+// implementation("foundation.e.apps:install-app-lib:1.0.0")
+
+ implementation(libs.core.ktx)
+ implementation(libs.lifecycle.runtime.ktx)
+ implementation(libs.activity.compose)
+ implementation(platform(libs.compose.bom))
+ implementation(libs.ui)
+ implementation(libs.ui.graphics)
+ implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.compose.material3)
+ debugImplementation(libs.compose.ui.tooling)
+ debugImplementation(libs.compose.ui.test.manifest)
+}
diff --git a/install-app-lib/installappdemo/src/main/AndroidManifest.xml b/install-app-lib/installappdemo/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3dd3efdb7cffe5f0f7b693c9a04032512e39e2f8
--- /dev/null
+++ b/install-app-lib/installappdemo/src/main/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/install-app-lib/installappdemo/src/main/java/foundation/e/apps/installapp/demo/MainActivity.kt b/install-app-lib/installappdemo/src/main/java/foundation/e/apps/installapp/demo/MainActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..32daded8463573d6eed094bc4061d667d21b9e65
--- /dev/null
+++ b/install-app-lib/installappdemo/src/main/java/foundation/e/apps/installapp/demo/MainActivity.kt
@@ -0,0 +1,162 @@
+package foundation.e.apps.installapp.demo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import foundation.e.apps.installapp.AppInstaller
+import foundation.e.apps.installapp.AppLoungeConfiguration
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+ private val progress = MutableStateFlow("Not-connected")
+ private val installStatus = MutableStateFlow(null)
+ private val appLoungeConfigurationFlow = MutableStateFlow(null)
+ private var installJob: Job? = null
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ InstallAppScreen(
+ modifier = Modifier.padding(innerPadding),
+ appLoungeConfigurationFlow,
+ progress,
+ installStatus,
+ ::installApp,
+ ::stopInstallApp
+ )
+ }
+ }
+
+ getAppLoungeConfiguration()
+ }
+
+ private fun installApp(packageName: String) {
+ installStatus.value = null
+ val appInstaller = AppInstaller(this)
+
+ installJob = lifecycleScope.launch {
+ installStatus.value = appInstaller.installByPackageName(packageName, {
+ progress.value = it.name
+ }).name
+ }
+ }
+
+ private fun stopInstallApp() {
+ installJob?.cancel()
+ }
+
+ private fun getAppLoungeConfiguration() {
+ val appInstaller = AppInstaller(this)
+ lifecycleScope.launch {
+ appLoungeConfigurationFlow.value = appInstaller.getAppLoungeConfiguration()
+ }
+ }
+}
+
+@Composable()
+fun InstallAppScreen(
+ modifier: Modifier,
+ appLoungeConfigurationFlow: Flow,
+ statusFlow: Flow,
+ installStatusFlow: Flow,
+ installApp: (String) -> Unit,
+ stopInstallApp: () -> Unit
+) {
+
+ var packageName by remember { mutableStateOf("") }
+ val status by statusFlow.collectAsState("not connected")
+ val installStatus by installStatusFlow.collectAsState(null)
+ val appLoungeConfiguration by appLoungeConfigurationFlow.collectAsState(null)
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = spacedBy(8.dp)
+ ) {
+ Text("AppLounge configuration")
+ appLoungeConfiguration?.let {
+ Text("GPlayAccountType:")
+ Text(
+ it.gPlayAccountType.name,
+ Modifier.layoutId("GPlayAccountType")
+ )
+ Text("SearchableSources:")
+ Text(
+ it.searchableSources.joinToString("-"),
+ Modifier.layoutId("SearchableSources"),
+ )
+ }
+
+ TextField(
+ value = packageName,
+ onValueChange = { packageName = it },
+ label = { Text(text = "Package name") },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Button(
+ onClick = { installApp(packageName) },
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp)
+ ) {
+ Text("Install")
+ }
+
+ Button(
+ onClick = {
+ stopInstallApp()
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp)
+ ) {
+ Text("STOP")
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp)
+ ) {
+ Text("Status: ")
+ Text(status, Modifier.layoutId("status"))
+ }
+ installStatus?.let {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp)
+ ) {
+ Text("Install status: ")
+ Text(it, Modifier.layoutId("InstallStatus"))
+ }
+ }
+ }
+}
diff --git a/install-app-lib/installappdemo/src/main/res/drawable/app_icon.xml b/install-app-lib/installappdemo/src/main/res/drawable/app_icon.xml
new file mode 100644
index 0000000000000000000000000000000000000000..51bc4f2137e6a9146b80eb09e53881e30238ed00
--- /dev/null
+++ b/install-app-lib/installappdemo/src/main/res/drawable/app_icon.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/maestro/install-app-lib/abort_install_apk.yaml b/maestro/install-app-lib/abort_install_apk.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..de4147b86312878d7cdd5bc43902d25ec2e9f370
--- /dev/null
+++ b/maestro/install-app-lib/abort_install_apk.yaml
@@ -0,0 +1,15 @@
+# Scenario: install-app-lib abort with EXPECTED_STATE on install app by PACKAGE_NAME
+# Given InstallAppDemo is installed
+# When user set PACKAGE_NAME in packageName field
+# And click Install
+# Then the status finally change to EXPECTED_STATE
+
+appId: foundation.e.apps.installapp.demo
+---
+
+- launchApp
+- tapOn: Package name
+- inputText: ${PACKAGE_NAME}
+- tapOn: Install
+
+- assertVisible: ${EXPECTED_STATE}
diff --git a/maestro/install-app-lib/check_applounge_configuration.yaml b/maestro/install-app-lib/check_applounge_configuration.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..8585903030236271ad88d54f03af85afda2c1457
--- /dev/null
+++ b/maestro/install-app-lib/check_applounge_configuration.yaml
@@ -0,0 +1,12 @@
+# Scenario: install-app-lib show AppLounge configuration
+# Given InstallAppDemo is installed
+# When open InstallAppDemo
+# Then GPlayAccountType shows GPLAY_ACCOUNT_TYPE
+# And SeachableSrouces shows SEARCHABLE_SOURCES
+
+appId: foundation.e.apps.installapp.demo
+---
+
+- launchApp
+- assertVisible: ${GPLAY_ACCOUNT_TYPE}
+- assertVisible: ${SEARCHABLE_SOURCES}
diff --git a/maestro/install-app-lib/first_start_anonymous.yaml b/maestro/install-app-lib/first_start_anonymous.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..9fa4013a8e5511f9b48f7ff9e3752255495cd469
--- /dev/null
+++ b/maestro/install-app-lib/first_start_anonymous.yaml
@@ -0,0 +1,9 @@
+appId: foundation.e.apps
+
+---
+
+- launchApp: foundation.e.apps
+- tapOn:
+ id: foundation.e.apps:id/agreeBT
+- tapOn:
+ id: foundation.e.apps:id/anonymousBT
diff --git a/maestro/install-app-lib/first_start_google_account.yaml b/maestro/install-app-lib/first_start_google_account.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..edd27259a0f61489a3d3047efc1ba986a114ecc1
--- /dev/null
+++ b/maestro/install-app-lib/first_start_google_account.yaml
@@ -0,0 +1,20 @@
+appId: foundation.e.apps
+
+---
+
+- launchApp: foundation.e.apps
+- tapOn:
+ id: foundation.e.apps:id/agreeBT
+- tapOn:
+ id: foundation.e.apps:id/googleBT
+- tapOn: PROCEED TO GOOGLE LOGIN
+- tapOn: Sign in with Google in AppLounge only
+- tapOn: Email or phone
+- inputText: ${GOOGLE_ACCOUNT}
+- tapOn: Next
+- extendedWaitUntil:
+ visible: Show password
+ timeout: 10000
+- inputText: ${GOOGLE_PASSWORD}
+- tapOn: Next
+- tapOn: I agree
diff --git a/maestro/install-app-lib/first_start_nogoogle.yaml b/maestro/install-app-lib/first_start_nogoogle.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..3bf81347b7ae82ae814260dc2996776d89782330
--- /dev/null
+++ b/maestro/install-app-lib/first_start_nogoogle.yaml
@@ -0,0 +1,9 @@
+appId: foundation.e.apps
+
+---
+
+- launchApp: foundation.e.apps
+- tapOn:
+ id: foundation.e.apps:id/agreeBT
+- tapOn:
+ id: foundation.e.apps:id/noGoogleBT
diff --git a/maestro/install-app-lib/install-cleanapk-app.yaml b/maestro/install-app-lib/install-cleanapk-app.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..139a9e350feed7ed390fc949436c09164f3b12d6
--- /dev/null
+++ b/maestro/install-app-lib/install-cleanapk-app.yaml
@@ -0,0 +1,11 @@
+appId: foundation.e.apps.installapp.demo
+env:
+ APK: "com.woefe.shoppinglist"
+ # "com.woefe.shoppinglist", "com.icedblueberry.shoppinglisteasy")
+---
+
+- launchApp: foundation.e.apps.installapp.demo
+- tapOn: Package name
+- inputText: com.woefe.shoppinglist
+- tapOn: Install
+- assertVisible: INSTALLED
diff --git a/maestro/install-app-lib/install_apk.yaml b/maestro/install-app-lib/install_apk.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..05b6f627b5c8d7ea43a7cdbd5032e71e0330327e
--- /dev/null
+++ b/maestro/install-app-lib/install_apk.yaml
@@ -0,0 +1,23 @@
+# Scenario: Install app by PACKAGE_NAME through install-app-lib
+# Given AppLounge is configured with anonymous or Google account
+# And InstallAppDemo is installed
+# And App with PACKAGE_NAME is not installed
+# When user set PACKAGE_NAME in packageName field
+# And click Install
+# Then the app starts to download
+# And status move to DOWNLOADING, INSTALLING and finally INSTALLED
+
+appId: foundation.e.apps.installapp.demo
+#env:
+# PACKAGE_NAME: "com.woefe.shoppinglist"
+# # "com.woefe.shoppinglist", "com.icedblueberry.shoppinglisteasy")
+---
+
+- launchApp
+- tapOn: Package name
+- inputText: ${PACKAGE_NAME}
+- tapOn: Install
+
+- assertVisible: DOWNLOADING
+- assertVisible: INSTALLING
+- assertVisible: INSTALLED
diff --git a/maestro/install-app-lib/run_cleanapk_tests.sh b/maestro/install-app-lib/run_cleanapk_tests.sh
new file mode 100755
index 0000000000000000000000000000000000000000..179e3f93b5c3a983f981516349c0f7a3c036c931
--- /dev/null
+++ b/maestro/install-app-lib/run_cleanapk_tests.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+
+cd "$(dirname "$0")"
+
+# Wake up the device if screen black (not password configured)
+screen_info=`adb shell dumpsys power | grep mHoldingDisplaySuspendBlocker`
+if [[ $screen_info == *mHoldingDisplaySuspendBlocker=false* ]]
+then
+ adb shell input keyevent 26
+fi
+
+adb shell input keyevent 82
+
+adb shell svc wifi enable
+## End wake up device.
+
+adb shell pm clear foundation.e.apps
+
+maestro test -e GPLAY_ACCOUNT_TYPE=NOT_CONFIGURED -e SEARCHABLE_SOURCES=PLAY_STORE-OPEN_SOURCE-PWA check_applounge_configuration.yaml || exit 1
+
+# Scenario: Return UNAVAILABLE when AppLounge not ready yet
+maestro test -e PACKAGE_NAME=no.an.existing.packangename -e EXPECTED_STATE=UNAVAILABLE abort_install_apk.yaml || exit 1
+
+# Scenario: Setup AppLounge with anonymous Google account
+maestro test first_start_nogoogle.yaml || exit 1
+
+# Scenario: AppLounge should be configured with no google now
+maestro test -e GPLAY_ACCOUNT_TYPE=NO_GOOGLE -e SEARCHABLE_SOURCES=OPEN_SOURCE-PWA check_applounge_configuration.yaml || exit 1
+
+# Scenario: Install FDroid App through install-app-lib
+adb uninstall com.woefe.shoppinglist
+maestro test -e PACKAGE_NAME=com.woefe.shoppinglist install_apk.yaml || exit 1
+
+# Scenario: Return UNAVAILABLE when app unavailable on stores
+maestro test -e PACKAGE_NAME=no.an.existing.packangename -e EXPECTED_STATE=UNAVAILABLE abort_install_apk.yaml || exit 1
+
+# Scenario: Install PWA App through install-app-lib
+maestro test -e PACKAGE_NAME=dice.richardekwonye.com -e EXPECTED_STATE=INSTALLED abort_install_apk.yaml || exit 1
+
+# Scenario: Return error when no network available
+adb shell svc wifi disable
+maestro test -e PACKAGE_NAME=no.an.existing.packangename -e EXPECTED_STATE=INSTALLATION_ISSUE abort_install_apk.yaml || exit 1
+
+adb shell svc wifi enable
diff --git a/maestro/install-app-lib/run_playstore_tests.sh b/maestro/install-app-lib/run_playstore_tests.sh
new file mode 100755
index 0000000000000000000000000000000000000000..43ea9d122b6d59cca1b4aae9805eb7b4305acf9c
--- /dev/null
+++ b/maestro/install-app-lib/run_playstore_tests.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+
+# Usage: with plugged in device, with AppLounge and applibdemo installed,
+# $ run_playstore_test GOOGLE_ACCOUNT GOOGLE_PASSWORD
+
+cd "$(dirname "$0")"
+
+GOOGLE_ACCOUNT=$1
+GOOGLE_PASSWORD=$2
+
+# Wake up the device if screen black (not password configured)
+screen_info=`adb shell dumpsys power | grep mHoldingDisplaySuspendBlocker`
+if [[ $screen_info == *mHoldingDisplaySuspendBlocker=false* ]]
+then
+ adb shell input keyevent 26
+fi
+
+adb shell input keyevent 82
+
+adb shell svc wifi enable
+## End wake up device.
+
+adb shell pm clear foundation.e.apps
+
+# Scenario: Setup AppLounge with anonymous Google account
+maestro test -e GOOGLE_ACCOUNT=$GOOGLE_ACCOUNT -e GOOGLE_PASSWORD=$GOOGLE_PASSWORD first_start_google_account.yaml || exit 1
+
+maestro test -e GPLAY_ACCOUNT_TYPE=GOOGLE -e SEARCHABLE_SOURCES=PLAY_STORE-OPEN_SOURCE-PWA check_applounge_configuration.yaml || exit 1
+
+# Scenario: Install PlayStore App through install-app-lib
+adb uninstall com.icedblueberry.shoppinglisteasy
+maestro test -e PACKAGE_NAME=com.icedblueberry.shoppinglisteasy install_apk.yaml || exit 1
+
+# Scenario: Return UNAVAILABLE when app unavailable on stores
+maestro test -e PACKAGE_NAME=no.an.existing.packangename -e EXPECTED_STATE=UNAVAILABLE abort_install_apk.yaml || exit 1
+
+# Scenario: Return error when app not purchased in AppLounge
+maestro test -e PACKAGE_NAME=com.MOBGames.PoppyMobileChap1 -e EXPECTED_STATE=PURCHASE_NEEDED abort_install_apk.yaml || exit 1
diff --git a/settings.gradle b/settings.gradle
index 3da8de1876216ce600f7a46ef5089e5d22416d4b..afa5364b27997672d608dcdaf88153a97f865cb0 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -65,3 +65,4 @@ include ':app'
include ':parental-control-data'
include ':auth-data-lib'
include ':install-app-lib'
+include ':install-app-lib:installappdemo'