diff --git a/app/build.gradle b/app/build.gradle index 5eb4e75321..cc55de2c7b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -167,6 +167,7 @@ android { buildConfigField "String", "GITHUB_LATEST_URI", "\"https://github.com/M66B/FairEmail/releases\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"https://api.bitbucket.org/2.0/repositories/M66B/fairemail-test/downloads\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"https://bitbucket.org/M66B/fairemail-test/downloads/\"" + buildConfigField "String", "ANNOUNCEMENT_URI", "\"https://gist.githubusercontent.com/M66B/d544192ca56224839d6ba0f2f6314c1f/raw/\"" buildConfigField "String", "TX_URI", localProperties.getProperty("paypal.uri", "\"\"") buildConfigField "String", "GPA_URI", localProperties.getProperty("gpa.uri", "\"\"") buildConfigField "String", "INFO_URI", localProperties.getProperty("info.uri", "\"\"") @@ -185,6 +186,7 @@ android { buildConfigField "String", "GITHUB_LATEST_URI", "\"https://github.com/M66B/FairEmail/releases\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"https://api.bitbucket.org/2.0/repositories/M66B/fairemail-test/downloads\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"https://bitbucket.org/M66B/fairemail-test/downloads/\"" + buildConfigField "String", "ANNOUNCEMENT_URI", "\"https://gist.githubusercontent.com/M66B/d544192ca56224839d6ba0f2f6314c1f/raw/\"" buildConfigField "String", "TX_URI", "\"\"" buildConfigField "String", "GPA_URI", "\"\"" buildConfigField "String", "INFO_URI", "\"\"" @@ -204,6 +206,7 @@ android { buildConfigField "String", "GITHUB_LATEST_URI", "\"\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"\"" + buildConfigField "String", "ANNOUNCEMENT_URI", "\"\"" buildConfigField "String", "TX_URI", "\"\"" buildConfigField "String", "GPA_URI", "\"\"" buildConfigField "String", "INFO_URI", "\"\"" @@ -223,6 +226,7 @@ android { buildConfigField "String", "GITHUB_LATEST_URI", "\"\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"\"" + buildConfigField "String", "ANNOUNCEMENT_URI", "\"\"" buildConfigField "String", "TX_URI", "\"\"" buildConfigField "String", "GPA_URI", "\"\"" buildConfigField "String", "INFO_URI", "\"\"" diff --git a/app/src/main/java/eu/faircode/email/ActivityView.java b/app/src/main/java/eu/faircode/email/ActivityView.java index 2dc5f0e836..5c3ec01f0c 100644 --- a/app/src/main/java/eu/faircode/email/ActivityView.java +++ b/app/src/main/java/eu/faircode/email/ActivityView.java @@ -41,6 +41,7 @@ import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; +import android.text.Spanned; import android.text.TextUtils; import android.util.DisplayMetrics; import android.util.Pair; @@ -94,9 +95,12 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URL; +import java.text.DateFormat; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.concurrent.Callable; @@ -163,8 +167,9 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB static final int PI_THREAD = 3; static final int PI_OUTBOX = 4; static final int PI_UPDATE = 5; - static final int PI_WIDGET = 6; - static final int PI_POWER = 7; + static final int PI_ANNOUNCEMENT = 6; + static final int PI_WIDGET = 7; + static final int PI_POWER = 8; static final String ACTION_VIEW_FOLDERS = BuildConfig.APPLICATION_ID + ".VIEW_FOLDERS"; static final String ACTION_VIEW_MESSAGES = BuildConfig.APPLICATION_ID + ".VIEW_MESSAGES"; @@ -183,6 +188,9 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB static final long UPDATE_DAILY = (BuildConfig.BETA_RELEASE ? 4 : 12) * 3600 * 1000L; // milliseconds static final long UPDATE_WEEKLY = 7 * 24 * 3600 * 1000L; // milliseconds + private static final int ANNOUNCEMENT_TIMEOUT = 15 * 1000; // milliseconds + private static final long ANNOUNCEMENT_INTERVAL = 4 * 3600 * 1000L; // milliseconds + private static final int REQUEST_RULES_ACCOUNT = 2001; private static final int REQUEST_RULES_FOLDER = 2002; @@ -939,6 +947,7 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB if (!drawerLayout.isLocked(drawerContainer)) drawerLayout.closeDrawer(drawerContainer); checkUpdate(true); + checkAnnouncements(true); } return !play; } @@ -1090,6 +1099,7 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB owner.start(); checkUpdate(false); + checkAnnouncements(false); checkIntent(); } @@ -1734,6 +1744,150 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB }.execute(this, args, "update:check"); } + private void checkAnnouncements(boolean always) { + if (TextUtils.isEmpty(BuildConfig.ANNOUNCEMENT_URI)) + return; + + long now = new Date().getTime(); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + boolean announcements = prefs.getBoolean("announcements", true); + long last_announcement_check = prefs.getLong("last_announcement_check", 0); + + if (!always && !announcements) + return; + if (!always && last_announcement_check + ANNOUNCEMENT_INTERVAL > now) + return; + + prefs.edit().putLong("last_announcement_check", now).apply(); + + Bundle args = new Bundle(); + args.putBoolean("always", always); + + new SimpleTask>() { + @Override + protected List onExecute(Context context, Bundle args) throws Throwable { + StringBuilder response = new StringBuilder(); + HttpsURLConnection urlConnection = null; + try { + URL latest = new URL(BuildConfig.ANNOUNCEMENT_URI); + urlConnection = (HttpsURLConnection) latest.openConnection(); + urlConnection.setRequestMethod("GET"); + urlConnection.setReadTimeout(ANNOUNCEMENT_TIMEOUT); + urlConnection.setConnectTimeout(ANNOUNCEMENT_TIMEOUT); + urlConnection.setDoOutput(false); + ConnectionHelper.setUserAgent(context, urlConnection); + urlConnection.connect(); + + int status = urlConnection.getResponseCode(); + InputStream inputStream = (status == HttpsURLConnection.HTTP_OK + ? urlConnection.getInputStream() : urlConnection.getErrorStream()); + + if (inputStream != null) { + BufferedReader br = new BufferedReader(new InputStreamReader(inputStream)); + + String line; + while ((line = br.readLine()) != null) + response.append(line); + } + + if (status != HttpsURLConnection.HTTP_OK) + throw new IOException("HTTP " + status + ": " + response); + + DateFormat DTF = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US); + + List announcements = new ArrayList<>(); + + JSONObject jroot = new JSONObject(response.toString()); + JSONArray jannouncements = jroot.getJSONArray("Announcements"); + for (int i = 0; i < jannouncements.length(); i++) { + JSONObject jannouncement = jannouncements.getJSONObject(i); + + String language = Locale.getDefault().getLanguage(); + + String title = jannouncement.optString("Title." + language); + if (TextUtils.isEmpty(title)) + title = jannouncement.getString("Title"); + + String text = jannouncement.optString("Text." + language); + if (TextUtils.isEmpty(text)) + text = jannouncement.getString("Text"); + + Announcement announcement = new Announcement(); + announcement.id = jannouncement.getInt("ID"); + announcement.test = jannouncement.optBoolean("Test"); + announcement.title = title; + announcement.text = HtmlHelper.fromHtml(text, context); + if (jannouncement.has("Link")) + announcement.link = Uri.parse(jannouncement.getString("Link")); + announcement.expires = DTF.parse(jannouncement.getString("Expires") + .replace("Z", "+00:00")); + announcements.add(announcement); + } + + return announcements; + } finally { + if (urlConnection != null) + urlConnection.disconnect(); + } + } + + @Override + protected void onExecuted(Bundle args, List announcements) { + boolean always = args.getBoolean("always"); + + NotificationManager nm = + Helper.getSystemService(ActivityView.this, NotificationManager.class); + if (!NotificationHelper.areNotificationsEnabled(nm)) + return; + + SharedPreferences.Editor editor = prefs.edit(); + + for (Announcement announcement : announcements) { + String key = "announcement." + announcement.id; + if (announcement.isExpired()) { + editor.remove(key); + nm.cancel(announcement.id); + } else { + boolean notified = prefs.getBoolean(key, false); + if (notified && !always) + continue; + editor.putBoolean(key, true); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(ActivityView.this, "announcements") + .setSmallIcon(R.drawable.baseline_warning_white_24) + .setContentTitle(announcement.title) + .setContentText(announcement.text) + .setAutoCancel(true) + .setShowWhen(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setVisibility(NotificationCompat.VISIBILITY_SECRET); + + if (announcement.link != null) { + Intent update = new Intent(Intent.ACTION_VIEW, announcement.link) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + PendingIntent piUpdate = PendingIntentCompat.getActivity( + ActivityView.this, PI_ANNOUNCEMENT, update, PendingIntent.FLAG_UPDATE_CURRENT); + builder.setContentIntent(piUpdate); + } + + nm.notify(announcement.id, builder.build()); + } + } + + editor.apply(); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + if (args.getBoolean("always")) + Log.unexpectedError(getSupportFragmentManager(), ex); + } + }.execute(this, args, "announcements:check"); + } + private void checkIntent() { Intent intent = getIntent(); Log.i("View intent=" + intent + @@ -2288,6 +2442,23 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB String download_url; } + private class Announcement { + int id; + boolean test; + String title; + Spanned text; + Uri link; + Date expires; + + boolean isExpired() { + if (this.test && !BuildConfig.DEBUG) + return true; + if (expires == null) + return true; + return (expires.getTime() < new Date().getTime()); + } + } + public static class FragmentDialogFirst extends FragmentDialogBase { @NonNull @Override diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java b/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java index 95d43677ca..2b10400576 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsMisc.java @@ -147,6 +147,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private SwitchCompat swBeta; private TextView tvBitBucketPrivacy; private SwitchCompat swChangelog; + private SwitchCompat swAnnouncements; + private TextView tvAnnouncementsPrivacy; private SwitchCompat swCrashReports; private TextView tvUuid; private Button btnReset; @@ -238,6 +240,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc private Group grpSend; private Group grpUpdates; private Group grpBitbucket; + private Group grpAnnouncements; private Group grpTest; private CardView cardDebug; @@ -254,7 +257,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc "deepl_enabled", "vt_enabled", "vt_apikey", "send_enabled", "send_host", - "updates", "weekly", "beta", "show_changelog", + "updates", "weekly", "beta", "show_changelog", "announcements", "crash_reports", "cleanup_attachments", "watchdog", "experiments", "main_log", "main_log_memory", "protocol", "log_level", "debug", "leak_canary", "test1", "test2", "test3", "test4", "test5", @@ -291,7 +294,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc "gmail_checked", "outlook_checked", "redmi_note", "accept_space", "accept_unsupported", - "junk_hint" + "junk_hint", + "last_update_check", "last_announcement_check" }; @Override @@ -361,6 +365,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swBeta = view.findViewById(R.id.swBeta); tvBitBucketPrivacy = view.findViewById(R.id.tvBitBucketPrivacy); swChangelog = view.findViewById(R.id.swChangelog); + swAnnouncements = view.findViewById(R.id.swAnnouncements); + tvAnnouncementsPrivacy = view.findViewById(R.id.tvAnnouncementsPrivacy); swCrashReports = view.findViewById(R.id.swCrashReports); tvUuid = view.findViewById(R.id.tvUuid); btnReset = view.findViewById(R.id.btnReset); @@ -452,6 +458,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc grpSend = view.findViewById(R.id.grpSend); grpUpdates = view.findViewById(R.id.grpUpdates); grpBitbucket = view.findViewById(R.id.grpBitbucket); + grpAnnouncements = view.findViewById(R.id.grpAnnouncements); grpTest = view.findViewById(R.id.grpTest); cardDebug = view.findViewById(R.id.cardDebug); @@ -914,6 +921,21 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc } }); + swAnnouncements.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + prefs.edit().putBoolean("announcements", checked).apply(); + } + }); + + tvAnnouncementsPrivacy.getPaint().setUnderlineText(true); + tvAnnouncementsPrivacy.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Helper.view(v.getContext(), Uri.parse(Helper.GITHUB_PRIVACY_URI), true); + } + }); + swCrashReports.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -1890,6 +1912,9 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc (Helper.isPlayStoreInstall() || !Helper.hasValidFingerprint(getContext())) ? View.GONE : View.VISIBLE); grpBitbucket.setVisibility(View.GONE); + grpAnnouncements.setVisibility(!BuildConfig.DEBUG && + (Helper.isPlayStoreInstall() || !Helper.hasValidFingerprint(getContext())) + ? View.GONE : View.VISIBLE); grpTest.setVisibility(BuildConfig.TEST_RELEASE ? View.VISIBLE : View.GONE); setLastCleanup(prefs.getLong("last_cleanup", -1)); @@ -2021,12 +2046,16 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc SharedPreferences.Editor editor = prefs.edit(); if (cbGeneral.isChecked()) - for (String option : RESET_QUESTIONS) - editor.remove(option); + for (String key : RESET_QUESTIONS) + if (prefs.contains(key)) { + Log.i("Removing option=" + key); + editor.remove(key); + } for (String key : prefs.getAll().keySet()) if ((!BuildConfig.DEBUG && key.startsWith("translated_") && cbGeneral.isChecked()) || + (key.startsWith("announcement.") && cbGeneral.isChecked()) || (key.endsWith(".show_full") && cbFull.isChecked()) || (key.endsWith(".show_images") && cbImages.isChecked()) || (key.endsWith(".confirm_link") && cbLinks.isChecked())) { @@ -2145,6 +2174,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc swBeta.setChecked(prefs.getBoolean("beta", false)); swBeta.setEnabled(swUpdates.isChecked()); swChangelog.setChecked(prefs.getBoolean("show_changelog", !BuildConfig.PLAY_STORE_RELEASE)); + swAnnouncements.setChecked(prefs.getBoolean("announcements", true)); swExperiments.setChecked(prefs.getBoolean("experiments", false)); swCrashReports.setChecked(prefs.getBoolean("crash_reports", false)); tvUuid.setText(prefs.getString("uuid", null)); diff --git a/app/src/main/java/eu/faircode/email/NotificationHelper.java b/app/src/main/java/eu/faircode/email/NotificationHelper.java index fea94b5f2b..87987287d0 100644 --- a/app/src/main/java/eu/faircode/email/NotificationHelper.java +++ b/app/src/main/java/eu/faircode/email/NotificationHelper.java @@ -106,14 +106,22 @@ class NotificationHelper { progress.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); nm.createNotificationChannel(progress); - // Update if (!Helper.isPlayStoreInstall()) { + // Update NotificationChannel update = new NotificationChannel( "update", context.getString(R.string.channel_update), NotificationManager.IMPORTANCE_HIGH); update.setSound(null, Notification.AUDIO_ATTRIBUTES_DEFAULT); update.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); nm.createNotificationChannel(update); + + // Announcements + NotificationChannel announcements = new NotificationChannel( + "announcements", context.getString(R.string.channel_announcements), + NotificationManager.IMPORTANCE_HIGH); + announcements.setSound(null, Notification.AUDIO_ATTRIBUTES_DEFAULT); + announcements.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); + nm.createNotificationChannel(announcements); } // Warnings diff --git a/app/src/main/res/layout/fragment_options_misc.xml b/app/src/main/res/layout/fragment_options_misc.xml index f30e25a2eb..e54e33aba6 100644 --- a/app/src/main/res/layout/fragment_options_misc.xml +++ b/app/src/main/res/layout/fragment_options_misc.xml @@ -653,6 +653,33 @@ app:layout_constraintTop_toBottomOf="@id/tvBitBucketPrivacy" app:switchPadding="12dp" /> + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e3ad3db201..3d637550f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,6 +22,7 @@ Email Progress Updates + Announcements Warnings Errors Server alerts @@ -779,6 +780,7 @@ Check weekly instead of daily Check for test versions on BitBucket Show changelog after update + Check for announcements Try experimental features Send error reports Leak canary