diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b5f09750d..52e6cd9f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -820,4 +820,6 @@ This often occurs with apps installed via Google Play or other sources, if they Expand Collapse + F-Droid is under threat. Google is changing the way you install apps on your phone. We need your help. + https://keepandroidopen.org diff --git a/legacy/src/main/java/org/fdroid/fdroid/Preferences.java b/legacy/src/main/java/org/fdroid/fdroid/Preferences.java index 1dbfb6d77..1f01003bf 100644 --- a/legacy/src/main/java/org/fdroid/fdroid/Preferences.java +++ b/legacy/src/main/java/org/fdroid/fdroid/Preferences.java @@ -115,6 +115,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh public static final String PREF_USE_IPFS_GATEWAYS = "useIpfsGateways"; public static final String PREF_IPFSGW_DISABLED_DEFAULTS_LIST = "ipfsGwDisabledDefaultsList"; public static final String PREF_IPFSGW_USER_LIST = "ipfsGwUserList"; + public static final String PREF_CALL_TO_ACTION_DISMISSED = "callToActionDismissed"; public static final String PREF_EXPERT = "expert"; public static final String PREF_FORCE_OLD_INDEX = "forceOldIndex"; public static final String PREF_FORCE_OLD_INSTALLER = "forceOldInstaller"; @@ -716,6 +717,14 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh ).apply(); } + public boolean getCallToActionDismissed() { + return preferences.getBoolean(PREF_CALL_TO_ACTION_DISMISSED, false); + } + + public void setCallToActionDismissed() { + preferences.edit().putBoolean(PREF_CALL_TO_ACTION_DISMISSED, true).apply(); + } + public List getIpfsGwDisabledDefaults() { return Utils.parseJsonStringArray(preferences.getString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, "[]")); } diff --git a/legacy/src/main/java/org/fdroid/fdroid/Utils.kt b/legacy/src/main/java/org/fdroid/fdroid/Utils.kt index 2c1e2002b..014990f18 100644 --- a/legacy/src/main/java/org/fdroid/fdroid/Utils.kt +++ b/legacy/src/main/java/org/fdroid/fdroid/Utils.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.unit.Dp import androidx.core.view.DisplayCompat import androidx.core.view.ViewCompat @@ -89,6 +90,15 @@ fun PaddingValues.copy( ) } +// Duplicated from the `app` module. +fun UriHandler.openUriSafe(uri: String) { + try { + openUri(uri) + } catch (e: Exception) { + Log.e("UriHandler", "Error opening $uri ", e) + } +} + class UiUtils { companion object { /** diff --git a/legacy/src/main/java/org/fdroid/fdroid/views/CallToActionBanner.kt b/legacy/src/main/java/org/fdroid/fdroid/views/CallToActionBanner.kt new file mode 100644 index 000000000..06c195239 --- /dev/null +++ b/legacy/src/main/java/org/fdroid/fdroid/views/CallToActionBanner.kt @@ -0,0 +1,154 @@ +package org.fdroid.fdroid.views + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import org.fdroid.fdroid.Preferences +import org.fdroid.fdroid.R +import org.fdroid.fdroid.openUriSafe +import org.fdroid.fdroid.ui.theme.FDroidContent +import java.util.Calendar + +/** + * Expire at the start of September 2026, to align with the countdown on keepandroidopen.org. + * + * The call to action regarding Google Developer Verification and keepandroidopen.org should not + * persist forever. Rather, we will only show it for a month or two. + * This is in the vain hope that something changes and Google decides to let us install the software + * we choose on our own device. If that were to happen, we don't want to keep showing this. + * + * @param now Allow this to be passed in for testing purposes. + */ +fun hasCallToActionExpired(now: Long = System.currentTimeMillis()): Boolean { + val calendar = Calendar.getInstance().apply { + set(2026, 8, 1) // 8 = Sept (0-based month) + } + val expiry = calendar.timeInMillis + val hasExpired = now > expiry + + return hasExpired +} + +/** + * Broadcast a call to action message to end users. + * + * This should only be used in exceptional circumstances. For example, it was first + * introduced to bring users attention to the Developer verification mandate which + * threatens the very existence of F-Droid. + */ +@Composable +fun CallToActionBanner(dismissed: Boolean, onDismiss: () -> Unit) { + var showBanner by remember { mutableStateOf(!dismissed) } + val uriHandler = LocalUriHandler.current + val url = stringResource(R.string.call_to_action_keepandroidopen_url) + + val handleDismiss = { + onDismiss() + showBanner = false + } + + AnimatedVisibility( + visible = showBanner, + exit = slideOutVertically(), + ) { + Row { + Row( + Modifier + .background(colorResource(R.color.call_to_action_banner__background)), + horizontalArrangement = spacedBy(0.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .selectable( + selected = false, + onClick = { uriHandler.openUriSafe(url) }, + ) + .padding(start = 6.dp, top = 6.dp, bottom = 6.dp), + ) { + Text( + text = stringResource(R.string.call_to_action_keepandroidopen), + textAlign = TextAlign.Center, + color = colorResource(R.color.call_to_action_banner__text), + style = TextStyle(lineHeight = 1.em), + ) + Text( + text = "https://keepandroidopen.org", + textAlign = TextAlign.Center, + color = colorResource(R.color.call_to_action_banner__link), + textDecoration = TextDecoration.Underline, + + // For some reason, setting the TextStyle on the above Text() makes the font size change. + // To make this have the same font, we add this empty TextStyle. + style = TextStyle(), + ) + } + + IconButton(onClick = handleDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = colorResource(R.color.call_to_action_banner__text), + ) + } + } + } + } +} + +/** + * A helper method to show [CallToActionBanner] from Java code. + */ +fun setContentCallToActionBanner(composeView: ComposeView) { + val dismissed = hasCallToActionExpired() || Preferences.get().callToActionDismissed + val onDismiss = { + Preferences.get().setCallToActionDismissed() + } + + composeView.setContent { + FDroidContent { + CallToActionBanner( + dismissed = dismissed, + onDismiss = onDismiss, + ) + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun CallToActionBannerPreview() { + FDroidContent(pureBlack = true) { + CallToActionBanner(onDismiss = {}, dismissed = false) + } +} diff --git a/legacy/src/main/java/org/fdroid/fdroid/views/main/LatestViewBinder.java b/legacy/src/main/java/org/fdroid/fdroid/views/main/LatestViewBinder.java index 0caa8557c..8df5dd1e4 100644 --- a/legacy/src/main/java/org/fdroid/fdroid/views/main/LatestViewBinder.java +++ b/legacy/src/main/java/org/fdroid/fdroid/views/main/LatestViewBinder.java @@ -7,6 +7,7 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; +import androidx.compose.ui.platform.ComposeView; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; @@ -29,6 +30,7 @@ import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.panic.HidingManager; +import org.fdroid.fdroid.views.CallToActionBannerKt; import org.fdroid.fdroid.views.apps.AppListActivity; import java.util.Arrays; @@ -68,6 +70,9 @@ class LatestViewBinder implements Observer>, ChangeListene View latestView = activity.getLayoutInflater().inflate(R.layout.main_tab_latest, parent, true); + ComposeView callToActionBanner = latestView.findViewById(R.id.call_to_action_banner); + CallToActionBannerKt.setContentCallToActionBanner(callToActionBanner); + latestAdapter = new LatestAdapter(activity); GridLayoutManager layoutManager = new GridLayoutManager(activity, 2); diff --git a/legacy/src/main/res/layout/main_tab_latest.xml b/legacy/src/main/res/layout/main_tab_latest.xml index 1c1ac2bec..f2fc2a7fa 100644 --- a/legacy/src/main/res/layout/main_tab_latest.xml +++ b/legacy/src/main/res/layout/main_tab_latest.xml @@ -15,6 +15,11 @@ android:layout_height="match_parent" android:orientation="vertical"> + + #ECEDF6 #E6E8F0 #E0E2EA + + #FFB9B9 + #540303 + #0000ff diff --git a/legacy/src/test/java/org/fdroid/fdroid/views/CallToActionBannerTest.kt b/legacy/src/test/java/org/fdroid/fdroid/views/CallToActionBannerTest.kt new file mode 100644 index 000000000..fc6690d35 --- /dev/null +++ b/legacy/src/test/java/org/fdroid/fdroid/views/CallToActionBannerTest.kt @@ -0,0 +1,23 @@ +package org.fdroid.fdroid.views + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Calendar + +class CallToActionBannerTest { + + @Test + fun testCallToActionExpiry() { + val notExpired = Calendar.getInstance().apply { + set(2026, 1, 1) + }.timeInMillis + assertFalse(hasCallToActionExpired(now = notExpired)) + + val expired = Calendar.getInstance().apply { + set(2026, 11, 1) + }.timeInMillis + assertTrue(hasCallToActionExpired(now = expired)) + } + +}