diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d9313de09..f212c2e53 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,6 +14,7 @@
+
diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java
index 34d315fbe..8f84e78c4 100644
--- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java
+++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java
@@ -22,12 +22,22 @@
import android.accounts.NetworkErrorException;
import android.animation.AnimatorInflater;
import android.app.SearchManager;
+import android.content.Context;
import android.content.Intent;
+import android.content.res.ColorStateList;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
+import android.util.DisplayMetrics;
import android.util.Log;
+import android.view.Menu;
import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.ColorInt;
@@ -36,6 +46,7 @@
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.SearchView;
+import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
@@ -44,6 +55,7 @@
import androidx.core.view.GravityCompat;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
+import androidx.preference.PreferenceManager;
import androidx.recyclerview.selection.SelectionTracker;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -66,11 +78,14 @@
import com.nextcloud.android.sso.helper.SingleAccountHelper;
import java.net.HttpURLConnection;
+import java.util.Arrays;
import java.util.LinkedList;
+import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
+import hct.Hct;
import it.niedermann.android.util.ColorUtil;
import it.niedermann.owncloud.notes.LockedActivity;
import it.niedermann.owncloud.notes.NotesApplication;
@@ -108,6 +123,7 @@
import it.niedermann.owncloud.notes.shared.util.CustomAppGlideModule;
import it.niedermann.owncloud.notes.shared.util.NoteUtil;
import it.niedermann.owncloud.notes.shared.util.ShareUtil;
+import it.niedermann.owncloud.notes.util.LinkHelper;
public class MainActivity extends LockedActivity implements NoteClickListener, AccountPickerListener, AccountSwitcherListener, CategoryDialogFragment.CategoryDialogListener {
@@ -170,6 +186,8 @@ protected void onCreate(Bundle savedInstanceState) {
setupToolbars();
setupNavigationList();
+ setupDrawerAppMenu();
+ setupDrawerAppMenuListener();
setupNotesList();
mainViewModel.getAccountsCount().observe(this, (count) -> {
@@ -346,6 +364,52 @@ public void handleOnBackPressed() {
});
}
+ private void setupDrawerAppMenu() {
+ // hide ecosystem apps based user preference or for branded clients
+ boolean isShowEcosystemApps = PreferenceManager
+ .getDefaultSharedPreferences(getApplicationContext())
+ .getBoolean(getString(R.string.pref_key_show_ecosystem_apps), true);
+ boolean shouldHideTopBanner = getResources().getBoolean(R.bool.is_branded_client) || !isShowEcosystemApps;
+
+ if (shouldHideTopBanner) {
+ binding.drawerEcosystemApps.setVisibility(GONE);
+ } else {
+ binding.drawerEcosystemApps.setVisibility(VISIBLE);
+ }
+ }
+
+ private void setupDrawerAppMenuListener() {
+ // Add listeners to the ecosystem items to launch the app or app-store
+ binding.drawerEcosystemFiles.setOnClickListener(v -> LinkHelper.INSTANCE.openAppOrStore(LinkHelper.APP_NEXTCLOUD_FILES, mainViewModel.getCurrentAccount().getValue().getAccountName(), this));
+ binding.drawerEcosystemTalk.setOnClickListener(v -> LinkHelper.INSTANCE.openAppOrStore(LinkHelper.APP_NEXTCLOUD_TALK, mainViewModel.getCurrentAccount().getValue().getAccountName(), this));
+ binding.drawerEcosystemMore.setOnClickListener(v -> LinkHelper.INSTANCE.openAppStore("Nextcloud", true, this));
+ }
+
+ private void themeDrawerAppMenu(int color) {
+ ColorStateList colorStateList = ColorStateList.valueOf(color);
+ binding.drawerEcosystemFilesIcon.setImageTintList(colorStateList);
+ ((GradientDrawable) binding.drawerEcosystemFilesIcon.getBackground())
+ .setStroke(convertDpToPixel(1, this), color);
+ binding.drawerEcosystemFilesText.setTextColor(color);
+
+ binding.drawerEcosystemTalkIcon.setImageTintList(colorStateList);
+ ((GradientDrawable) binding.drawerEcosystemTalkIcon.getBackground())
+ .setStroke(convertDpToPixel(1, this), color);
+ binding.drawerEcosystemTalkText.setTextColor(color);
+
+ binding.drawerEcosystemMoreIcon.setImageTintList(colorStateList);
+ ((GradientDrawable) binding.drawerEcosystemMoreIcon.getBackground())
+ .setStroke(convertDpToPixel(1, this), color);
+ binding.drawerEcosystemMoreText.setTextColor(color);
+ }
+
+ public static int convertDpToPixel(float dp, Context context) {
+ Resources resources = context.getResources();
+ DisplayMetrics metrics = resources.getDisplayMetrics();
+
+ return (int) (dp * ((float) metrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT));
+ }
+
private void showAppAccountNotFoundAlertDialog(NextcloudFilesAppAccountNotFoundException e) {
final MaterialAlertDialogBuilder alertDialogBuilder = new MaterialAlertDialogBuilder(this)
.setTitle(NextcloudFilesAppAccountNotFoundException.class.getSimpleName())
@@ -632,6 +696,7 @@ public void applyBrand(int color) {
@ColorInt final int headerTextColor = ColorUtil.getForegroundColorForBackgroundColor(color);
binding.appName.setTextColor(headerTextColor);
DrawableCompat.setTint(binding.logo.getDrawable(), headerTextColor);
+ themeDrawerAppMenu(headerTextColor);
adapter.applyBrand(color);
adapterCategories.applyBrand(color);
diff --git a/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesFragment.java
index c2fa645cc..3f67b9f7d 100644
--- a/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesFragment.java
+++ b/app/src/main/java/it/niedermann/owncloud/notes/preferences/PreferencesFragment.java
@@ -41,6 +41,7 @@ public class PreferencesFragment extends PreferenceFragmentCompat implements Bra
private BrandedSwitchPreference backgroundSyncPref;
private BrandedSwitchPreference keepScreenOnPref;
private BrandedSwitchPreference enableDirectEditorPref;
+ private BrandedSwitchPreference showEcosystemAppBarPref;
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
@@ -50,6 +51,8 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
fontPref = findPreference(getString(R.string.pref_key_font));
+ showEcosystemAppBarPref = findPreference(getString(R.string.pref_key_show_ecosystem_apps));
+
gridViewPref = findPreference(getString(R.string.pref_key_gridview));
if (gridViewPref != null) {
gridViewPref.setOnPreferenceChangeListener((Preference preference, Object newValue) -> {
@@ -141,6 +144,7 @@ public void applyBrand(int color) {
lockPref.applyBrand(color);
wifiOnlyPref.applyBrand(color);
gridViewPref.applyBrand(color);
+ showEcosystemAppBarPref.applyBrand(color);
preventScreenCapturePref.applyBrand(color);
backgroundSyncPref.applyBrand(color);
keepScreenOnPref.applyBrand(color);
diff --git a/app/src/main/java/it/niedermann/owncloud/notes/util/LinkHelper.kt b/app/src/main/java/it/niedermann/owncloud/notes/util/LinkHelper.kt
new file mode 100644
index 000000000..365f3d708
--- /dev/null
+++ b/app/src/main/java/it/niedermann/owncloud/notes/util/LinkHelper.kt
@@ -0,0 +1,132 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package it.niedermann.owncloud.notes.util
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.core.net.toUri
+import com.owncloud.android.lib.common.utils.Log_OC
+import java.util.Locale
+
+/**
+ * Helper class for opening Nextcloud apps if present
+ * or falling back to opening the app store
+ * in case the app is not yet installed on the device.
+ */
+object LinkHelper {
+ const val APP_NEXTCLOUD_FILES = "com.nextcloud.client"
+ const val APP_NEXTCLOUD_NOTES = "it.niedermann.owncloud.notes"
+ const val APP_NEXTCLOUD_TALK = "com.nextcloud.talk2"
+ const val KEY_ACCOUNT: String = "KEY_ACCOUNT"
+ private const val TAG = "LinkHelper"
+
+ /**
+ * Open specified app and, if not installed redirect to corresponding download.
+ *
+ * @param packageName of app to be opened
+ * @param userHash to pass in intent
+ */
+ fun openAppOrStore(
+ packageName: String,
+ userHash: String?,
+ context: Context,
+ ) {
+ val intent = context.packageManager.getLaunchIntentForPackage(packageName)
+ if (intent != null) {
+ // app installed - open directly
+ // TODO handle null user?
+ intent.putExtra(KEY_ACCOUNT, userHash)
+ context.startActivity(intent)
+ } else {
+ // app not found - open market (Google Play Store, F-Droid, etc.)
+ openAppStore(packageName, false, context)
+ }
+ }
+
+ /**
+ * Open app store page of specified app or search for specified string. Will attempt to open browser when no app
+ * store is available.
+ *
+ * @param string packageName or url-encoded search string
+ * @param search false -> show app corresponding to packageName; true -> open search for string
+ */
+ fun openAppStore(
+ string: String,
+ search: Boolean = false,
+ context: Context,
+ ) {
+ var suffix = (if (search) "search?q=" else "details?id=") + string
+ val intent = Intent(Intent.ACTION_VIEW, "market://$suffix".toUri())
+ try {
+ context.startActivity(intent)
+ } catch (activityNotFoundException1: ActivityNotFoundException) {
+ // all is lost: open google play store web page for app
+ if (!search) {
+ suffix = "apps/$suffix"
+ }
+ intent.setData("https://play.google.com/store/$suffix".toUri())
+ context.startActivity(intent)
+ }
+ }
+
+ // region Validation
+ private const val HTTP = "http"
+ private const val HTTPS = "https"
+ private const val FILE = "file"
+ private const val CONTENT = "content"
+
+ /**
+ * Validates if a string can be converted to a valid URI
+ */
+ @Suppress("TooGenericExceptionCaught", "ReturnCount")
+ fun validateAndGetURI(uriString: String?): Uri? {
+ if (uriString.isNullOrBlank()) {
+ Log_OC.w(TAG, "Given uriString is null or blank")
+ return null
+ }
+
+ return try {
+ val uri = uriString.toUri()
+ if (uri.scheme == null) {
+ return null
+ }
+
+ val validSchemes = listOf(HTTP, HTTPS, FILE, CONTENT)
+ if (uri.scheme in validSchemes) uri else null
+ } catch (e: Exception) {
+ Log_OC.e(TAG, "Invalid URI string: $uriString -- $e")
+ null
+ }
+ }
+
+ /**
+ * Validates if a URL string is valid
+ */
+ @Suppress("TooGenericExceptionCaught", "ReturnCount")
+ fun validateAndGetURL(url: String?): String? {
+ if (url.isNullOrBlank()) {
+ Log_OC.w(TAG, "Given url is null or blank")
+ return null
+ }
+
+ return try {
+ val uri = url.toUri()
+ if (uri.scheme == null) {
+ return null
+ }
+ val validSchemes = listOf(HTTP, HTTPS)
+ if (uri.scheme in validSchemes) url else null
+ } catch (e: Exception) {
+ Log_OC.e(TAG, "Invalid URL: $url -- $e")
+ null
+ }
+ }
+ // endregion
+}
diff --git a/app/src/main/res/drawable/ic_assistant.xml b/app/src/main/res/drawable/ic_assistant.xml
new file mode 100644
index 000000000..6607e2a7e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_assistant.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_more_apps.xml b/app/src/main/res/drawable/ic_more_apps.xml
new file mode 100644
index 000000000..b644a5431
--- /dev/null
+++ b/app/src/main/res/drawable/ic_more_apps.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_rocket_launch_grey600_24dp.xml b/app/src/main/res/drawable/ic_rocket_launch_grey600_24dp.xml
new file mode 100644
index 000000000..fd74595a1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_rocket_launch_grey600_24dp.xml
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/white_outline.xml b/app/src/main/res/drawable/white_outline.xml
new file mode 100644
index 000000000..c56147b0c
--- /dev/null
+++ b/app/src/main/res/drawable/white_outline.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/drawer_layout.xml b/app/src/main/res/layout/drawer_layout.xml
index 44929adfd..a01c3a406 100644
--- a/app/src/main/res/layout/drawer_layout.xml
+++ b/app/src/main/res/layout/drawer_layout.xml
@@ -38,14 +38,13 @@
@@ -53,15 +52,143 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
~ SPDX-License-Identifier: GPL-3.0-or-later
-->
-
+
+
+ false
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 07140003f..3babf2b94 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -186,6 +186,8 @@
Device credentials
Background synchronization
Prevent screen capture
+ Show app switcher
+ Nextcloud app suggestions in navigation heading
Grid view
Direct Edit
When disabled, the advanced editor will be hidden.
@@ -261,6 +263,7 @@
lastNoteMode
backgroundSync
directEditPreference
+ show_ecosystem_apps
edit
directEdit
preview
@@ -539,4 +542,9 @@
Failed to set status!
Failed to fetch status, please try again.
Online
+ Files
+ Talk
+ Nextcloud Talk
+ More Nextcloud Apps
+ More
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index 7a61ed546..8c87a05b0 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -42,6 +42,14 @@
android:summary="%s"
android:title="@string/settings_theme_title" />
+
+
-