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))
+ }
+
+}