First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.fullscreenintent.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
}
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.fullscreenintent.api
sealed interface FullScreenIntentPermissionsEvents {
data object Dismiss : FullScreenIntentPermissionsEvents
data object OpenSettings : FullScreenIntentPermissionsEvents
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.fullscreenintent.api
data class FullScreenIntentPermissionsState(
val permissionGranted: Boolean,
val shouldDisplayBanner: Boolean,
val eventSink: (FullScreenIntentPermissionsEvents) -> Unit,
)
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.fullscreenintent.api
fun aFullScreenIntentPermissionsState(
permissionGranted: Boolean = true,
shouldDisplay: Boolean = false,
eventSink: (FullScreenIntentPermissionsEvents) -> Unit = {},
) = FullScreenIntentPermissionsState(
permissionGranted = permissionGranted,
shouldDisplayBanner = shouldDisplay,
eventSink = eventSink,
)
@@ -0,0 +1,38 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.fullscreenintent.impl"
}
setupDependencyInjection()
dependencies {
api(projects.libraries.fullscreenintent.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.libraries.preferences.api)
implementation(projects.services.toolbox.api)
implementation(libs.androidx.datastore.preferences)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.testtags)
testImplementation(projects.services.toolbox.test)
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.fullscreenintent.impl
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@SingleIn(AppScope::class)
@Inject
class FullScreenIntentPermissionsPresenter(
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
private val externalIntentLauncher: ExternalIntentLauncher,
private val buildMeta: BuildMeta,
private val notificationManagerCompat: NotificationManagerCompat,
preferencesDataStoreFactory: PreferenceDataStoreFactory,
) : Presenter<FullScreenIntentPermissionsState> {
companion object {
private const val PREF_KEY_FULL_SCREEN_INTENT_BANNER_DISMISSED = "PREF_KEY_FULL_SCREEN_INTENT_BANNER_DISMISSED"
}
private val dataStore = preferencesDataStoreFactory.create("full_screen_intent_permissions")
private val isFullScreenIntentBannerDismissed = dataStore.data.map { prefs ->
prefs[booleanPreferencesKey(PREF_KEY_FULL_SCREEN_INTENT_BANNER_DISMISSED)] ?: false
}
private suspend fun dismissFullScreenIntentBanner() {
dataStore.edit { prefs ->
prefs[booleanPreferencesKey(PREF_KEY_FULL_SCREEN_INTENT_BANNER_DISMISSED)] = true
}
}
@Composable
override fun present(): FullScreenIntentPermissionsState {
val coroutineScope = rememberCoroutineScope()
val isGranted = notificationManagerCompat.canUseFullScreenIntent()
val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true)
fun handleEvent(event: FullScreenIntentPermissionsEvents) {
when (event) {
FullScreenIntentPermissionsEvents.Dismiss -> coroutineScope.launch {
dismissFullScreenIntentBanner()
}
FullScreenIntentPermissionsEvents.OpenSettings -> openFullScreenIntentSettings()
}
}
return FullScreenIntentPermissionsState(
permissionGranted = isGranted,
shouldDisplayBanner = !isBannerDismissed && !isGranted,
eventSink = ::handleEvent,
)
}
private fun openFullScreenIntentSettings() {
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
try {
val intent = Intent(
Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
"package:${buildMeta.applicationId}".toUri()
)
externalIntentLauncher.launch(intent)
} catch (e: ActivityNotFoundException) {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, buildMeta.applicationId)
externalIntentLauncher.launch(intent)
}
}
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.fullscreenintent.impl.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter
@ContributesTo(AppScope::class)
@BindingContainer
interface FullScreenIntentModule {
@Binds
fun bindFullScreenIntentPermissionsPresenter(presenter: FullScreenIntentPermissionsPresenter): Presenter<FullScreenIntentPermissionsState>
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.fullscreenintent.test
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationManagerCompat
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
import io.element.android.services.toolbox.test.intent.FakeExternalIntentLauncher
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class FullScreenIntentPermissionsPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `shouldDisplay - is true when permission is not granted and banner is not dismissed`() = runTest {
val presenter = createPresenter(
notificationManagerCompat = mockk {
every { canUseFullScreenIntent() } returns false
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialItem = awaitItem()
assertThat(initialItem.shouldDisplayBanner).isTrue()
}
}
@Test
fun `shouldDisplay - is false if permission is granted`() = runTest {
val presenter = createPresenter(
notificationManagerCompat = mockk {
every { canUseFullScreenIntent() } returns true
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialItem = awaitItem()
assertThat(initialItem.shouldDisplayBanner).isFalse()
}
}
@Test
fun `dismissFullScreenIntentBanner - makes shouldDisplay false`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val loadedItem = awaitItem()
loadedItem.eventSink(FullScreenIntentPermissionsEvents.Dismiss)
runCurrent()
assertThat(awaitItem().shouldDisplayBanner).isFalse()
}
}
@Test
fun `openFullScreenIntentSettings - opens external screen using intent`() = runTest {
val launchLambda = lambdaRecorder<Intent, Unit> { _ -> }
val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda)
val presenter = createPresenter(externalIntentLauncher = externalIntentLauncher)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val loadedItem = awaitItem()
loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
launchLambda.assertions().isCalledOnce()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `openFullScreenIntentSettings - does nothing in old APIs`() = runTest {
val launchLambda = lambdaRecorder<Intent, Unit> { _ -> }
val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda)
val presenter = createPresenter(
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.Q),
externalIntentLauncher = externalIntentLauncher,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val loadedItem = awaitItem()
loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
launchLambda.assertions().isNeverCalled()
cancelAndIgnoreRemainingEvents()
}
}
private fun createPresenter(
buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
dataStoreFactory: FakePreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
externalIntentLauncher: ExternalIntentLauncher = FakeExternalIntentLauncher(),
buildMeta: BuildMeta = aBuildMeta(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true)
) = FullScreenIntentPermissionsPresenter(
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
externalIntentLauncher = externalIntentLauncher,
buildMeta = buildMeta,
preferencesDataStoreFactory = dataStoreFactory,
notificationManagerCompat = notificationManagerCompat,
)
}