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" /> + + -