diff --git a/app/build.gradle b/app/build.gradle index baed71e00..06099f31c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -95,6 +95,15 @@ ext { dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' + implementation 'com.google.guava:guava:31.1-android' + implementation ('commons-httpclient:commons-httpclient:3.1') { + exclude group: 'commons-logging', module: 'commons-logging' + } + + implementation("com.github.nextcloud:android-library:2.19.0") { + exclude group: 'org.ogce', module: 'xpp3' + } + // Nextcloud SSO implementation 'com.github.nextcloud.android-common:ui:48ed8e86d9' implementation 'com.github.nextcloud:Android-SingleSignOn:1.3.2' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4427996f..6a43f4dba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,8 @@ - + + @@ -47,10 +48,22 @@ android:value=".android.activity.NotesListViewActivity" /> + + + + + + dismiss()); final var adapter = new AccountSwitcherAdapter((localAccount -> { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java index b3119fdd5..d128e07ef 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java @@ -14,13 +14,9 @@ import androidx.core.util.Consumer; import androidx.recyclerview.widget.RecyclerView; -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.RequestOptions; - -import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl; -import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.databinding.ItemAccountChooseBinding; import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.share.helper.AvatarLoader; public class AccountSwitcherViewHolder extends RecyclerView.ViewHolder { @@ -34,12 +30,7 @@ public AccountSwitcherViewHolder(@NonNull View itemView) { public void bind(@NonNull Account localAccount, @NonNull Consumer onAccountClick) { binding.accountName.setText(localAccount.getDisplayName()); binding.accountHost.setText(Uri.parse(localAccount.getUrl()).getHost()); - Glide.with(itemView.getContext()) - .load(new SingleSignOnUrl(localAccount.getAccountName(), localAccount.getUrl() + "/index.php/avatar/" + Uri.encode(localAccount.getUserName()) + "/64")) - .placeholder(R.drawable.ic_account_circle_grey_24dp) - .error(R.drawable.ic_account_circle_grey_24dp) - .apply(RequestOptions.circleCropTransform()) - .into(binding.accountItemAvatar); + AvatarLoader.INSTANCE.load(itemView.getContext(), binding.accountItemAvatar, localAccount); itemView.setOnClickListener((v) -> onAccountClick.accept(localAccount)); binding.accountContextMenu.setVisibility(View.GONE); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedBottomSheetDialog.kt b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedBottomSheetDialog.kt new file mode 100644 index 000000000..0aed84b85 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedBottomSheetDialog.kt @@ -0,0 +1,15 @@ +package it.niedermann.owncloud.notes.branding + +import android.content.Context +import androidx.annotation.ColorInt +import com.google.android.material.bottomsheet.BottomSheetDialog + +abstract class BrandedBottomSheetDialog(context: Context) : BottomSheetDialog(context), Branded { + + override fun onStart() { + super.onStart() + + @ColorInt val color = BrandingUtil.readBrandMainColor(context) + applyBrand(color) + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedViewHolder.kt b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedViewHolder.kt new file mode 100644 index 000000000..6804bfae7 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedViewHolder.kt @@ -0,0 +1,13 @@ +package it.niedermann.owncloud.notes.branding + +import android.view.View +import androidx.annotation.ColorInt +import androidx.recyclerview.widget.RecyclerView + +abstract class BrandedViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), Branded { + + fun bindBranding() { + @ColorInt val color = BrandingUtil.readBrandMainColor(itemView.context) + applyBrand(color) + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java index 28c38152a..54f23cf50 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java @@ -122,7 +122,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat if (content == null) { throw new IllegalArgumentException(PARAM_NOTE_ID + " is not given, argument " + PARAM_NEWNOTE + " is missing and " + PARAM_CONTENT + " is missing."); } else { - note = new Note(-1, null, Calendar.getInstance(), NoteUtil.generateNoteTitle(content), content, getString(R.string.category_readonly), false, null, DBStatus.VOID, -1, "", 0); + note = new Note(-1, null, Calendar.getInstance(), NoteUtil.generateNoteTitle(content), content, getString(R.string.category_readonly), false, null, DBStatus.VOID, -1, "", 0, false, false); requireActivity().runOnUiThread(() -> onNoteLoaded(note)); requireActivity().invalidateOptionsMenu(); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java index cb59994eb..ee24c2820 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java @@ -302,7 +302,7 @@ private void launchNewNote() { if (content == null) { content = ""; } - final var newNote = new Note(null, Calendar.getInstance(), NoteUtil.generateNonEmptyNoteTitle(content, this), content, categoryTitle, favorite, null); + final var newNote = new Note(null, Calendar.getInstance(), NoteUtil.generateNonEmptyNoteTitle(content, this), content, categoryTitle, favorite, null, false, false); fragment = getNewNoteFragment(newNote); replaceFragment(); } 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 30d3e0374..2d08ffc91 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 @@ -99,6 +99,7 @@ import it.niedermann.owncloud.notes.persistence.CapabilitiesWorker; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.share.helper.AvatarLoader; import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; import it.niedermann.owncloud.notes.shared.model.IResponseCallback; import it.niedermann.owncloud.notes.shared.model.NavigationCategory; @@ -517,7 +518,7 @@ public void onError(@NonNull Throwable t) { public void onSelectionChanged() { super.onSelectionChanged(); if (tracker.hasSelection() && mActionMode == null) { - mActionMode = startSupportActionMode(new MultiSelectedActionModeCallback(MainActivity.this, coordinatorLayout, binding.activityNotesListView.fabCreate, mainViewModel, MainActivity.this, canMoveNoteToAnotherAccounts, tracker, getSupportFragmentManager())); + mActionMode = startSupportActionMode(new MultiSelectedActionModeCallback(MainActivity.this,MainActivity.this, coordinatorLayout, binding.activityNotesListView.fabCreate, mainViewModel, MainActivity.this, canMoveNoteToAnotherAccounts, tracker, getSupportFragmentManager())); } if (mActionMode != null) { if (tracker.hasSelection()) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MultiSelectedActionModeCallback.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MultiSelectedActionModeCallback.java index 9996562ad..929ad1319 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MultiSelectedActionModeCallback.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MultiSelectedActionModeCallback.java @@ -7,6 +7,8 @@ package it.niedermann.owncloud.notes.main; import android.content.Context; +import android.content.Intent; +import android.os.Bundle; import android.util.TypedValue; import android.view.Menu; import android.view.MenuItem; @@ -31,6 +33,7 @@ import it.niedermann.owncloud.notes.accountpicker.AccountPickerDialogFragment; import it.niedermann.owncloud.notes.branding.BrandedSnackbar; import it.niedermann.owncloud.notes.edit.category.CategoryDialogFragment; +import it.niedermann.owncloud.notes.share.NoteShareActivity; import it.niedermann.owncloud.notes.shared.util.ShareUtil; public class MultiSelectedActionModeCallback implements Callback { @@ -53,8 +56,11 @@ public class MultiSelectedActionModeCallback implements Callback { private final SelectionTracker tracker; @NonNull private final FragmentManager fragmentManager; + @NonNull + private final MainActivity mainActivity; public MultiSelectedActionModeCallback( + @NonNull MainActivity mainActivity, @NonNull Context context, @NonNull View view, @NonNull View anchorView, @@ -63,6 +69,7 @@ public MultiSelectedActionModeCallback( boolean canMoveNoteToAnotherAccounts, @NonNull SelectionTracker tracker, @NonNull FragmentManager fragmentManager) { + this.mainActivity = mainActivity; this.context = context; this.view = view; this.anchorView = anchorView; @@ -153,16 +160,26 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { } tracker.clearSelection(); - executor.submit(() -> { - if (selection.size() == 1) { - final var note = mainViewModel.getFullNote(selection.get(0)); - ShareUtil.openShareDialog(context, note.getTitle(), note.getContent()); - } else { - ShareUtil.openShareDialog(context, - context.getResources().getQuantityString(R.plurals.share_multiple, selection.size(), selection.size()), - mainViewModel.collectNoteContents(selection)); - } - }); + if (selection.size() == 1) { + final var currentAccount$ = mainViewModel.getCurrentAccount(); + currentAccount$.observe(lifecycleOwner, account -> { + currentAccount$.removeObservers(lifecycleOwner); + executor.submit(() -> {{ + final var note = mainViewModel.getFullNote(selection.get(0)); + Bundle bundle = new Bundle(); + bundle.putSerializable(NoteShareActivity.ARG_NOTE, note); + bundle.putSerializable(NoteShareActivity.ARG_ACCOUNT, account); + Intent intent = new Intent(mainActivity, NoteShareActivity.class); + intent.putExtras(bundle); + mainActivity.startActivity(intent); + }}); + }); + } else { + ShareUtil.openShareDialog(context, + context.getResources().getQuantityString(R.plurals.share_multiple, selection.size(), selection.size()), + mainViewModel.collectNoteContents(selection)); + } + return true; } else if (itemId == R.id.menu_category) {// TODO detect whether all selected notes do have the same category - in this case preselect it final var accountLiveData = mainViewModel.getCurrentAccount(); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountViewHolder.java index 49d7b0734..b2de7704d 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountViewHolder.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/manageaccounts/ManageAccountViewHolder.java @@ -18,14 +18,11 @@ import androidx.appcompat.widget.PopupMenu; import androidx.recyclerview.widget.RecyclerView; -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.RequestOptions; - -import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl; import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.databinding.ItemAccountChooseBinding; import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.share.helper.AvatarLoader; public class ManageAccountViewHolder extends RecyclerView.ViewHolder { @@ -43,11 +40,7 @@ public void bind( ) { binding.accountName.setText(localAccount.getUserName()); binding.accountHost.setText(Uri.parse(localAccount.getUrl()).getHost()); - Glide.with(itemView.getContext()) - .load(new SingleSignOnUrl(localAccount.getAccountName(), localAccount.getUrl() + "/index.php/avatar/" + Uri.encode(localAccount.getUserName()) + "/64")) - .error(R.drawable.ic_account_circle_grey_24dp) - .apply(RequestOptions.circleCropTransform()) - .into(binding.accountItemAvatar); + AvatarLoader.INSTANCE.load(itemView.getContext(), binding.accountItemAvatar, localAccount); itemView.setOnClickListener((v) -> callback.onSelect(localAccount)); binding.accountContextMenu.setVisibility(VISIBLE); binding.accountContextMenu.setOnClickListener((v) -> { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java index c6268f6f0..e183b4c18 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java @@ -18,6 +18,7 @@ import com.google.gson.JsonDeserializer; import com.google.gson.JsonPrimitive; import com.google.gson.JsonSerializer; +import com.google.gson.Strictness; import com.nextcloud.android.sso.api.NextcloudAPI; import com.nextcloud.android.sso.model.SingleSignOnAccount; @@ -29,8 +30,10 @@ import it.niedermann.owncloud.notes.persistence.sync.FilesAPI; import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; import it.niedermann.owncloud.notes.persistence.sync.OcsAPI; +import it.niedermann.owncloud.notes.persistence.sync.ShareAPI; import it.niedermann.owncloud.notes.shared.model.ApiVersion; import it.niedermann.owncloud.notes.shared.model.Capabilities; +import okhttp3.ResponseBody; import retrofit2.NextcloudRetrofitApiBuilder; import retrofit2.Retrofit; @@ -47,12 +50,14 @@ public class ApiProvider { private static final String API_ENDPOINT_OCS = "/ocs/v2.php/cloud/"; private static final String API_ENDPOINT_FILES ="/ocs/v2.php/apps/files/api/v1/"; + private static final String API_ENDPOINT_FILES_SHARING ="/ocs/v2.php/apps/files_sharing/api/v1/"; private static final Map API_CACHE = new ConcurrentHashMap<>(); private static final Map API_CACHE_OCS = new ConcurrentHashMap<>(); private static final Map API_CACHE_NOTES = new ConcurrentHashMap<>(); private static final Map API_CACHE_FILES = new ConcurrentHashMap<>(); + private static final Map API_CACHE_FILES_SHARING = new ConcurrentHashMap<>(); public static ApiProvider getInstance() { @@ -96,6 +101,15 @@ public synchronized FilesAPI getFilesAPI(@NonNull Context context, @NonNull Sing return filesAPI; } + public synchronized ShareAPI getShareAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { + if (API_CACHE_FILES_SHARING.containsKey(ssoAccount.name)) { + return API_CACHE_FILES_SHARING.get(ssoAccount.name); + } + final var shareAPI = new NextcloudRetrofitApiBuilder(getNextcloudAPI(context, ssoAccount), API_ENDPOINT_FILES_SHARING).create(ShareAPI.class); + API_CACHE_FILES_SHARING.put(ssoAccount.name, shareAPI); + return shareAPI; + } + private synchronized NextcloudAPI getNextcloudAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { if (API_CACHE.containsKey(ssoAccount.name)) { return API_CACHE.get(ssoAccount.name); @@ -103,6 +117,7 @@ private synchronized NextcloudAPI getNextcloudAPI(@NonNull Context context, @Non Log.v(TAG, "NextcloudRequest account: " + ssoAccount.name); final var nextcloudAPI = new NextcloudAPI(context.getApplicationContext(), ssoAccount, new GsonBuilder() + .setStrictness(Strictness.LENIENT) .excludeFieldsWithoutExposeAnnotation() .registerTypeHierarchyAdapter(Calendar.class, (JsonSerializer) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.getTimeInMillis() / 1_000)) .registerTypeHierarchyAdapter(Calendar.class, (JsonDeserializer) (src, typeOfSrc, ctx) -> { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiResult.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiResult.kt new file mode 100644 index 000000000..2f30ed4ac --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiResult.kt @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.persistence + +sealed class ApiResult { + data class Success(val data: T, val message: String? = null) : ApiResult() + data class Error(val message: String, val code: Int? = null) : ApiResult() +} + +fun ApiResult.isSuccess(): Boolean = this is ApiResult.Success +fun ApiResult.isError(): Boolean = this is ApiResult.Error diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java index af5389061..8b0d22f40 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java @@ -52,6 +52,7 @@ public Result doWork() { for (final var account : repo.getAccounts()) { try { final var ssoAccount = AccountImporter.getSingleSignOnAccount(getApplicationContext(), account.getAccountName()); + Log.i(TAG, "Refreshing capabilities for " + ssoAccount.name); final var capabilities = CapabilitiesClient.getCapabilities(getApplicationContext(), ssoAccount, account.getCapabilitiesETag(), ApiProvider.getInstance()); repo.updateCapabilitiesETag(account.getId(), capabilities.getETag()); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java index 72507d588..1df1c22d1 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java @@ -17,8 +17,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase; import it.niedermann.owncloud.notes.persistence.dao.AccountDao; +import it.niedermann.owncloud.notes.persistence.dao.CapabilitiesDao; import it.niedermann.owncloud.notes.persistence.dao.CategoryOptionsDao; import it.niedermann.owncloud.notes.persistence.dao.NoteDao; +import it.niedermann.owncloud.notes.persistence.dao.ShareDao; import it.niedermann.owncloud.notes.persistence.dao.WidgetNotesListDao; import it.niedermann.owncloud.notes.persistence.dao.WidgetSingleNoteDao; import it.niedermann.owncloud.notes.persistence.entity.Account; @@ -26,6 +28,7 @@ import it.niedermann.owncloud.notes.persistence.entity.Converters; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; +import it.niedermann.owncloud.notes.persistence.entity.ShareEntity; import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; import it.niedermann.owncloud.notes.persistence.migration.Migration_10_11; import it.niedermann.owncloud.notes.persistence.migration.Migration_11_12; @@ -43,6 +46,7 @@ import it.niedermann.owncloud.notes.persistence.migration.Migration_23_24; import it.niedermann.owncloud.notes.persistence.migration.Migration_24_25; import it.niedermann.owncloud.notes.persistence.migration.Migration_9_10; +import it.niedermann.owncloud.notes.shared.model.Capabilities; @Database( entities = { @@ -50,8 +54,10 @@ Note.class, CategoryOptions.class, SingleNoteWidgetData.class, - NotesListWidgetData.class - }, version = 25 + NotesListWidgetData.class, + ShareEntity.class, + Capabilities.class + }, version = 26 ) @TypeConverters({Converters.class}) public abstract class NotesDatabase extends RoomDatabase { @@ -115,4 +121,8 @@ public void onCreate(@NonNull SupportSQLiteDatabase db) { public abstract WidgetSingleNoteDao getWidgetSingleNoteDao(); public abstract WidgetNotesListDao getWidgetNotesListDao(); + + public abstract ShareDao getShareDao(); + + public abstract CapabilitiesDao getCapabilitiesDao(); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java index 0ad24f3d1..7e00aec15 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java @@ -15,6 +15,7 @@ import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt; import static it.niedermann.owncloud.notes.widget.notelist.NoteListWidget.updateNoteListWidgets; import static it.niedermann.owncloud.notes.widget.singlenote.SingleNoteWidget.updateSingleNoteWidgets; + import android.accounts.NetworkErrorException; import android.content.Context; import android.content.Intent; @@ -25,8 +26,11 @@ import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkCapabilities; +import android.os.Handler; +import android.os.Looper; import android.text.TextUtils; import android.util.Log; + import androidx.annotation.AnyThread; import androidx.annotation.ColorInt; import androidx.annotation.MainThread; @@ -37,11 +41,13 @@ import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import androidx.preference.PreferenceManager; + import com.nextcloud.android.sso.AccountImporter; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.nextcloud.android.sso.model.SingleSignOnAccount; + import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; @@ -51,6 +57,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; + import it.niedermann.android.sharedpreferences.SharedPreferenceIntLiveData; import it.niedermann.owncloud.notes.BuildConfig; import it.niedermann.owncloud.notes.R; @@ -60,6 +67,7 @@ import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; +import it.niedermann.owncloud.notes.persistence.entity.ShareEntity; import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; import it.niedermann.owncloud.notes.shared.model.ApiVersion; import it.niedermann.owncloud.notes.shared.model.Capabilities; @@ -99,6 +107,7 @@ public class NotesRepository { private boolean syncOnlyOnWifi; private final MutableLiveData syncStatus = new MutableLiveData<>(false); private final MutableLiveData> syncErrors = new MutableLiveData<>(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Observer syncObserver = (Observer) connectionType -> { observeNetworkStatus(connectionType); @@ -157,7 +166,7 @@ private NotesRepository(@NonNull final Context context, @NonNull final NotesData this.syncOnlyOnWifiKey = context.getApplicationContext().getResources().getString(R.string.pref_key_wifi_only); this.connectionLiveData = new ConnectionLiveData(context); - connectionLiveData.observeForever(syncObserver); + mainHandler.post(() -> connectionLiveData.observeForever(syncObserver)); final var prefs = PreferenceManager.getDefaultSharedPreferences(this.context); prefs.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener); @@ -217,13 +226,15 @@ private void handleNetworkStatus(boolean isWifiActive) { @Override protected void finalize() throws Throwable { - connectionLiveData.removeObserver(syncObserver); + mainHandler.post(() -> connectionLiveData.removeObserver(syncObserver)); super.finalize(); } // Accounts @AnyThread public LiveData addAccount(@NonNull String url, @NonNull String username, @NonNull String accountName, @NonNull Capabilities capabilities, @Nullable String displayName, @NonNull IResponseCallback callback) { + db.getCapabilitiesDao().insert(capabilities); + final var account = db.getAccountDao().getAccountById(db.getAccountDao().insert(new Account(url, username, accountName, displayName, capabilities))); if (account == null) { callback.onError(new Exception("Could not read created account.")); @@ -284,6 +295,10 @@ public void deleteAccount(@NonNull Account account) { db.getAccountDao().deleteAccount(account); } + public Capabilities getCapabilities() { + return db.getCapabilitiesDao().getCapabilities(); + } + public Account getAccountByName(String accountName) { return db.getAccountDao().getAccountByName(accountName); } @@ -473,7 +488,7 @@ public NotesListWidgetData getNoteListWidgetData(int appWidgetId) { @NonNull @MainThread public LiveData addNoteAndSync(Account account, Note note) { - final var entity = new Note(0, null, note.getModified(), note.getTitle(), note.getContent(), note.getCategory(), note.getFavorite(), note.getETag(), DBStatus.LOCAL_EDITED, account.getId(), generateNoteExcerpt(note.getContent(), note.getTitle()), 0); + final var entity = new Note(0, null, note.getModified(), note.getTitle(), note.getContent(), note.getCategory(), note.getFavorite(), note.getETag(), DBStatus.LOCAL_EDITED, account.getId(), generateNoteExcerpt(note.getContent(), note.getTitle()), 0, note.isShared(), note.getReadonly()); final var ret = new MutableLiveData(); executor.submit(() -> ret.postValue(addNote(account.getId(), entity))); return map(ret, newNote -> { @@ -501,7 +516,7 @@ public Note addNote(long accountId, @NonNull Note note) { @MainThread public LiveData moveNoteToAnotherAccount(Account account, @NonNull Note note) { - final var fullNote = new Note(null, note.getModified(), note.getTitle(), note.getContent(), note.getCategory(), note.getFavorite(), null); + final var fullNote = new Note(null, note.getModified(), note.getTitle(), note.getContent(), note.getCategory(), note.getFavorite(), null, note.isShared(), note.getReadonly()); fullNote.setStatus(DBStatus.LOCAL_EDITED); deleteNoteAndSync(account, note.getId()); return addNoteAndSync(account, fullNote); @@ -563,7 +578,7 @@ public Note updateNoteAndSync(@NonNull Account localAccount, @NonNull Note oldNo // https://github.com/nextcloud/notes-android/issues/1198 @Nullable final Long remoteId = db.getNoteDao().getRemoteId(oldNote.getId()); if (newContent == null) { - newNote = new Note(oldNote.getId(), remoteId, oldNote.getModified(), oldNote.getTitle(), oldNote.getContent(), oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), oldNote.getExcerpt(), oldNote.getScrollY()); + newNote = new Note(oldNote.getId(), remoteId, oldNote.getModified(), oldNote.getTitle(), oldNote.getContent(), oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), oldNote.getExcerpt(), oldNote.getScrollY(), oldNote.isShared(), oldNote.getReadonly()); } else { final String title; if (newTitle != null) { @@ -577,7 +592,7 @@ public Note updateNoteAndSync(@NonNull Account localAccount, @NonNull Note oldNo title = oldNote.getTitle(); } } - newNote = new Note(oldNote.getId(), remoteId, Calendar.getInstance(), title, newContent, oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), generateNoteExcerpt(newContent, title), oldNote.getScrollY()); + newNote = new Note(oldNote.getId(), remoteId, Calendar.getInstance(), title, newContent, oldNote.getCategory(), oldNote.getFavorite(), oldNote.getETag(), DBStatus.LOCAL_EDITED, localAccount.getId(), generateNoteExcerpt(newContent, title), oldNote.getScrollY(), oldNote.isShared(), oldNote.getReadonly()); } int rows = db.getNoteDao().updateNote(newNote); // if data was changed, set new status and schedule sync (with callback); otherwise invoke callback directly. @@ -959,4 +974,16 @@ public Call putServerSettings(@NonNull SingleSignOnAccount ssoAcc public void updateDisplayName(long id, @Nullable String displayName) { db.getAccountDao().updateDisplayName(id, displayName); } + + public void addShareEntities(List entities) { + db.getShareDao().addShareEntities(entities); + } + + public List getShareEntities(String path) { + return db.getShareDao().getShareEntities(path); + } + + public void updateNote(Note note) { + db.getNoteDao().updateNote(note); + } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/CapabilitiesDao.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/CapabilitiesDao.kt new file mode 100644 index 000000000..ed8b3d1c8 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/CapabilitiesDao.kt @@ -0,0 +1,16 @@ +package it.niedermann.owncloud.notes.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import it.niedermann.owncloud.notes.shared.model.Capabilities + +@Dao +interface CapabilitiesDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(capabilities: Capabilities) + + @Query("SELECT * FROM capabilities WHERE id = 1") + fun getCapabilities(): Capabilities +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java index 07b7e4f4a..6c1b48634 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java @@ -38,14 +38,14 @@ public interface NoteDao { String getNoteById = "SELECT * FROM NOTE WHERE id = :id"; String count = "SELECT COUNT(*) FROM NOTE WHERE status != 'LOCAL_DELETED' AND accountId = :accountId"; String countFavorites = "SELECT COUNT(*) FROM NOTE WHERE status != 'LOCAL_DELETED' AND accountId = :accountId AND favorite = 1"; - String searchRecentByModified = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) ORDER BY favorite DESC, modified DESC"; - String searchRecentLexicographically = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) ORDER BY favorite DESC, title COLLATE LOCALIZED ASC"; - String searchFavoritesByModified = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND favorite = 1 ORDER BY modified DESC"; - String searchFavoritesLexicographically = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND favorite = 1 ORDER BY title COLLATE LOCALIZED ASC"; - String searchUncategorizedByModified = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND category = '' ORDER BY favorite DESC, modified DESC"; - String searchUncategorizedLexicographically = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND category = '' ORDER BY favorite DESC, title COLLATE LOCALIZED ASC"; - String searchCategoryByModified = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND (category = :category OR category LIKE :category || '/%') ORDER BY category, favorite DESC, modified DESC"; - String searchCategoryLexicographically = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND (category = :category OR category LIKE :category || '/%') ORDER BY category, favorite DESC, title COLLATE LOCALIZED ASC"; + String searchRecentByModified = "SELECT id, remoteId, accountId, title, favorite, isShared, readonly, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) ORDER BY favorite DESC, modified DESC"; + String searchRecentLexicographically = "SELECT id, remoteId, accountId, title, favorite, isShared, readonly, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) ORDER BY favorite DESC, title COLLATE LOCALIZED ASC"; + String searchFavoritesByModified = "SELECT id, remoteId, accountId, title, favorite, isShared, readonly, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND favorite = 1 ORDER BY modified DESC"; + String searchFavoritesLexicographically = "SELECT id, remoteId, accountId, title, favorite, isShared, readonly, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND favorite = 1 ORDER BY title COLLATE LOCALIZED ASC"; + String searchUncategorizedByModified = "SELECT id, remoteId, accountId, title, favorite, isShared, readonly, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND category = '' ORDER BY favorite DESC, modified DESC"; + String searchUncategorizedLexicographically = "SELECT id, remoteId, accountId, title, favorite, isShared, readonly, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND category = '' ORDER BY favorite DESC, title COLLATE LOCALIZED ASC"; + String searchCategoryByModified = "SELECT id, remoteId, accountId, title, favorite, isShared, readonly, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND (category = :category OR category LIKE :category || '/%') ORDER BY category, favorite DESC, modified DESC"; + String searchCategoryLexicographically = "SELECT id, remoteId, accountId, title, favorite, isShared, readonly, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) AND (category = :category OR category LIKE :category || '/%') ORDER BY category, favorite DESC, title COLLATE LOCALIZED ASC"; @Query(getNoteById) LiveData getNoteById$(long id); @@ -141,7 +141,7 @@ public interface NoteDao { * Gets a list of {@link Note} objects with filled {@link Note#id} and {@link Note#remoteId}, * where {@link Note#remoteId} is not null */ - @Query("SELECT id, remoteId, 0 as accountId, '' as title, 0 as favorite, '' as excerpt, 0 as modified, '' as eTag, 0 as status, '' as category, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND remoteId IS NOT NULL") + @Query("SELECT id, remoteId, 0 as accountId, '' as title, 0 as favorite, 0 as isShared, 0 as readonly, '' as excerpt, 0 as modified, '' as eTag, 0 as status, '' as category, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND remoteId IS NOT NULL") List getRemoteIdAndId(long accountId); /** diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/ShareDao.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/ShareDao.kt new file mode 100644 index 000000000..15ab98f80 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/ShareDao.kt @@ -0,0 +1,16 @@ +package it.niedermann.owncloud.notes.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import it.niedermann.owncloud.notes.persistence.entity.ShareEntity + +@Dao +interface ShareDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun addShareEntities(entities: List) + + @Query("SELECT * FROM share_table WHERE path = :path") + fun getShareEntities(path: String): List +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java index a8fc3ba56..6b3c46a5b 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java @@ -7,6 +7,7 @@ package it.niedermann.owncloud.notes.persistence.entity; import android.graphics.Color; +import android.net.Uri; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; @@ -93,6 +94,10 @@ public String getUrl() { return url; } + public String getAvatarUrl() { + return url + "/index.php/avatar/" + Uri.encode(userName) + "/64"; + } + public void setUrl(@NonNull String url) { this.url = url; } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java index 886eb8786..dabd3bd51 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java @@ -38,6 +38,8 @@ @Index(name = "IDX_NOTE_ACCOUNTID", value = "accountId"), @Index(name = "IDX_NOTE_CATEGORY", value = "category"), @Index(name = "IDX_NOTE_FAVORITE", value = "favorite"), + @Index(name = "IDX_NOTE_IS_SHARED", value = "isShared"), + @Index(name = "IDX_READONLY", value = "readonly"), @Index(name = "IDX_NOTE_MODIFIED", value = "modified"), @Index(name = "IDX_NOTE_REMOTEID", value = "remoteId"), @Index(name = "IDX_NOTE_STATUS", value = "status") @@ -81,6 +83,14 @@ public class Note implements Serializable, Item { @ColumnInfo(defaultValue = "0") private boolean favorite = false; + @Expose + @ColumnInfo(defaultValue = "0") + private boolean isShared = false; + + @Expose + @ColumnInfo(defaultValue = "0") + private boolean readonly = false; + @Expose @Nullable @SerializedName("etag") @@ -99,6 +109,19 @@ public Note() { @Ignore public Note(@Nullable Long remoteId, @Nullable Calendar modified, @NonNull String title, @NonNull String content, @NonNull String category, boolean favorite, @Nullable String eTag) { + this(remoteId, + modified, + title, + content, + category, + favorite, + eTag, + false, + false); + } + + @Ignore + public Note(@Nullable Long remoteId, @Nullable Calendar modified, @NonNull String title, @NonNull String content, @NonNull String category, boolean favorite, @Nullable String eTag, boolean isShared, boolean readonly) { this.remoteId = remoteId; this.title = title; this.modified = modified; @@ -106,11 +129,22 @@ public Note(@Nullable Long remoteId, @Nullable Calendar modified, @NonNull Strin this.favorite = favorite; this.category = category; this.eTag = eTag; + this.isShared = isShared; } @Ignore public Note(long id, @Nullable Long remoteId, @Nullable Calendar modified, @NonNull String title, @NonNull String content, @NonNull String category, boolean favorite, @Nullable String etag, @NonNull DBStatus status, long accountId, @NonNull String excerpt, int scrollY) { - this(remoteId, modified, title, content, category, favorite, etag); + this(remoteId, modified, title, content, category, favorite, etag, false, false); + this.id = id; + this.status = status; + this.accountId = accountId; + this.excerpt = excerpt; + this.scrollY = scrollY; + } + + @Ignore + public Note(long id, @Nullable Long remoteId, @Nullable Calendar modified, @NonNull String title, @NonNull String content, @NonNull String category, boolean favorite, @Nullable String etag, @NonNull DBStatus status, long accountId, @NonNull String excerpt, int scrollY, boolean isShared, boolean readonly) { + this(remoteId, modified, title, content, category, favorite, etag, isShared, readonly); this.id = id; this.status = status; this.accountId = accountId; @@ -126,6 +160,14 @@ public void setId(long id) { this.id = id; } + public boolean isShared() { + return isShared; + } + + public void setIsShared(boolean value) { + this.isShared = value; + } + @NonNull public String getCategory() { return category; @@ -188,6 +230,14 @@ public void setContent(@NonNull String content) { this.content = content; } + public boolean getReadonly() { + return readonly; + } + + public void setReadonly(boolean value) { + readonly = value; + } + public boolean getFavorite() { return favorite; } @@ -230,6 +280,7 @@ public boolean equals(Object o) { if (id != note.id) return false; if (accountId != note.accountId) return false; if (favorite != note.favorite) return false; + if (isShared != note.isShared) return false; if (scrollY != note.scrollY) return false; if (!Objects.equals(remoteId, note.remoteId)) return false; @@ -254,6 +305,7 @@ public int hashCode() { result = 31 * result + (modified != null ? modified.hashCode() : 0); result = 31 * result + content.hashCode(); result = 31 * result + (favorite ? 1 : 0); + result = 31 * result + (isShared ? 1 : 0); result = 31 * result + (eTag != null ? eTag.hashCode() : 0); result = 31 * result + excerpt.hashCode(); result = 31 * result + scrollY; @@ -273,9 +325,10 @@ public String toString() { ", modified=" + modified + ", content='" + content + '\'' + ", favorite=" + favorite + + ", isShared=" + isShared + ", eTag='" + eTag + '\'' + ", excerpt='" + excerpt + '\'' + ", scrollY=" + scrollY + '}'; } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/ShareEntity.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/ShareEntity.kt new file mode 100644 index 000000000..943a5e6aa --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/ShareEntity.kt @@ -0,0 +1,22 @@ +package it.niedermann.owncloud.notes.persistence.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "share_table") +data class ShareEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Int? = null, + val note: String? = null, + val path: String? = null, + val file_target: String? = null, + val share_with: String? = null, + val share_with_displayname: String? = null, + val uid_file_owner: String? = null, + val displayname_file_owner: String? = null, + val uid_owner: String? = null, + val displayname_owner: String? = null, + val url: String? = null +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java index be211bd9e..b9b5e959c 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java @@ -40,13 +40,49 @@ public class CapabilitiesDeserializer implements JsonDeserializer private static final String CAPABILITIES_FILES = "files"; private static final String CAPABILITIES_FILES_DIRECT_EDITING = "directEditing"; private static final String CAPABILITIES_FILES_DIRECT_EDITING_SUPPORTS_FILE_ID = "supportsFileId"; + private static final String CAPABILITIES_FILES_SHARING = "files_sharing"; + private static final String VERSION = "version"; @Override public Capabilities deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { final var response = new Capabilities(); final var data = json.getAsJsonObject(); + if (data.has(VERSION)) { + final var version = data.getAsJsonObject(VERSION); + final var nextcloudMajorVersion = version.get("major"); + response.setNextcloudMajorVersion(String.valueOf(nextcloudMajorVersion)); + + final var nextcloudMinorVersion = version.get("minor"); + response.setNextcloudMinorVersion(String.valueOf(nextcloudMinorVersion)); + + + final var nextcloudMicroVersion = version.get("micro"); + response.setNextcloudMicroVersion(String.valueOf(nextcloudMicroVersion)); + } + if (data.has(CAPABILITIES)) { final var capabilities = data.getAsJsonObject(CAPABILITIES); + + if (capabilities.has(CAPABILITIES_FILES_SHARING)) { + final var filesSharing = capabilities.getAsJsonObject(CAPABILITIES_FILES_SHARING); + final var federation = filesSharing.getAsJsonObject("federation"); + final var outgoing = federation.get("outgoing"); + + response.setFederationShare(outgoing.getAsBoolean()); + + final var publicObject = filesSharing.getAsJsonObject("public"); + final var password = publicObject.getAsJsonObject("password"); + final var enforced = password.getAsJsonPrimitive("enforced"); + final var askForOptionalPassword = password.getAsJsonPrimitive("askForOptionalPassword"); + final var isReSharingAllowed = filesSharing.getAsJsonPrimitive("resharing"); + final var defaultPermission = filesSharing.getAsJsonPrimitive("default_permissions"); + + response.setDefaultPermission(defaultPermission.getAsInt()); + response.setPublicPasswordEnforced(enforced.getAsBoolean()); + response.setAskForOptionalPassword(askForOptionalPassword.getAsBoolean()); + response.setReSharingAllowed(isReSharingAllowed.getAsBoolean()); + } + if (capabilities.has(CAPABILITIES_NOTES)) { final var notes = capabilities.getAsJsonObject(CAPABILITIES_NOTES); if (notes.has(CAPABILITIES_NOTES_API_VERSION)) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java index fa12f0f0f..060cae353 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI_0_2.java @@ -24,6 +24,7 @@ import retrofit2.http.Query; /** + * * @link Notes API v0.2 */ public interface NotesAPI_0_2 { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ShareAPI.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ShareAPI.kt new file mode 100644 index 000000000..53e3c0449 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ShareAPI.kt @@ -0,0 +1,66 @@ +package it.niedermann.owncloud.notes.persistence.sync + +import com.google.gson.internal.LinkedTreeMap +import it.niedermann.owncloud.notes.share.model.CreateShareRequest +import it.niedermann.owncloud.notes.share.model.CreateShareResponse +import it.niedermann.owncloud.notes.share.model.SharePasswordRequest +import it.niedermann.owncloud.notes.share.model.UpdateSharePermissionRequest +import it.niedermann.owncloud.notes.share.model.UpdateShareRequest +import it.niedermann.owncloud.notes.shared.model.OcsResponse +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +interface ShareAPI { + @GET("sharees") + fun getSharees( + @Query("format") format: String = "json", + @Query("itemType") itemType: String = "file", + @Query("search") search: String, + @Query("page") page: String, + @Query("perPage") perPage: String, + @Query("lookup") lookup: String = "false", + ): LinkedTreeMap? + + @GET("shares/{remoteId}?format=json") + fun getShares( + @Path("remoteId") remoteId: Long, + @Query("include_tags") includeTags: Boolean = true, + ): Call>> + + @GET("shares/?format=json") + fun getSharesForSpecificNote( + @Query("path") path: String, + @Query("reshares") reshares: Boolean = true, + @Query("subfiles") subfiles: Boolean = true + ): LinkedTreeMap? + + @DELETE("shares/{shareId}?format=json") + fun removeShare(@Path("shareId") shareId: Long): Call + + @POST("shares?format=json") + fun addShare(@Body request: CreateShareRequest): Call> + + @POST("shares/{shareId}/send-email?format=json") + fun sendEmail(@Path("shareId") shareId: Long, @Body password: SharePasswordRequest?): Call + + @PUT("shares/{shareId}?format=json") + fun updateShare(@Path("shareId") shareId: Long, @Body request: UpdateShareRequest): Call> + + @PUT("shares/{shareId}?format=json") + fun updateSharePermission( + @Path("shareId") shareId: Long, + @Body request: UpdateSharePermissionRequest + ): Call> + + @GET("shares/?format=json") + fun getShareFromNote( + @Query("path") path: String, + @Query("shared_with_me") sharedWithMe: Boolean = true + ): Call>> +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareActivity.java new file mode 100644 index 000000000..a27dd5b24 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareActivity.java @@ -0,0 +1,856 @@ +package it.niedermann.owncloud.notes.share; + +import android.Manifest; +import android.app.Activity; +import android.app.SearchManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.text.InputType; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.widget.SearchView; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.LinearLayoutManager; + +import com.google.android.material.snackbar.Snackbar; +import com.nextcloud.android.sso.helper.SingleAccountHelper; +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedActivity; +import it.niedermann.owncloud.notes.branding.BrandedSnackbar; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.databinding.ActivityNoteShareBinding; +import it.niedermann.owncloud.notes.main.MainActivity; +import it.niedermann.owncloud.notes.persistence.ApiResult; +import it.niedermann.owncloud.notes.persistence.ApiResultKt; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.persistence.entity.Note; +import it.niedermann.owncloud.notes.share.adapter.ShareeListAdapter; +import it.niedermann.owncloud.notes.share.adapter.SuggestionAdapter; +import it.niedermann.owncloud.notes.share.dialog.NoteShareActivityShareItemActionBottomSheetDialog; +import it.niedermann.owncloud.notes.share.dialog.QuickSharingPermissionsBottomSheetDialog; +import it.niedermann.owncloud.notes.share.dialog.ShareLinkToDialog; +import it.niedermann.owncloud.notes.share.dialog.SharePasswordDialogFragment; +import it.niedermann.owncloud.notes.share.helper.AvatarLoader; +import it.niedermann.owncloud.notes.share.helper.UsersAndGroupsSearchProvider; +import it.niedermann.owncloud.notes.share.listener.NoteShareItemAction; +import it.niedermann.owncloud.notes.share.listener.ShareeListAdapterListener; +import it.niedermann.owncloud.notes.share.model.CreateShareResponse; +import it.niedermann.owncloud.notes.share.model.CreateShareResponseExtensionsKt; +import it.niedermann.owncloud.notes.share.model.UsersAndGroupsSearchConfig; +import it.niedermann.owncloud.notes.share.repository.ShareRepository; +import it.niedermann.owncloud.notes.shared.model.Capabilities; +import it.niedermann.owncloud.notes.shared.model.OcsResponse; +import it.niedermann.owncloud.notes.shared.util.DisplayUtils; +import it.niedermann.owncloud.notes.shared.util.ShareUtil; +import it.niedermann.owncloud.notes.shared.util.clipboard.ClipboardUtil; +import it.niedermann.owncloud.notes.shared.util.extensions.BundleExtensionsKt; + +public class NoteShareActivity extends BrandedActivity implements ShareeListAdapterListener, NoteShareItemAction, QuickSharingPermissionsBottomSheetDialog.QuickPermissionSharingBottomSheetActions, SharePasswordDialogFragment.SharePasswordDialogListener { + + private static final String TAG = "NoteShareActivity"; + public static final String ARG_NOTE = "NOTE"; + public static final String ARG_ACCOUNT = "ACCOUNT"; + public static final String FTAG_CHOOSER_DIALOG = "CHOOSER_DIALOG"; + + private ScheduledExecutorService executorService; + private Future future; + private static final long SEARCH_DELAY_MS = 500; + + private ActivityNoteShareBinding binding; + private Note note; + private Account account; + private ShareRepository repository; + private Capabilities capabilities; + private ActivityResultLauncher resultLauncher; + private final List shares = Collections.synchronizedList(new ArrayList<>()); + + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + executorService = Executors.newSingleThreadScheduledExecutor(); + binding = ActivityNoteShareBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + registerResultLauncher(); + initializeArguments(); + initializeOnBackPressedDispatcher(); + } + + private void registerResultLauncher() { + resultLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == RESULT_OK && result.getData() != null) { + recreate(); + } + }); + } + + private void initializeOnBackPressedDispatcher() { + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + Intent intent = new Intent(NoteShareActivity.this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivity(intent); + finish(); + } + }); + } + + private void initializeArguments() { + Bundle bundler = getIntent().getExtras(); + note = BundleExtensionsKt.getSerializableArgument(bundler, ARG_NOTE, Note.class); + account = BundleExtensionsKt.getSerializableArgument(bundler, ARG_ACCOUNT, Account.class); + + if (note == null) { + throw new IllegalArgumentException("Note cannot be null"); + } + + if (account == null) { + throw new IllegalArgumentException("Account cannot be null"); + } + + executorService.submit(() -> { + try { + final var ssoAcc = SingleAccountHelper.getCurrentSingleSignOnAccount(NoteShareActivity.this); + repository = new ShareRepository(NoteShareActivity.this, ssoAcc); + capabilities = repository.getCapabilities(); + repository.getSharesForNotesAndSaveShareEntities(); + + runOnUiThread(() -> { + binding.searchContainer.setVisibility(View.VISIBLE); + binding.sharesList.setVisibility(View.VISIBLE); + binding.sharesList.setAdapter(new ShareeListAdapter(this, new ArrayList<>(), this, account)); + binding.sharesList.setLayoutManager(new LinearLayoutManager(this)); + binding.pickContactEmailBtn.setOnClickListener(v -> checkContactPermission()); + binding.btnShareButton.setOnClickListener(v -> ShareUtil.openShareDialog(this, note.getTitle(), note.getContent())); + + if (note.getReadonly()) { + setupReadOnlySearchView(); + } else { + setupSearchView((SearchManager) getSystemService(Context.SEARCH_SERVICE), getComponentName()); + } + + updateShareeListAdapter(); + binding.loadingLayout.setVisibility(View.GONE); + }); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @Override + public void onStart() { + super.onStart(); + + // note cannot be encrypted - logic ported from files app + UsersAndGroupsSearchConfig.INSTANCE.setSearchOnlyUsers(false); + } + + @Override + public void onStop() { + super.onStop(); + UsersAndGroupsSearchConfig.INSTANCE.reset(); + } + + private void disableSearchView(View view) { + view.setEnabled(false); + + if (view instanceof ViewGroup viewGroup) { + for (int i = 0; i < viewGroup.getChildCount(); i++) { + disableSearchView(viewGroup.getChildAt(i)); + } + } + } + + private void setupReadOnlySearchView() { + binding.searchView.setIconifiedByDefault(false); + binding.searchView.setQueryHint(getResources().getString(R.string.note_share_activity_resharing_not_allowed)); + binding.searchView.setInputType(InputType.TYPE_NULL); + binding.pickContactEmailBtn.setVisibility(View.GONE); + disableSearchView(binding.searchView); + } + + private void setupSearchView(@Nullable SearchManager searchManager, ComponentName componentName) { + if (searchManager == null) { + binding.searchView.setVisibility(View.GONE); + return; + } + + SuggestionAdapter suggestionAdapter = new SuggestionAdapter(this, null, account); + UsersAndGroupsSearchProvider provider = new UsersAndGroupsSearchProvider(this, repository); + + binding.searchView.setSuggestionsAdapter(suggestionAdapter); + binding.searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName)); + binding.searchView.setIconifiedByDefault(false); + binding.searchView.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI); + binding.searchView.setQueryHint(getResources().getString(R.string.note_share_activity_search_text)); + binding.searchView.setInputType(InputType.TYPE_NULL); + + View closeButton = binding.searchView.findViewById(androidx.appcompat.R.id.search_close_btn); + closeButton.setOnClickListener(v -> { + binding.progressBar.setVisibility(View.GONE); + binding.searchView.setQuery("", false); + }); + + binding.searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + // return true to prevent the query from being processed; + return true; + } + + @Override + public boolean onQueryTextChange(String newText) { + if (!newText.isEmpty()) { + binding.progressBar.setVisibility(View.VISIBLE); + + // Cancel the previous task if it's still running + if (future != null && !future.isDone()) { + future.cancel(true); + } + + // Schedule a new task with a delay + future = executorService.schedule(() -> { + if (capabilities == null) { + Log_OC.d(TAG, "Capabilities cannot be null"); + return; + } + + final var isFederationShareAllowed = capabilities.getFederationShare(); + try { + var cursor = provider.searchForUsersOrGroups(newText, isFederationShareAllowed); + + if (cursor == null || cursor.getCount() == 0) { + return; + } + + runOnUiThread(() -> { + if (binding.searchView.getVisibility() == View.VISIBLE) { + suggestionAdapter.swapCursor(cursor); + } + + binding.progressBar.setVisibility(View.GONE); + }); + + } catch (Exception e) { + Log_OC.d(TAG, "Exception setupSearchView.onQueryTextChange: " + e); + runOnUiThread(() -> binding.progressBar.setVisibility(View.GONE)); + } + + }, SEARCH_DELAY_MS, TimeUnit.MILLISECONDS); + } else { + binding.progressBar.setVisibility(View.GONE); + suggestionAdapter.swapCursor(null); + } + return false; + } + }); + + binding.searchView.setOnSuggestionListener(new SearchView.OnSuggestionListener() { + @Override + public boolean onSuggestionSelect(int position) { + return false; + } + + @Override + public boolean onSuggestionClick(int position) { + Cursor cursor = suggestionAdapter.getCursor(); + if (cursor != null && cursor.moveToPosition(position)) { + String suggestion = cursor.getString(cursor.getColumnIndexOrThrow(SearchManager.SUGGEST_COLUMN_TEXT_1)); + binding.searchView.setQuery(suggestion, false); + + String shareWith = cursor.getString(cursor.getColumnIndexOrThrow(UsersAndGroupsSearchProvider.SHARE_WITH)); + int shareType = cursor.getInt(cursor.getColumnIndexOrThrow(UsersAndGroupsSearchProvider.SHARE_TYPE)); + navigateNoteShareDetail(shareWith, shareType); + } + return true; + } + }); + } + + private void navigateNoteShareDetail(String shareWith, int shareType) { + Bundle bundle = new Bundle(); + + bundle.putSerializable(NoteShareDetailActivity.ARG_NOTE, note); + bundle.putString(NoteShareDetailActivity.ARG_SHAREE_NAME, shareWith); + bundle.putInt(NoteShareDetailActivity.ARG_SHARE_TYPE, shareType); + + Intent intent = new Intent(this, NoteShareDetailActivity.class); + intent.putExtras(bundle); + resultLauncher.launch(intent); + } + + private boolean accountOwnsFile() { + if (shares.isEmpty()) { + return true; + } + + final var share = shares.get(0); + String ownerDisplayName = share.getOwnerDisplayName(); + return TextUtils.isEmpty(ownerDisplayName) || account.getAccountName().split("@")[0].equalsIgnoreCase(ownerDisplayName); + } + + private void setShareWithYou() { + if (accountOwnsFile()) { + binding.sharedWithYouContainer.setVisibility(View.GONE); + } else { + if (shares.isEmpty()) { + return; + } + + final var share = shares.get(0); + + binding.sharedWithYouUsername.setText( + String.format(getString(R.string.note_share_activity_shared_with_you), share.getOwnerDisplayName())); + AvatarLoader.INSTANCE.load(this, binding.sharedWithYouAvatar, account); + binding.sharedWithYouAvatar.setVisibility(View.VISIBLE); + + String description = share.getNote(); + + if (!TextUtils.isEmpty(description)) { + binding.sharedWithYouNote.setText(description); + binding.sharedWithYouNoteContainer.setVisibility(View.VISIBLE); + } else { + binding.sharedWithYouNoteContainer.setVisibility(View.GONE); + } + } + } + + public void copyInternalLink() { + if (account == null) { + DisplayUtils.showSnackMessage(this, getString(R.string.note_share_activity_could_not_retrieve_url)); + return; + } + + final var link = createInternalLink(); + showShareLinkDialog(link); + } + + @Override + public void createPublicShareLink() { + if (capabilities == null) { + Log_OC.d(TAG, "Capabilities cannot be null"); + return; + } + + if (capabilities.getPublicPasswordEnforced() || capabilities.getAskForOptionalPassword()) { + // password enforced by server, request to the user before trying to create + requestPasswordForShareViaLink(true, capabilities.getAskForOptionalPassword()); + } else { + executorService.submit(() -> { + final var result = repository.addShare(note, ShareType.PUBLIC_LINK, "", "false", "", 0, ""); + runOnUiThread(() -> { + if (result instanceof ApiResult.Success> successResponse && binding.sharesList.getAdapter() instanceof ShareeListAdapter adapter) { + DisplayUtils.showSnackMessage(NoteShareActivity.this, successResponse.getMessage()); + note.setIsShared(true); + repository.updateNote(note); + adapter.addShare(CreateShareResponseExtensionsKt.toOCShare(successResponse.getData().ocs.data)); + } else if (result instanceof ApiResult.Error errorResponse) { + DisplayUtils.showSnackMessage(NoteShareActivity.this, errorResponse.getMessage()); + } + }); + }); + } + } + + public void requestPasswordForShareViaLink(boolean createShare, boolean askForPassword) { + SharePasswordDialogFragment dialog = SharePasswordDialogFragment.newInstance(note, createShare, askForPassword, this); + dialog.show(getSupportFragmentManager(), SharePasswordDialogFragment.PASSWORD_FRAGMENT); + } + + // TODO: + @Override + public void createSecureFileDrop() { + + } + + private void showShareLinkDialog(String link) { + Intent intentToShareLink = new Intent(Intent.ACTION_SEND); + + intentToShareLink.putExtra(Intent.EXTRA_TEXT, link); + intentToShareLink.setType("text/plain"); + intentToShareLink.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.note_share_activity_subject_shared_with_you, note.getTitle())); + + String[] packagesToExclude = new String[]{this.getPackageName()}; + DialogFragment chooserDialog = ShareLinkToDialog.newInstance(intentToShareLink, packagesToExclude); + chooserDialog.show(getSupportFragmentManager(), FTAG_CHOOSER_DIALOG); + } + + private String createInternalLink() { + Uri baseUri = Uri.parse(account.getUrl()); + return baseUri + "/index.php/f/" + note.getRemoteId(); + } + + @Override + public void copyLink(OCShare share) { + if (!note.isShared()) { + return; + } + + if (TextUtils.isEmpty(share.getShareLink())) { + copyAndShareFileLink(share.getShareLink()); + } else { + ClipboardUtil.copyToClipboard(this, share.getShareLink()); + } + } + + private void copyAndShareFileLink(String link) { + ClipboardUtil.copyToClipboard(this, link, false); + Snackbar snackbar = Snackbar + .make(this.findViewById(android.R.id.content), R.string.clipboard_text_copied, Snackbar.LENGTH_LONG) + .setAction(R.string.share, v -> showShareLinkDialog(link)); + snackbar.show(); + } + + @Override + public void showSharingMenuActionSheet(OCShare share) { + if (!this.isFinishing()) { + new NoteShareActivityShareItemActionBottomSheetDialog(this, this, share).show(); + } + } + + @Override + public void showPermissionsDialog(OCShare share) { + new QuickSharingPermissionsBottomSheetDialog(this, this, share).show(); + } + + @Override + public void requestPasswordForShare(OCShare share, boolean askForPassword) { + SharePasswordDialogFragment dialog = SharePasswordDialogFragment.newInstance(share, askForPassword, this); + dialog.show(getSupportFragmentManager(), SharePasswordDialogFragment.PASSWORD_FRAGMENT); + } + + // TODO: + @Override + public void showProfileBottomSheet(Account account, String shareWith) { + } + + private boolean isInitializingShares = false; + + public void updateShareeListAdapter() { + if (isInitializingShares) { + Log_OC.d(TAG, "Shares initialization already in progress"); + return; + } + + isInitializingShares = true; + + executorService.submit(() -> { + try { + ShareeListAdapter adapter = (ShareeListAdapter) binding.sharesList.getAdapter(); + + if (adapter == null) { + runOnUiThread(() -> DisplayUtils.showSnackMessage(NoteShareActivity.this, getString(R.string.could_not_retrieve_shares))); + isInitializingShares = false; + return; + } + + List tempShares = new ArrayList<>(); + + // to show share with users/groups info + if (note != null) { + // get shares from local DB + populateSharesList(tempShares); + } + + runOnUiThread(() -> { + shares.clear(); + shares.addAll(tempShares); + + adapter.removeAll(); + adapter.addShares(shares); + addPublicShares(adapter); + setShareWithYou(); + + isInitializingShares = false; + }); + } catch (Exception e) { + Log_OC.d(TAG, "Exception while updateShareeListAdapter: " + e); + } + }); + } + + private void populateSharesList(List targetList) { + // Get shares from local DB + final var shareEntities = repository.getShareEntitiesForSpecificNote(note); + for (var entity : shareEntities) { + if (entity.getId() != null) { + addSharesToList(entity.getId(), targetList); + } + } + + // Get shares from remote + final var remoteShares = repository.getShareFromNote(note); + if (remoteShares != null) { + for (var entity : remoteShares) { + addSharesToList(entity.getId(), targetList); + } + } + } + + private void addSharesToList(long id, List targetList) { + final var result = repository.getShares(id); + if (result != null) { + for (OCShare ocShare : result) { + if (!targetList.contains(ocShare)) { + targetList.add(ocShare); + } + } + } + } + + private void addPublicShares(ShareeListAdapter adapter) { + List publicShares = new ArrayList<>(); + + if (containsNoNewPublicShare(adapter.getShares())) { + final OCShare ocShare = new OCShare(); + ocShare.setShareType(ShareType.NEW_PUBLIC_LINK); + publicShares.add(ocShare); + } else { + adapter.removeNewPublicShare(); + } + + adapter.addShares(publicShares); + } + + private void checkContactPermission() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { + pickContactEmail(); + } else { + requestContactPermissionLauncher.launch(Manifest.permission.READ_CONTACTS); + } + } + + private void pickContactEmail() { + Intent intent = new Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI); + + // FIXME: + if (intent.resolveActivity(getPackageManager()) != null) { + onContactSelectionResultLauncher.launch(intent); + } else { + DisplayUtils.showSnackMessage(this, getString(R.string.file_detail_sharing_fragment_no_contact_app_message)); + } + } + + private void handleContactResult(@NonNull Uri contactUri) { + // Define the projection to get all email addresses. + String[] projection = {ContactsContract.CommonDataKinds.Email.ADDRESS}; + + Cursor cursor = getContentResolver().query(contactUri, projection, null, null, null); + if (cursor == null) { + DisplayUtils.showSnackMessage(this, getString(R.string.email_pick_failed)); + Log_OC.e(TAG, "Failed to pick email address as Cursor is null."); + return; + } + + if (!cursor.moveToFirst()) { + DisplayUtils.showSnackMessage(this, getString(R.string.email_pick_failed)); + Log_OC.e(TAG, "Failed to pick email address as no Email found."); + return; + } + + // The contact has only one email address, use it. + int columnIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS); + if (columnIndex == -1) { + DisplayUtils.showSnackMessage(this, getString(R.string.email_pick_failed)); + Log_OC.e(TAG, "Failed to pick email address."); + return; + } + + // Use the email address as needed. + // email variable contains the selected contact's email address. + String email = cursor.getString(columnIndex); + binding.searchView.post(() -> { + binding.searchView.setQuery(email, false); + binding.searchView.requestFocus(); + }); + + cursor.close(); + } + + private boolean containsNoNewPublicShare(List shares) { + for (OCShare share : shares) { + if (share.getShareType() != null && share.getShareType() == ShareType.NEW_PUBLIC_LINK) { + return false; + } + } + + return true; + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putSerializable(ARG_NOTE, note); + outState.putSerializable(ARG_ACCOUNT, account); + } + + private boolean isReshareForbidden(OCShare share) { + return (share.getShareType() != null && ShareType.FEDERATED == share.getShareType()) || + capabilities != null && !capabilities.isReSharingAllowed(); + } + + @VisibleForTesting + public void search(String query) { + SearchView searchView = findViewById(R.id.searchView); + searchView.setQuery(query, true); + } + + @Override + public void advancedPermissions(OCShare share) { + modifyExistingShare(share, NoteShareDetailActivity.SCREEN_TYPE_PERMISSION); + } + + + @Override + public void sendNewEmail(OCShare share) { + modifyExistingShare(share, NoteShareDetailActivity.SCREEN_TYPE_NOTE); + } + + @Override + public void unShare(OCShare share) { + executorService.submit(() -> { + final var result = repository.removeShare(share, note); + + runOnUiThread(() -> { + if (result) { + ShareeListAdapter adapter = (ShareeListAdapter) binding.sharesList.getAdapter(); + if (adapter == null) { + DisplayUtils.showSnackMessage(NoteShareActivity.this, getString(R.string.email_pick_failed)); + return; + } + adapter.remove(share); + } else { + DisplayUtils.showSnackMessage(NoteShareActivity.this, getString(R.string.failed_the_remove_share)); + } + }); + }); + } + + // TODO: + @Override + public void sendLink(OCShare share) { + } + + @Override + public void addAnotherLink(OCShare share) { + createPublicShareLink(); + } + + private void modifyExistingShare(OCShare share, int screenTypePermission) { + Bundle bundle = new Bundle(); + + bundle.putSerializable(NoteShareDetailActivity.ARG_OCSHARE, share); + bundle.putInt(NoteShareDetailActivity.ARG_SCREEN_TYPE, screenTypePermission); + bundle.putBoolean(NoteShareDetailActivity.ARG_RESHARE_SHOWN, !isReshareForbidden(share)); + bundle.putBoolean(NoteShareDetailActivity.ARG_EXP_DATE_SHOWN, getExpDateShown()); + + Intent intent = new Intent(this, NoteShareDetailActivity.class); + intent.putExtras(bundle); + resultLauncher.launch(intent); + } + + private boolean getExpDateShown() { + try { + if (capabilities == null) { + Log_OC.d(TAG, "Capabilities cannot be null"); + return false; + } + + final var majorVersionAsString = capabilities.getNextcloudMajorVersion(); + if (majorVersionAsString != null) { + final var majorVersion = Integer.parseInt(majorVersionAsString); + return majorVersion >= 18; + } + + return false; + } catch (NumberFormatException e) { + Log_OC.d(TAG, "Exception while getting expDateShown"); + return false; + } + } + + @Override + public void onQuickPermissionChanged(OCShare share, int permission) { + executorService.submit(() -> { + final var result = repository.updateSharePermission(share.getId(), permission); + runOnUiThread(() -> { + if (ApiResultKt.isSuccess(result)) { + updateShare(share); + } else if (result instanceof ApiResult.Error error) { + DisplayUtils.showSnackMessage(NoteShareActivity.this, error.getMessage()); + } + }); + }); + } + + private void updateShare(OCShare share) { + executorService.submit(() -> { + try { + final var updatedShares = repository.getShares(share.getId()); + + runOnUiThread(() -> { + if (updatedShares != null && binding.sharesList.getAdapter() instanceof ShareeListAdapter adapter) { + OCShare updatedShare = null; + for (int i=0;i requestContactPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { + if (isGranted) { + pickContactEmail(); + } else { + BrandedSnackbar.make(binding.getRoot(), getString(R.string.note_share_activity_contact_no_permission), Snackbar.LENGTH_LONG) + .show(); + } + }); + + private final ActivityResultLauncher onContactSelectionResultLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + Intent intent = result.getData(); + if (intent == null) { + BrandedSnackbar.make(binding.getRoot(), getString(R.string.email_pick_failed), Snackbar.LENGTH_LONG) + .show(); + return; + } + + Uri contactUri = intent.getData(); + if (contactUri == null) { + BrandedSnackbar.make(binding.getRoot(), getString(R.string.email_pick_failed), Snackbar.LENGTH_LONG) + .show(); + return; + } + + handleContactResult(contactUri); + + } + }); + + @Override + public void applyBrand(int color) { + final var util = BrandingUtil.of(color, this); + util.platform.themeStatusBar(this); + util.androidx.themeToolbarSearchView(binding.searchView); + util.platform.themeHorizontalProgressBar(binding.progressBar); + } + + @Override + protected void onDestroy() { + executorService.shutdown(); + super.onDestroy(); + } + + @Override + public void shareFileViaPublicShare(@Nullable Note note, @Nullable String password) { + if (note == null || password == null) { + Log_OC.d(TAG, "note or password is null, cannot create a public share"); + return; + } + + executorService.submit(() -> { + final var result = repository.addShare( + note, + ShareType.PUBLIC_LINK, + "", + "false", + password, + repository.getCapabilities().getDefaultPermission(), + "" + ); + + runOnUiThread(() -> { + if (result instanceof ApiResult.Success> successResponse && + binding.sharesList.getAdapter() instanceof ShareeListAdapter adapter) { + adapter.addShare(CreateShareResponseExtensionsKt.toOCShare(successResponse.getData().ocs.data)); + } else if (result instanceof ApiResult.Error error) { + DisplayUtils.showSnackMessage(NoteShareActivity.this, error.getMessage()); + } + }); + }); + } + + @Override + public void setPasswordToShare(@NotNull OCShare share, @Nullable String password) { + if (password == null) { + Log_OC.d(TAG, "password is null, cannot update a public share"); + return; + } + + executorService.submit(() -> { + { + final var requestBody = repository.getUpdateShareRequest( + false, + share, + "", + password, + false, + -1, + share.getPermissions() + ); + final var result = repository.updateShare(share.getId(), requestBody); + + runOnUiThread(() -> { + if (ApiResultKt.isSuccess(result)) { + final var intent = new Intent(NoteShareActivity.this, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + NoteShareActivity.this.startActivity(intent); + } else if (ApiResultKt.isError(result)) { + ApiResult.Error error = (ApiResult.Error) result; + DisplayUtils.showSnackMessage(NoteShareActivity.this, error.getMessage()); + } + }); + } + }); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareDetailActivity.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareDetailActivity.kt new file mode 100644 index 000000000..bfa39fd43 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareDetailActivity.kt @@ -0,0 +1,611 @@ +package it.niedermann.owncloud.notes.share + +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.text.TextUtils +import android.view.View +import androidx.lifecycle.lifecycleScope +import com.nextcloud.android.sso.helper.SingleAccountHelper +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.SharePermissionsBuilder +import com.owncloud.android.lib.resources.shares.ShareType +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.branding.BrandedActivity +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.ActivityNoteShareDetailBinding +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.persistence.isSuccess +import it.niedermann.owncloud.notes.share.dialog.ExpirationDatePickerDialogFragment +import it.niedermann.owncloud.notes.share.helper.SharingMenuHelper +import it.niedermann.owncloud.notes.share.model.SharePasswordRequest +import it.niedermann.owncloud.notes.share.repository.ShareRepository +import it.niedermann.owncloud.notes.shared.util.DisplayUtils +import it.niedermann.owncloud.notes.shared.util.clipboard.ClipboardUtil +import it.niedermann.owncloud.notes.shared.util.extensions.getParcelableArgument +import it.niedermann.owncloud.notes.shared.util.extensions.getSerializableArgument +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date + +/** + * Activity class to show share permission options, set expiration date, change label, set password, send note + * + * This activity handles following: + * 1. This will be shown while creating new internal and external share. So that user can set every share + * configuration at one time. + * 2. This will handle both Advanced Permissions and Send New Email functionality for existing shares to modify them. + */ +@Suppress("TooManyFunctions") +class NoteShareDetailActivity : BrandedActivity(), + ExpirationDatePickerDialogFragment.OnExpiryDateListener { + + companion object { + const val TAG = "NoteShareDetailActivity" + const val ARG_NOTE = "arg_sharing_note" + const val ARG_SHAREE_NAME = "arg_sharee_name" + const val ARG_SHARE_TYPE = "arg_share_type" + const val ARG_OCSHARE = "arg_ocshare" + const val ARG_SCREEN_TYPE = "arg_screen_type" + const val ARG_RESHARE_SHOWN = "arg_reshare_shown" + const val ARG_EXP_DATE_SHOWN = "arg_exp_date_shown" + private const val ARG_SECURE_SHARE = "secure_share" + + // types of screens to be displayed + const val SCREEN_TYPE_PERMISSION = 1 // permissions screen + const val SCREEN_TYPE_NOTE = 2 // note screen + } + + private lateinit var binding: ActivityNoteShareDetailBinding + private var note: Note? = null // note to be share + private var shareeName: String? = null + private lateinit var shareType: ShareType + private var shareProcessStep = SCREEN_TYPE_PERMISSION // default screen type + private var permission = OCShare.NO_PERMISSION // no permission + private var chosenExpDateInMills: Long = -1 // for no expiry date + + private var share: OCShare? = null + private var isReShareShown: Boolean = true // show or hide reShare option + private var isExpDateShown: Boolean = true // show or hide expiry date option + private var isSecureShare: Boolean = false + + private var expirationDatePickerFragment: ExpirationDatePickerDialogFragment? = null + private lateinit var repository: ShareRepository + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityNoteShareDetailBinding.inflate(layoutInflater) + setContentView(binding.root) + val arguments = intent.extras + + arguments?.let { + note = it.getSerializableArgument(ARG_NOTE, Note::class.java) + shareeName = it.getString(ARG_SHAREE_NAME) + share = it.getParcelableArgument(ARG_OCSHARE, OCShare::class.java) + + if (it.containsKey(ARG_SHARE_TYPE)) { + shareType = ShareType.fromValue(it.getInt(ARG_SHARE_TYPE)) + } else if (share != null) { + shareType = share!!.shareType!! + } + + shareProcessStep = it.getInt(ARG_SCREEN_TYPE, SCREEN_TYPE_PERMISSION) + isReShareShown = it.getBoolean(ARG_RESHARE_SHOWN, true) + isExpDateShown = it.getBoolean(ARG_EXP_DATE_SHOWN, true) + isSecureShare = it.getBoolean(ARG_SECURE_SHARE, false) + } + + lifecycleScope.launch(Dispatchers.IO) { + val ssoAcc = + SingleAccountHelper.getCurrentSingleSignOnAccount(this@NoteShareDetailActivity) + repository = ShareRepository(this@NoteShareDetailActivity, ssoAcc) + permission = repository.getCapabilities().defaultPermission + + withContext(Dispatchers.Main) { + if (shareProcessStep == SCREEN_TYPE_PERMISSION) { + showShareProcessFirst() + } else { + showShareProcessSecond() + } + implementClickEvents() + } + } + } + + + override fun applyBrand(color: Int) { + val util = BrandingUtil.of(color, this) + + binding.run { + util.platform.run { + themeRadioButton(shareProcessPermissionReadOnly) + themeRadioButton(shareProcessPermissionUploadEditing) + themeRadioButton(shareProcessPermissionFileDrop) + + colorTextView(shareProcessEditShareLink) + colorTextView(shareProcessAdvancePermissionTitle) + + themeCheckbox(shareProcessAllowResharingCheckbox) + } + + util.androidx.run { + colorSwitchCompat(shareProcessSetPasswordSwitch) + colorSwitchCompat(shareProcessSetExpDateSwitch) + colorSwitchCompat(shareProcessHideDownloadCheckbox) + colorSwitchCompat(shareProcessChangeNameSwitch) + } + + util.material.run { + colorTextInputLayout(shareProcessEnterPasswordContainer) + colorTextInputLayout(shareProcessChangeNameContainer) + colorTextInputLayout(noteContainer) + + colorMaterialButtonPrimaryFilled(shareProcessBtnNext) + colorMaterialButtonPrimaryOutlined(shareProcessBtnCancel) + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // Force recreation of dialog activity when screen rotates + // This is needed because the calendar layout should be different in portrait and landscape, + // but as FDA persists through config changes, the dialog is not recreated automatically + val datePicker = expirationDatePickerFragment + if (datePicker?.dialog?.isShowing == true) { + val currentSelectionMillis = datePicker.currentSelectionMillis + datePicker.dismiss() + showExpirationDateDialog(currentSelectionMillis) + } + } + + private fun showShareProcessFirst() { + binding.shareProcessGroupOne.visibility = View.VISIBLE + binding.shareProcessEditShareLink.visibility = View.VISIBLE + binding.shareProcessGroupTwo.visibility = View.GONE + + if (share != null) { + setupModificationUI() + } else { + setupUpdateUI() + } + + if (isSecureShare) { + binding.shareProcessAdvancePermissionTitle.visibility = View.GONE + } + + // show or hide expiry date + if (isExpDateShown && !isSecureShare) { + binding.shareProcessSetExpDateSwitch.visibility = View.VISIBLE + } else { + binding.shareProcessSetExpDateSwitch.visibility = View.GONE + } + shareProcessStep = SCREEN_TYPE_PERMISSION + } + + private fun setupModificationUI() { + if (share?.isFolder == true) updateViewForFolder() else updateViewForFile() + + // read only / allow upload and editing / file drop + if (SharingMenuHelper.isUploadAndEditingAllowed(share)) { + binding.shareProcessPermissionUploadEditing.isChecked = true + } else if (SharingMenuHelper.isFileDrop(share) && share?.isFolder == true) { + binding.shareProcessPermissionFileDrop.isChecked = true + } else if (SharingMenuHelper.isReadOnly(share)) { + binding.shareProcessPermissionReadOnly.isChecked = true + } + + shareType = share?.shareType ?: ShareType.NO_SHARED + + // show different text for link share and other shares + // because we have link to share in Public Link + binding.shareProcessBtnNext.text = getString( + if (shareType == ShareType.PUBLIC_LINK) { + R.string.note_share_detail_activity_share_copy_link + } else { + R.string.note_share_detail_activity_common_confirm + } + ) + + updateViewForShareType() + binding.shareProcessSetPasswordSwitch.isChecked = share?.isPasswordProtected == true + showPasswordInput(binding.shareProcessSetPasswordSwitch.isChecked) + updateExpirationDateView() + showExpirationDateInput(binding.shareProcessSetExpDateSwitch.isChecked) + } + + private fun setupUpdateUI() { + binding.shareProcessBtnNext.text = + getString(R.string.note_share_detail_activity_common_next) + note.let { + updateViewForFile() + updateViewForShareType() + } + showPasswordInput(binding.shareProcessSetPasswordSwitch.isChecked) + showExpirationDateInput(binding.shareProcessSetExpDateSwitch.isChecked) + } + + private fun updateViewForShareType() { + when (shareType) { + ShareType.EMAIL -> { + updateViewForExternalShare() + } + + ShareType.PUBLIC_LINK -> { + updateViewForLinkShare() + } + + else -> { + updateViewForInternalShare() + } + } + } + + private fun updateViewForExternalShare() { + binding.shareProcessChangeNameSwitch.visibility = View.GONE + binding.shareProcessChangeNameContainer.visibility = View.GONE + updateViewForExternalAndLinkShare() + } + + private fun updateViewForLinkShare() { + updateViewForExternalAndLinkShare() + binding.shareProcessChangeNameSwitch.visibility = View.VISIBLE + if (share != null) { + binding.shareProcessChangeName.setText(share?.label) + binding.shareProcessChangeNameSwitch.isChecked = !TextUtils.isEmpty(share?.label) + } + showChangeNameInput(binding.shareProcessChangeNameSwitch.isChecked) + } + + private fun updateViewForInternalShare() { + binding.run { + shareProcessChangeNameSwitch.visibility = View.GONE + shareProcessChangeNameContainer.visibility = View.GONE + shareProcessHideDownloadCheckbox.visibility = View.GONE + if (isSecureShare) { + shareProcessAllowResharingCheckbox.visibility = View.GONE + } else { + shareProcessAllowResharingCheckbox.visibility = View.VISIBLE + } + shareProcessSetPasswordSwitch.visibility = View.GONE + + if (share != null) { + if (!isReShareShown) { + shareProcessAllowResharingCheckbox.visibility = View.GONE + } + shareProcessAllowResharingCheckbox.isChecked = + SharingMenuHelper.canReshare(share) + } + } + } + + /** + * update views where share type external or link share + */ + private fun updateViewForExternalAndLinkShare() { + binding.run { + shareProcessHideDownloadCheckbox.visibility = View.VISIBLE + shareProcessAllowResharingCheckbox.visibility = View.GONE + shareProcessSetPasswordSwitch.visibility = View.VISIBLE + + if (share != null) { + if (SharingMenuHelper.isFileDrop(share)) { + shareProcessHideDownloadCheckbox.visibility = View.GONE + } else { + shareProcessHideDownloadCheckbox.visibility = View.VISIBLE + shareProcessHideDownloadCheckbox.isChecked = + share?.isHideFileDownload == true + } + } + } + } + + /** + * update expiration date view while modifying the share + */ + private fun updateExpirationDateView() { + share?.let { share -> + if (share.expirationDate > 0) { + chosenExpDateInMills = share.expirationDate + binding.shareProcessSetExpDateSwitch.isChecked = true + binding.shareProcessSelectExpDate.text = getString( + R.string.share_expiration_date_format, + SimpleDateFormat.getDateInstance().format(Date(share.expirationDate)) + ) + } + } + } + + private fun updateViewForFile() { + binding.shareProcessPermissionUploadEditing.text = getString(R.string.link_share_editing) + binding.shareProcessPermissionFileDrop.visibility = View.GONE + } + + private fun updateViewForFolder() { + binding.run { + shareProcessPermissionUploadEditing.text = + getString(R.string.link_share_allow_upload_and_editing) + shareProcessPermissionFileDrop.visibility = View.VISIBLE + if (isSecureShare) { + shareProcessPermissionFileDrop.visibility = View.GONE + shareProcessAllowResharingCheckbox.visibility = View.GONE + shareProcessSetExpDateSwitch.visibility = View.GONE + } + } + } + + /** + * update views for screen type Note + */ + private fun showShareProcessSecond() { + binding.run { + shareProcessGroupOne.visibility = View.GONE + shareProcessEditShareLink.visibility = View.GONE + shareProcessGroupTwo.visibility = View.VISIBLE + if (share != null) { + shareProcessBtnNext.text = + getString(R.string.note_share_detail_activity_set_note) + noteText.setText(share?.note) + } else { + shareProcessBtnNext.text = + getString(R.string.note_share_detail_activity_send_share) + noteText.setText(R.string.empty) + } + shareProcessStep = SCREEN_TYPE_NOTE + shareProcessBtnNext.performClick() + } + } + + private fun implementClickEvents() { + binding.run { + shareProcessBtnCancel.setOnClickListener { + onCancelClick() + } + shareProcessBtnNext.setOnClickListener { + if (shareProcessStep == SCREEN_TYPE_PERMISSION) { + validateShareProcessFirst() + } else { + createOrUpdateShare() + } + } + shareProcessSetPasswordSwitch.setOnCheckedChangeListener { _, isChecked -> + showPasswordInput(isChecked) + } + shareProcessSetExpDateSwitch.setOnCheckedChangeListener { _, isChecked -> + showExpirationDateInput(isChecked) + } + shareProcessChangeNameSwitch.setOnCheckedChangeListener { _, isChecked -> + showChangeNameInput(isChecked) + } + shareProcessSelectExpDate.setOnClickListener { + showExpirationDateDialog() + } + } + } + + private fun showExpirationDateDialog(chosenDateInMillis: Long = chosenExpDateInMills) { + val dialog = ExpirationDatePickerDialogFragment.newInstance(chosenDateInMillis) + dialog.setOnExpiryDateListener(this) + expirationDatePickerFragment = dialog + dialog.show(supportFragmentManager, ExpirationDatePickerDialogFragment.DATE_PICKER_DIALOG) + } + + private fun showChangeNameInput(isChecked: Boolean) { + binding.shareProcessChangeNameContainer.visibility = + if (isChecked) View.VISIBLE else View.GONE + if (!isChecked) { + binding.shareProcessChangeName.setText(R.string.empty) + } + } + + private fun onCancelClick() { + // if modifying the existing share then on back press remove the current activity + if (share != null) { + finish() + } + + // else we have to check if user is in step 2(note screen) then show step 1 (permission screen) + // and if user is in step 1 (permission screen) then remove the activity + else { + if (shareProcessStep == SCREEN_TYPE_NOTE) { + showShareProcessFirst() + } else { + finish() + } + } + } + + private fun showExpirationDateInput(isChecked: Boolean) { + binding.shareProcessSelectExpDate.visibility = if (isChecked) View.VISIBLE else View.GONE + binding.shareProcessExpDateDivider.visibility = if (isChecked) View.VISIBLE else View.GONE + + // reset the expiration date if switch is unchecked + if (!isChecked) { + chosenExpDateInMills = -1 + binding.shareProcessSelectExpDate.text = getString(R.string.empty) + } + } + + private fun showPasswordInput(isChecked: Boolean) { + binding.shareProcessEnterPasswordContainer.visibility = + if (isChecked) View.VISIBLE else View.GONE + + // reset the password if switch is unchecked + if (!isChecked) { + binding.shareProcessEnterPassword.setText(R.string.empty) + } + } + + + private fun getReSharePermission(): Int { + return SharePermissionsBuilder().apply { + setSharePermission(true) + }.build() + } + + /** + * method to validate the step 1 screen information + */ + @Suppress("ReturnCount") + private fun validateShareProcessFirst() { + permission = getSelectedPermission() + if (permission == OCShare.NO_PERMISSION) { + DisplayUtils.showSnackMessage( + binding.root, + R.string.note_share_detail_activity_no_share_permission_selected + ) + return + } + + if (binding.shareProcessSetPasswordSwitch.isChecked && + binding.shareProcessEnterPassword.text?.trim().isNullOrEmpty() + ) { + DisplayUtils.showSnackMessage( + binding.root, + R.string.note_share_detail_activity_share_link_empty_password + ) + return + } + + if (binding.shareProcessSetExpDateSwitch.isChecked && + binding.shareProcessSelectExpDate.text?.trim().isNullOrEmpty() + ) { + showExpirationDateDialog() + return + } + + if (binding.shareProcessChangeNameSwitch.isChecked && + binding.shareProcessChangeName.text?.trim().isNullOrEmpty() + ) { + DisplayUtils.showSnackMessage( + binding.root, + R.string.note_share_detail_activity_label_empty + ) + return + } + + // if modifying existing share information then execute the process + if (share != null) { + lifecycleScope.launch(Dispatchers.IO) { + val noteText = binding.noteText.text.toString().trim() + val password = binding.shareProcessEnterPassword.text.toString().trim() + + updateShare(noteText, password, false) + } + } else { + // else show step 2 (note screen) + showShareProcessSecond() + } + } + + /** + * get the permissions on the basis of selection + */ + private fun getSelectedPermission() = when { + binding.shareProcessAllowResharingCheckbox.isChecked -> getReSharePermission() + binding.shareProcessPermissionReadOnly.isChecked -> OCShare.READ_PERMISSION_FLAG + binding.shareProcessPermissionUploadEditing.isChecked -> OCShare.MAXIMUM_PERMISSIONS_FOR_FILE + binding.shareProcessPermissionFileDrop.isChecked -> OCShare.CREATE_PERMISSION_FLAG + else -> permission + } + + /** + * method to validate step 2 (note screen) information + */ + private fun createOrUpdateShare() { + val noteText = binding.noteText.text.toString().trim() + val password = binding.shareProcessEnterPassword.text.toString().trim() + + lifecycleScope.launch(Dispatchers.IO) { + if (share != null && share?.note != noteText) { + updateShare(noteText, password, true) + } else { + createShare(noteText, password) + } + } + } + + private suspend fun updateShare(noteText: String, password: String, sendEmail: Boolean) { + val downloadPermission = !binding.shareProcessHideDownloadCheckbox.isChecked + val requestBody = repository.getUpdateShareRequest( + downloadPermission, + share, + noteText, + password, + sendEmail, + chosenExpDateInMills, + permission + ) + + val updateShareResult = repository.updateShare(share!!.id, requestBody) + + if (updateShareResult.isSuccess() && sendEmail) { + val sendEmailResult = repository.sendEmail(share!!.id, SharePasswordRequest(password)) + handleResult(sendEmailResult) + } else { + handleResult(updateShareResult.isSuccess()) + } + + if (!sendEmail) { + withContext(Dispatchers.Main) { + if (!TextUtils.isEmpty(share?.shareLink)) { + ClipboardUtil.copyToClipboard(this@NoteShareDetailActivity, share?.shareLink) + } + } + } + } + + private suspend fun createShare(noteText: String, password: String) { + if (note == null || shareeName == null) { + Log_OC.d(TAG, "validateShareProcessSecond cancelled") + return + } + + val result = repository.addShare( + note!!, + shareType, + shareeName!!, + "false", // TODO: Check how to determine it + password, + permission, + noteText + ) + + if (result.isSuccess()) { + repository.getSharesForNotesAndSaveShareEntities() + } + + handleResult(result.isSuccess()) + } + + private suspend fun handleResult(success: Boolean) { + withContext(Dispatchers.Main) { + if (success) { + val resultIntent = Intent() + setResult(RESULT_OK, resultIntent) + finish() + } else { + DisplayUtils.showSnackMessage( + this@NoteShareDetailActivity, + getString(R.string.note_share_detail_activity_create_share_error) + ) + } + } + } + + override fun onDateSet(year: Int, monthOfYear: Int, dayOfMonth: Int, chosenDateInMillis: Long) { + binding.shareProcessSelectExpDate.text = getString( + R.string.share_expiration_date_format, + SimpleDateFormat.getDateInstance().format(Date(chosenDateInMillis)) + ) + this.chosenExpDateInMills = chosenDateInMillis + } + + override fun onDateUnSet() { + binding.shareProcessSetExpDateSwitch.isChecked = false + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/QuickSharingPermissionsAdapter.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/QuickSharingPermissionsAdapter.kt new file mode 100644 index 000000000..84e335008 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/QuickSharingPermissionsAdapter.kt @@ -0,0 +1,64 @@ +package it.niedermann.owncloud.notes.share.adapter + + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import it.niedermann.owncloud.notes.databinding.ItemQuickSharePermissionsBinding +import it.niedermann.owncloud.notes.share.model.QuickPermissionModel + +class QuickSharingPermissionsAdapter( + private val quickPermissionList: MutableList, + private val onPermissionChangeListener: QuickSharingPermissionViewHolder.OnPermissionChangeListener, +) : + RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding = ItemQuickSharePermissionsBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return QuickSharingPermissionViewHolder(binding, binding.root, onPermissionChangeListener) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is QuickSharingPermissionViewHolder) { + holder.bindData(quickPermissionList[position]) + } + } + + override fun getItemCount(): Int { + return quickPermissionList.size + } + + class QuickSharingPermissionViewHolder( + val binding: ItemQuickSharePermissionsBinding, + itemView: View, + val onPermissionChangeListener: OnPermissionChangeListener, + ) : + RecyclerView + .ViewHolder(itemView) { + + fun bindData(quickPermissionModel: QuickPermissionModel) { + binding.tvQuickShareName.text = quickPermissionModel.permissionName + if (quickPermissionModel.isSelected) { + // viewThemeUtils.platform.colorImageView(binding.tvQuickShareCheckIcon) + binding.tvQuickShareCheckIcon.visibility = View.VISIBLE + } else { + binding.tvQuickShareCheckIcon.visibility = View.INVISIBLE + } + + itemView.setOnClickListener { + // if user select different options then only update the permission + if (!quickPermissionModel.isSelected) { + onPermissionChangeListener.onPermissionChanged(adapterPosition) + } else { + // dismiss sheet on selection of same permission + onPermissionChangeListener.onDismissSheet() + } + } + } + + interface OnPermissionChangeListener { + fun onPermissionChanged(position: Int) + fun onDismissSheet() + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/ShareeListAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/ShareeListAdapter.java new file mode 100644 index 000000000..f234c4a11 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/ShareeListAdapter.java @@ -0,0 +1,231 @@ +package it.niedermann.owncloud.notes.share.adapter; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import it.niedermann.owncloud.notes.databinding.ItemAddPublicShareBinding; +import it.niedermann.owncloud.notes.databinding.ItemInternalShareLinkBinding; +import it.niedermann.owncloud.notes.databinding.ItemShareLinkShareBinding; +import it.niedermann.owncloud.notes.databinding.ItemShareShareBinding; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.share.adapter.holder.InternalShareViewHolder; +import it.niedermann.owncloud.notes.share.adapter.holder.LinkShareViewHolder; +import it.niedermann.owncloud.notes.share.adapter.holder.NewLinkShareViewHolder; +import it.niedermann.owncloud.notes.share.adapter.holder.ShareViewHolder; +import it.niedermann.owncloud.notes.share.listener.ShareeListAdapterListener; + +/** + * Adapter to show a user/group/email/remote in Sharing list in file details view. + */ +public class ShareeListAdapter extends RecyclerView.Adapter { + + private final Account account; + private final ShareeListAdapterListener listener; + private final Activity activity; + private List shares; + + public ShareeListAdapter(Activity activity, + List shares, + ShareeListAdapterListener listener, + Account account) { + this.activity = activity; + this.shares = shares; + this.listener = listener; + this.account = account; + + sortShares(); + setHasStableIds(true); + } + + @Override + public int getItemViewType(int position) { + if (shares == null) { + return 0; + } + + if (position < 0 || position >= shares.size()) { + return 0; + } + + final var share = shares.get(position); + if (share == null) { + return 0; + } + + final var shareType = share.getShareType(); + if (shareType == null) { + return 0; + } + + return shareType.getValue(); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (ShareType.fromValue(viewType)) { + case PUBLIC_LINK, EMAIL -> { + return new LinkShareViewHolder( + ItemShareLinkShareBinding.inflate(LayoutInflater.from(activity), + parent, + false), + activity); + } + case NEW_PUBLIC_LINK -> { + return new NewLinkShareViewHolder( + ItemAddPublicShareBinding.inflate(LayoutInflater.from(activity), + parent, + false) + ); + } + case INTERNAL -> { + return new InternalShareViewHolder( + ItemInternalShareLinkBinding.inflate(LayoutInflater.from(activity), parent, false), + activity); + } + default -> { + return new ShareViewHolder(ItemShareShareBinding.inflate(LayoutInflater.from(activity), + parent, + false), + account, + activity); + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + if (shares == null || shares.size() <= position) { + return; + } + + final OCShare share = shares.get(position); + + + if (holder instanceof LinkShareViewHolder publicShareViewHolder) { + publicShareViewHolder.bind(share, listener); + } else if (holder instanceof InternalShareViewHolder internalShareViewHolder) { + internalShareViewHolder.bind(share, listener); + } else if (holder instanceof NewLinkShareViewHolder newLinkShareViewHolder) { + newLinkShareViewHolder.bind(listener); + } else { + ShareViewHolder userViewHolder = (ShareViewHolder) holder; + userViewHolder.bind(share, listener); + } + } + + @Override + public long getItemId(int position) { + if (position < 0 || position >= shares.size()) { + return 0; + } + + return shares.get(position).getId(); + } + + @Override + public int getItemCount() { + return shares.size(); + } + + @SuppressLint("NotifyDataSetChanged") + public void addShares(List sharesToAdd) { + Set uniqueShares = new LinkedHashSet<>(shares); + + // Automatically removes duplicates + uniqueShares.addAll(sharesToAdd); + + shares.clear(); + shares.addAll(uniqueShares); + + sortShares(); + notifyDataSetChanged(); + } + + @SuppressLint("NotifyDataSetChanged") + public void remove(OCShare share) { + shares.remove(share); + notifyDataSetChanged(); + } + + @SuppressLint("NotifyDataSetChanged") + public void removeAll() { + shares.clear(); + notifyDataSetChanged(); + } + + public void addShare(@NonNull OCShare newShare) { + shares.add(0, newShare); + notifyItemInserted(0); + } + + public void updateShare(@NonNull OCShare updatedShare) { + int indexToUpdate = -1; + for (int i = 0; i < getItemCount(); i++) { + final var share = shares.get(i); + if (share != null && share.getId() == updatedShare.getId()) { + indexToUpdate = i; + break; + } + } + + if (indexToUpdate != -1) { + shares.set(indexToUpdate, updatedShare); + notifyItemChanged(indexToUpdate); + } + } + + /** + * sort all by creation time, then email/link shares on top + */ + protected final void sortShares() { + List links = new ArrayList<>(); + List users = new ArrayList<>(); + + for (OCShare share : shares) { + if (share.getShareType() != null) { + if (ShareType.PUBLIC_LINK == share.getShareType() || ShareType.EMAIL == share.getShareType()) { + links.add(share); + } else if (share.getShareType() != ShareType.INTERNAL) { + users.add(share); + } + } + } + + links.sort((o1, o2) -> Long.compare(o2.getSharedDate(), o1.getSharedDate())); + users.sort((o1, o2) -> Long.compare(o2.getSharedDate(), o1.getSharedDate())); + + shares = links; + shares.addAll(users); + + final OCShare ocShare = new OCShare(); + ocShare.setShareType(ShareType.INTERNAL); + shares.add(ocShare); + } + + public List getShares() { + return shares; + } + + public void removeNewPublicShare() { + for (OCShare share : shares) { + if (share.getShareType() == ShareType.NEW_PUBLIC_LINK) { + shares.remove(share); + break; + } + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/SuggestionAdapter.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/SuggestionAdapter.kt new file mode 100644 index 000000000..f057c7124 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/SuggestionAdapter.kt @@ -0,0 +1,52 @@ +package it.niedermann.owncloud.notes.share.adapter + +import android.app.SearchManager +import android.content.Context +import android.database.Cursor +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.database.getIntOrNull +import androidx.core.database.getStringOrNull +import androidx.cursoradapter.widget.CursorAdapter +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.persistence.entity.Account +import it.niedermann.owncloud.notes.share.helper.AvatarLoader + +class SuggestionAdapter(context: Context, cursor: Cursor?, private val account: Account) : CursorAdapter(context, cursor, false) { + override fun newView(context: Context, cursor: Cursor, parent: ViewGroup): View { + val inflater = LayoutInflater.from(context) + return inflater.inflate(R.layout.item_suggestion_adapter, parent, false) + } + + override fun bindView(view: View, context: Context, cursor: Cursor) { + val suggestion = + cursor.getString(cursor.getColumnIndexOrThrow(SearchManager.SUGGEST_COLUMN_TEXT_1)) + view.findViewById(R.id.suggestion_text).text = suggestion + + + val icon = view.findViewById(R.id.suggestion_icon) + val iconColumn = cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_ICON_1) + + if (iconColumn != -1) { + try { + val iconId = cursor.getIntOrNull(iconColumn) + if (iconId != null) { + icon.setImageDrawable(ContextCompat.getDrawable(context, iconId)) + } + } catch (e: Exception) { + try { + val username = cursor.getStringOrNull(iconColumn) + if (username != null) { + AvatarLoader.load(context, icon, account, username) + } + } catch (e: Exception) { + icon.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_account_circle_grey_24dp)) + } + } + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/InternalShareViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/InternalShareViewHolder.java new file mode 100644 index 000000000..c322022b1 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/InternalShareViewHolder.java @@ -0,0 +1,55 @@ +package it.niedermann.owncloud.notes.share.adapter.holder; + + +import android.content.Context; +import android.graphics.PorterDuff; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.content.res.ResourcesCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.owncloud.android.lib.resources.shares.OCShare; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemInternalShareLinkBinding; +import it.niedermann.owncloud.notes.share.listener.ShareeListAdapterListener; + +public class InternalShareViewHolder extends RecyclerView.ViewHolder { + private ItemInternalShareLinkBinding binding; + private Context context; + + public InternalShareViewHolder(@NonNull View itemView) { + super(itemView); + } + + public InternalShareViewHolder(ItemInternalShareLinkBinding binding, Context context) { + this(binding.getRoot()); + this.binding = binding; + this.context = context; + } + + public void bind(OCShare share, ShareeListAdapterListener listener) { + binding.copyInternalLinkIcon + .getBackground() + .setColorFilter(ResourcesCompat.getColor(context.getResources(), + R.color.widget_foreground, + null), + PorterDuff.Mode.SRC_IN); + binding.copyInternalLinkIcon + .getDrawable() + .mutate() + .setColorFilter(ResourcesCompat.getColor(context.getResources(), + R.color.fg_contrast, + null), + PorterDuff.Mode.SRC_IN); + + if (share.isFolder()) { + binding.shareInternalLinkText.setText(context.getString(R.string.share_internal_link_to_folder_text)); + } else { + binding.shareInternalLinkText.setText(context.getString(R.string.share_internal_link_to_file_text)); + } + + binding.copyInternalContainer.setOnClickListener(l -> listener.copyInternalLink()); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/LinkShareViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/LinkShareViewHolder.java new file mode 100644 index 000000000..0f6bb3864 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/LinkShareViewHolder.java @@ -0,0 +1,92 @@ +package it.niedermann.owncloud.notes.share.adapter.holder; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.text.TextUtils; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.content.res.ResourcesCompat; + +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.branding.BrandedViewHolder; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.databinding.ItemShareLinkShareBinding; +import it.niedermann.owncloud.notes.share.helper.SharingMenuHelper; +import it.niedermann.owncloud.notes.share.listener.ShareeListAdapterListener; + +public class LinkShareViewHolder extends BrandedViewHolder { + private ItemShareLinkShareBinding binding; + private Context context; + + public LinkShareViewHolder(@NonNull View itemView) { + super(itemView); + bindBranding(); + } + + public LinkShareViewHolder(ItemShareLinkShareBinding binding, Context context) { + this(binding.getRoot()); + this.binding = binding; + this.context = context; + bindBranding(); + } + + public void bind(OCShare publicShare, ShareeListAdapterListener listener) { + if (publicShare.getShareType() != null && ShareType.EMAIL == publicShare.getShareType()) { + binding.name.setText(publicShare.getSharedWithDisplayName()); + binding.icon.setImageDrawable(ResourcesCompat.getDrawable(context.getResources(), + R.drawable.ic_email, + null)); + binding.copyLink.setVisibility(View.GONE); + + binding.icon.getBackground().setColorFilter(context.getResources().getColor(R.color.nc_grey), + PorterDuff.Mode.SRC_IN); + binding.icon.getDrawable().mutate().setColorFilter(context.getResources().getColor(R.color.icon_on_nc_grey), + PorterDuff.Mode.SRC_IN); + } else { + if (!TextUtils.isEmpty(publicShare.getLabel())) { + String text = String.format(context.getString(R.string.share_link_with_label), publicShare.getLabel()); + binding.name.setText(text); + } else { + if (SharingMenuHelper.isSecureFileDrop(publicShare)) { + binding.name.setText(context.getResources().getString(R.string.share_permission_secure_file_drop)); + } else { + binding.name.setText(R.string.share_link); + } + } + } + + binding.subline.setVisibility(View.GONE); + + String permissionName = SharingMenuHelper.getPermissionName(context, publicShare); + setPermissionName(publicShare, permissionName); + + binding.overflowMenu.setOnClickListener(v -> listener.showSharingMenuActionSheet(publicShare)); + if (!SharingMenuHelper.isSecureFileDrop(publicShare)) { + binding.shareByLinkContainer.setOnClickListener(v -> listener.showPermissionsDialog(publicShare)); + } + + binding.copyLink.setOnClickListener(v -> listener.copyLink(publicShare)); + } + + private void setPermissionName(OCShare publicShare, String permissionName) { + if (!TextUtils.isEmpty(permissionName) && !SharingMenuHelper.isSecureFileDrop(publicShare)) { + binding.permissionName.setText(permissionName); + binding.permissionName.setVisibility(View.VISIBLE); + } else { + binding.permissionName.setVisibility(View.GONE); + } + } + + @Override + public void applyBrand(int color) { + final var util = BrandingUtil.of(color, context); + if (binding != null) { + util.androidx.colorPrimaryTextViewElement(binding.permissionName); + util.platform.colorImageViewBackgroundAndIcon(binding.icon); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/NewLinkShareViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/NewLinkShareViewHolder.java new file mode 100644 index 000000000..f2e795a0b --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/NewLinkShareViewHolder.java @@ -0,0 +1,26 @@ +package it.niedermann.owncloud.notes.share.adapter.holder; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import it.niedermann.owncloud.notes.databinding.ItemAddPublicShareBinding; +import it.niedermann.owncloud.notes.share.listener.ShareeListAdapterListener; + +public class NewLinkShareViewHolder extends RecyclerView.ViewHolder { + private ItemAddPublicShareBinding binding; + + public NewLinkShareViewHolder(@NonNull View itemView) { + super(itemView); + } + + public NewLinkShareViewHolder(ItemAddPublicShareBinding binding) { + this(binding.getRoot()); + this.binding = binding; + } + + public void bind(ShareeListAdapterListener listener) { + binding.addNewPublicShareLink.setOnClickListener(v -> listener.createPublicShareLink()); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/ShareViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/ShareViewHolder.java new file mode 100644 index 000000000..52c3ff526 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/adapter/holder/ShareViewHolder.java @@ -0,0 +1,118 @@ +package it.niedermann.owncloud.notes.share.adapter.holder; + + +import android.content.Context; +import android.text.TextUtils; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.owncloud.android.lib.resources.shares.OCShare; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.ItemShareShareBinding; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.share.helper.AvatarLoader; +import it.niedermann.owncloud.notes.share.helper.SharingMenuHelper; +import it.niedermann.owncloud.notes.share.listener.ShareeListAdapterListener; +import it.niedermann.owncloud.notes.shared.util.FilesSpecificViewThemeUtils; + +public class ShareViewHolder extends RecyclerView.ViewHolder { + private ItemShareShareBinding binding; + private Account account; + private Context context; + + public ShareViewHolder(@NonNull View itemView) { + super(itemView); + } + + public ShareViewHolder(ItemShareShareBinding binding, + Account account, + Context context) { + this(binding.getRoot()); + this.binding = binding; + this.account = account; + this.context = context; + } + + public void bind(OCShare share, ShareeListAdapterListener listener) { + String accountName = account.getDisplayName(); + String name = share.getSharedWithDisplayName(); + binding.icon.setTag(null); + final var shareType = share.getShareType(); + if (shareType == null) { + return; + } + + final var viewThemeUtils = FilesSpecificViewThemeUtils.INSTANCE; + + switch (shareType) { + case GROUP: + name = context.getString(R.string.share_group_clarification, name); + viewThemeUtils.createAvatar(share.getShareType(), binding.icon, context); + break; + case ROOM: + name = context.getString(R.string.share_room_clarification, name); + viewThemeUtils.createAvatar(share.getShareType(), binding.icon, context); + break; + case CIRCLE: + viewThemeUtils.createAvatar(share.getShareType(), binding.icon, context); + break; + case FEDERATED: + name = context.getString(R.string.share_remote_clarification, name); + setImage(binding.icon, share.getSharedWithDisplayName(), R.drawable.ic_account_circle_grey_24dp); + break; + case USER: + binding.icon.setTag(share.getShareWith()); + + if (share.getSharedWithDisplayName() != null) { + AvatarLoader.INSTANCE.load(context, binding.icon, account, share.getSharedWithDisplayName()); + } + + // binding.icon.setOnClickListener(v -> listener.showProfileBottomSheet(user, share.getShareWith())); + default: + setImage(binding.icon, name, R.drawable.ic_account_circle_grey_24dp); + break; + } + + binding.name.setText(name); + + if (accountName == null) { + binding.overflowMenu.setVisibility(View.GONE); + return; + } + + if (accountName.equalsIgnoreCase(share.getShareWith()) || accountName.equalsIgnoreCase(share.getUserId())) { + binding.overflowMenu.setVisibility(View.VISIBLE); + + String permissionName = SharingMenuHelper.getPermissionName(context, share); + setPermissionName(permissionName); + + // bind listener to edit privileges + binding.overflowMenu.setOnClickListener(v -> listener.showSharingMenuActionSheet(share)); + binding.shareNameLayout.setOnClickListener(v -> listener.showPermissionsDialog(share)); + } else { + binding.overflowMenu.setVisibility(View.GONE); + } + } + + private void setPermissionName(String permissionName) { + if (!TextUtils.isEmpty(permissionName)) { + binding.permissionName.setText(permissionName); + binding.permissionName.setVisibility(View.VISIBLE); + } else { + binding.permissionName.setVisibility(View.GONE); + } + } + + private void setImage(ImageView avatar, String name, @DrawableRes int fallback) { + try { + AvatarLoader.INSTANCE.load(context, avatar, account, name); + } catch (StringIndexOutOfBoundsException e) { + avatar.setImageResource(fallback); + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ExpirationDatePickerDialogFragment.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ExpirationDatePickerDialogFragment.kt new file mode 100644 index 000000000..54d74dfd1 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ExpirationDatePickerDialogFragment.kt @@ -0,0 +1,167 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2022 Álvaro Brey + * SPDX-FileCopyrightText: 2021 2018 TSI-mc + * SPDX-FileCopyrightText: 2018 Tobias Kaminsky + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-FileCopyrightText: 2015 David A. Velasco + * SPDX-FileCopyrightText: 2015 ownCloud Inc. + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package it.niedermann.owncloud.notes.share.dialog + +import android.app.DatePickerDialog +import android.app.DatePickerDialog.OnDateSetListener +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.format.DateUtils +import android.widget.DatePicker +import com.google.android.material.button.MaterialButton +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.branding.BrandedDialogFragment +import it.niedermann.owncloud.notes.branding.BrandingUtil +import java.util.Calendar + +/** + * Dialog requesting a date after today. + */ +class ExpirationDatePickerDialogFragment : BrandedDialogFragment(), OnDateSetListener { + + + private var onExpiryDateListener: OnExpiryDateListener? = null + + fun setOnExpiryDateListener(onExpiryDateListener: OnExpiryDateListener?) { + this.onExpiryDateListener = onExpiryDateListener + } + + /** + * {@inheritDoc} + * + * @return A new dialog to let the user choose an expiration date that will be bound to a share link. + */ + @Suppress("DEPRECATION", "MagicNumber") + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + // Chosen date received as an argument must be later than tomorrow ; default to tomorrow in other case + val chosenDate = Calendar.getInstance() + val tomorrowInMillis = chosenDate.timeInMillis + DateUtils.DAY_IN_MILLIS + val chosenDateInMillis = requireArguments().getLong(ARG_CHOSEN_DATE_IN_MILLIS) + chosenDate.timeInMillis = chosenDateInMillis.coerceAtLeast(tomorrowInMillis) + + // Create a new instance of DatePickerDialog + val dialog = DatePickerDialog( + requireActivity(), + R.style.FallbackDatePickerDialogTheme, + this, + chosenDate[Calendar.YEAR], + chosenDate[Calendar.MONTH], + chosenDate[Calendar.DAY_OF_MONTH] + ) + + // show unset button only when date is already selected + if (chosenDateInMillis > 0) { + dialog.setButton( + Dialog.BUTTON_NEGATIVE, + getText(R.string.share_via_link_unset_password) + ) { _: DialogInterface?, _: Int -> + onExpiryDateListener?.onDateUnSet() + } + } + + // Prevent days in the past may be chosen + val picker = dialog.datePicker + picker.minDate = tomorrowInMillis - 1000 + + // Enforce spinners view; ignored by MD-based theme in Android >=5, but calendar is REALLY buggy + // in Android < 5, so let's be sure it never appears (in tablets both spinners and calendar are + // shown by default) + @Suppress("DEPRECATION") + picker.calendarViewShown = false + return dialog + } + + override fun applyBrand(color: Int) { + val util = BrandingUtil.of(color, requireContext()) + val currentDialog = dialog + + if (currentDialog != null) { + val dialog = currentDialog as DatePickerDialog + + val positiveButton = dialog.getButton(DatePickerDialog.BUTTON_POSITIVE) as MaterialButton? + if (positiveButton != null) { + util?.material?.colorMaterialButtonPrimaryTonal(positiveButton) + } + val negativeButton = dialog.getButton(DatePickerDialog.BUTTON_NEGATIVE) as MaterialButton? + if (negativeButton != null) { + util?.material?.colorMaterialButtonPrimaryBorderless(negativeButton) + } + val neutralButton = dialog.getButton(DatePickerDialog.BUTTON_NEUTRAL) as MaterialButton? + if (neutralButton != null) { + util?.material?.colorMaterialButtonPrimaryBorderless(neutralButton) + } + } + } + + val currentSelectionMillis: Long + get() { + val dialog = dialog + if (dialog != null) { + val datePickerDialog = dialog as DatePickerDialog + val picker = datePickerDialog.datePicker + return yearMonthDayToMillis(picker.year, picker.month, picker.dayOfMonth) + } + return 0 + } + + /** + * Called when the user chooses an expiration date. + * + * @param view View instance where the date was chosen + * @param year Year of the date chosen. + * @param monthOfYear Month of the date chosen [0, 11] + * @param dayOfMonth Day of the date chosen + */ + override fun onDateSet(view: DatePicker, year: Int, monthOfYear: Int, dayOfMonth: Int) { + val chosenDateInMillis = yearMonthDayToMillis(year, monthOfYear, dayOfMonth) + if (onExpiryDateListener != null) { + onExpiryDateListener?.onDateSet(year, monthOfYear, dayOfMonth, chosenDateInMillis) + } + } + + private fun yearMonthDayToMillis(year: Int, monthOfYear: Int, dayOfMonth: Int): Long { + val date = Calendar.getInstance() + date[Calendar.YEAR] = year + date[Calendar.MONTH] = monthOfYear + date[Calendar.DAY_OF_MONTH] = dayOfMonth + return date.timeInMillis + } + + interface OnExpiryDateListener { + fun onDateSet(year: Int, monthOfYear: Int, dayOfMonth: Int, chosenDateInMillis: Long) + fun onDateUnSet() + } + + companion object { + /** Tag for FragmentsManager */ + const val DATE_PICKER_DIALOG = "DATE_PICKER_DIALOG" + + /** Parameter constant for date chosen initially */ + private const val ARG_CHOSEN_DATE_IN_MILLIS = "CHOSEN_DATE_IN_MILLIS" + + /** + * Factory method to create new instances + * + * @param chosenDateInMillis Date chosen when the dialog appears + * @return New dialog instance + */ + fun newInstance(chosenDateInMillis: Long): ExpirationDatePickerDialogFragment { + val arguments = Bundle() + arguments.putLong(ARG_CHOSEN_DATE_IN_MILLIS, chosenDateInMillis) + val dialog = ExpirationDatePickerDialogFragment() + dialog.arguments = arguments + return dialog + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/NoteShareActivityShareItemActionBottomSheetDialog.java b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/NoteShareActivityShareItemActionBottomSheetDialog.java new file mode 100644 index 000000000..82340bd4a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/NoteShareActivityShareItemActionBottomSheetDialog.java @@ -0,0 +1,111 @@ +package it.niedermann.owncloud.notes.share.dialog; + + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; + +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.nextcloud.android.common.ui.theme.utils.ColorRole; +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; + +import it.niedermann.owncloud.notes.branding.BrandedBottomSheetDialog; +import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.databinding.ItemNoteShareActionBinding; +import it.niedermann.owncloud.notes.share.helper.SharingMenuHelper; +import it.niedermann.owncloud.notes.share.listener.NoteShareItemAction; + +public class NoteShareActivityShareItemActionBottomSheetDialog extends BrandedBottomSheetDialog { + private ItemNoteShareActionBinding binding; + private final NoteShareItemAction actions; + private final OCShare ocShare; + public NoteShareActivityShareItemActionBottomSheetDialog(Activity activity, + NoteShareItemAction actions, + OCShare ocShare) { + super(activity); + this.actions = actions; + this.ocShare = ocShare; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ItemNoteShareActionBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + if (getWindow() != null) { + getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + + updateUI(); + + setupClickListener(); + + setOnShowListener(d -> + BottomSheetBehavior.from((View) binding.getRoot().getParent()) + .setPeekHeight(binding.getRoot().getMeasuredHeight()) + ); + } + + private void updateUI() { + if (ocShare.getShareType() != null && ocShare.getShareType() == ShareType.PUBLIC_LINK) { + binding.menuShareAddAnotherLink.setVisibility(View.VISIBLE); + binding.menuShareSendLink.setVisibility(View.VISIBLE); + } else { + binding.menuShareAddAnotherLink.setVisibility(View.GONE); + binding.menuShareSendLink.setVisibility(View.GONE); + } + + if (SharingMenuHelper.isSecureFileDrop(ocShare)) { + binding.menuShareAdvancedPermissions.setVisibility(View.GONE); + binding.menuShareAddAnotherLink.setVisibility(View.GONE); + } + } + + private void setupClickListener() { + binding.menuShareAdvancedPermissions.setOnClickListener(v -> { + actions.advancedPermissions(ocShare); + dismiss(); + }); + + binding.menuShareSendNewEmail.setOnClickListener(v -> { + actions.sendNewEmail(ocShare); + dismiss(); + }); + + binding.menuShareUnshare.setOnClickListener(v -> { + actions.unShare(ocShare); + dismiss(); + }); + + binding.menuShareSendLink.setOnClickListener(v -> { + actions.sendLink(ocShare); + dismiss(); + }); + + binding.menuShareAddAnotherLink.setOnClickListener(v -> { + actions.addAnotherLink(ocShare); + dismiss(); + }); + } + + @Override + protected void onStop() { + super.onStop(); + binding = null; + } + + @Override + public void applyBrand(int color) { + final var util = BrandingUtil.of(color, getContext()); + util.platform.themeDialog(binding.getRoot()); + + util.platform.colorImageView(binding.menuIconAddAnotherLink, ColorRole.PRIMARY); + util.platform.colorImageView(binding.menuIconAdvancedPermissions, ColorRole.PRIMARY); + util.platform.colorImageView(binding.menuIconSendLink, ColorRole.PRIMARY); + util.platform.colorImageView(binding.menuIconUnshare, ColorRole.PRIMARY); + util.platform.colorImageView(binding.menuIconSendNewEmail, ColorRole.PRIMARY); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ProfileBottomSheetDialog.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ProfileBottomSheetDialog.kt new file mode 100644 index 000000000..eb80e83e7 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ProfileBottomSheetDialog.kt @@ -0,0 +1,144 @@ +package it.niedermann.owncloud.notes.share.dialog + + +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.FragmentActivity +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.nextcloud.android.lib.resources.profile.Action +import com.nextcloud.android.lib.resources.profile.HoverCard +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.databinding.ProfileBottomSheetActionBinding +import it.niedermann.owncloud.notes.databinding.ProfileBottomSheetFragmentBinding +import it.niedermann.owncloud.notes.persistence.entity.Account +import it.niedermann.owncloud.notes.share.helper.AvatarLoader +import it.niedermann.owncloud.notes.shared.util.DisplayUtils + +/** + * Show actions of an user + */ +class ProfileBottomSheetDialog( + private val fileActivity: FragmentActivity, + private val account: Account, + private val hoverCard: HoverCard, +) : BottomSheetDialog(fileActivity) { + private var _binding: ProfileBottomSheetFragmentBinding? = null + + // This property is only valid between onCreateView and onDestroyView. + val binding get() = _binding!! + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + _binding = ProfileBottomSheetFragmentBinding.inflate(layoutInflater) + setContentView(binding.root) + if (window != null) { + window!!.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + // viewThemeUtils.platform.themeDialog(binding.root) + + binding.icon.tag = hoverCard.userId + + + /* + // TODO: How to get owner display name from note? + + binding.sharedWithYouUsername.setText( + String.format(getString(R.string.note_share_fragment_shared_with_you), file.getOwnerDisplayName())); + */ + AvatarLoader.load(fileActivity, binding.icon, account) + + binding.displayName.text = hoverCard.displayName + + for (action in hoverCard.actions) { + val actionBinding = ProfileBottomSheetActionBinding.inflate( + layoutInflater + ) + val creatorView: View = actionBinding.root + + if (action.appId == "email") { + action.hyperlink = action.title + action.title = context.resources.getString(R.string.write_email) + } + + actionBinding.name.text = action.title + + val icon = when (action.appId) { + "profile" -> R.drawable.ic_account_circle_grey_24dp + "email" -> R.drawable.ic_email + "spreed" -> R.drawable.ic_talk + else -> R.drawable.ic_edit + } + actionBinding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + context.resources, + icon, + null + ) + ) + // viewThemeUtils.platform.tintPrimaryDrawable(context, actionBinding.icon.drawable) + + creatorView.setOnClickListener { v: View? -> + send(hoverCard.userId, action) + dismiss() + } + binding.creators.addView(creatorView) + } + + setOnShowListener { d: DialogInterface? -> + BottomSheetBehavior.from(binding.root.parent as View) + .setPeekHeight(binding.root.measuredHeight) + } + } + + private fun send(userId: String, action: Action) { + when (action.appId) { + "profile" -> openWebsite(action.hyperlink) + "core" -> sendEmail(action.hyperlink) + "spreed" -> openTalk(userId, action.hyperlink) + } + } + + private fun openWebsite(url: String) { + DisplayUtils.startLinkIntent(fileActivity, url) + } + + private fun sendEmail(email: String) { + val intent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(email)) + } + + DisplayUtils.startIntentIfAppAvailable(intent, fileActivity, R.string.no_email_app_available) + } + + private fun openTalk(userId: String, hyperlink: String) { + // TODO: + /* + try { + val sharingIntent = Intent(Intent.ACTION_VIEW) + sharingIntent.setClassName( + "com.nextcloud.talk2", + "com.nextcloud.talk.activities.MainActivity" + ) + sharingIntent.putExtra("server", user.server.uri) + sharingIntent.putExtra("userId", userId) + fileActivity.startActivity(sharingIntent) + } catch (e: ActivityNotFoundException) { + openWebsite(hyperlink) + } + */ + + } + + override fun onStop() { + super.onStop() + _binding = null + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/QuickSharingPermissionsBottomSheetDialog.java b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/QuickSharingPermissionsBottomSheetDialog.java new file mode 100644 index 000000000..f250e0523 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/QuickSharingPermissionsBottomSheetDialog.java @@ -0,0 +1,146 @@ +package it.niedermann.owncloud.notes.share.dialog; + + +import static com.owncloud.android.lib.resources.shares.OCShare.CREATE_PERMISSION_FLAG; +import static com.owncloud.android.lib.resources.shares.OCShare.MAXIMUM_PERMISSIONS_FOR_FILE; +import static com.owncloud.android.lib.resources.shares.OCShare.MAXIMUM_PERMISSIONS_FOR_FOLDER; +import static com.owncloud.android.lib.resources.shares.OCShare.READ_PERMISSION_FLAG; + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.LinearLayoutManager; + +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.owncloud.android.lib.resources.shares.OCShare; + +import java.util.ArrayList; +import java.util.List; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.databinding.QuickSharingPermissionsBottomSheetFragmentBinding; +import it.niedermann.owncloud.notes.share.adapter.QuickSharingPermissionsAdapter; +import it.niedermann.owncloud.notes.share.helper.SharingMenuHelper; +import it.niedermann.owncloud.notes.share.model.QuickPermissionModel; + +/** + * File Details Quick Sharing permissions options {@link android.app.Dialog} styled as a bottom sheet for main actions. + */ +public class QuickSharingPermissionsBottomSheetDialog extends BottomSheetDialog { + private QuickSharingPermissionsBottomSheetFragmentBinding binding; + private final QuickPermissionSharingBottomSheetActions actions; + private final Activity activity; + private final OCShare ocShare; + + public QuickSharingPermissionsBottomSheetDialog(Activity activity, + QuickPermissionSharingBottomSheetActions actions, + OCShare ocShare) { + super(activity); + this.actions = actions; + this.ocShare = ocShare; + this.activity = activity; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = QuickSharingPermissionsBottomSheetFragmentBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + if (getWindow() != null) { + getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + + // viewThemeUtils.platform.themeDialog(binding.getRoot()); + + setUpRecyclerView(); + setOnShowListener(d -> + BottomSheetBehavior.from((View) binding.getRoot().getParent()) + .setPeekHeight(binding.getRoot().getMeasuredHeight()) + ); + } + + private void setUpRecyclerView() { + List quickPermissionModelList = getQuickPermissionList(); + QuickSharingPermissionsAdapter adapter = new QuickSharingPermissionsAdapter( + quickPermissionModelList, + new QuickSharingPermissionsAdapter.QuickSharingPermissionViewHolder.OnPermissionChangeListener() { + @Override + public void onPermissionChanged(int position) { + handlePermissionChanged(quickPermissionModelList, position); + } + + @Override + public void onDismissSheet() { + dismiss(); + } + } + ); + LinearLayoutManager linearLayoutManager = new LinearLayoutManager(activity); + binding.rvQuickSharePermissions.setLayoutManager(linearLayoutManager); + binding.rvQuickSharePermissions.setAdapter(adapter); + } + + private void handlePermissionChanged(List quickPermissionModelList, int position) { + if (quickPermissionModelList.get(position).getPermissionName().equalsIgnoreCase(activity.getResources().getString(R.string.link_share_allow_upload_and_editing)) + || quickPermissionModelList.get(position).getPermissionName().equalsIgnoreCase(activity.getResources().getString(R.string.link_share_editing))) { + if (ocShare.isFolder()) { + actions.onQuickPermissionChanged(ocShare, + MAXIMUM_PERMISSIONS_FOR_FOLDER); + } else { + actions.onQuickPermissionChanged(ocShare, + MAXIMUM_PERMISSIONS_FOR_FILE); + } + } else if (quickPermissionModelList.get(position).getPermissionName().equalsIgnoreCase(activity.getResources().getString(R.string + .link_share_view_only))) { + actions.onQuickPermissionChanged(ocShare, + READ_PERMISSION_FLAG); + + } else if (quickPermissionModelList.get(position).getPermissionName().equalsIgnoreCase(activity.getResources().getString(R.string + .link_share_file_drop))) { + actions.onQuickPermissionChanged(ocShare, + CREATE_PERMISSION_FLAG); + } + dismiss(); + } + + /** + * prepare the list of permissions needs to be displayed on recyclerview + * @return + */ + private List getQuickPermissionList() { + + String[] permissionArray; + if (ocShare.isFolder()) { + permissionArray = + activity.getResources().getStringArray(R.array.quick_sharing_permission_bottom_sheet_dialog_folder_share_values); + } else { + permissionArray = + activity.getResources().getStringArray(R.array.quick_sharing_permission_bottom_sheet_dialog_note_share_values); + } + //get the checked item position + int checkedItem = SharingMenuHelper.getPermissionCheckedItem(activity, ocShare, permissionArray); + + + final List quickPermissionModelList = new ArrayList<>(permissionArray.length); + for (int i = 0; i < permissionArray.length; i++) { + QuickPermissionModel quickPermissionModel = new QuickPermissionModel(permissionArray[i], checkedItem == i); + quickPermissionModelList.add(quickPermissionModel); + } + return quickPermissionModelList; + } + + + @Override + protected void onStop() { + super.onStop(); + binding = null; + } + + public interface QuickPermissionSharingBottomSheetActions { + void onQuickPermissionChanged(OCShare share, int permission); + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ShareLinkDialog.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ShareLinkDialog.kt new file mode 100644 index 000000000..08faf11e9 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ShareLinkDialog.kt @@ -0,0 +1,141 @@ +package it.niedermann.owncloud.notes.share.dialog + + +import android.app.Dialog +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.owncloud.android.lib.common.utils.Log_OC +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.shared.util.clipboard.CopyToClipboardActivity +import it.niedermann.owncloud.notes.shared.util.extensions.getParcelableArgument +import java.util.Collections + +/** + * Dialog showing a list activities able to resolve a given Intent, + * filtering out the activities matching give package names. + */ +class ShareLinkToDialog : DialogFragment() { + private var mAdapter: ActivityAdapter? = null + private var mIntent: Intent? = null + + init { + Log_OC.d(TAG, "constructor") + } + + @Suppress("SpreadOperator") + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + mIntent = arguments.getParcelableArgument(ARG_INTENT, Intent::class.java) ?: throw NullPointerException() + val packagesToExclude = arguments?.getStringArray(ARG_PACKAGES_TO_EXCLUDE) + val packagesToExcludeList = listOf(*packagesToExclude ?: arrayOfNulls(0)) + val pm = activity?.packageManager ?: throw NullPointerException() + + val activities = pm.queryIntentActivities(mIntent!!, PackageManager.MATCH_DEFAULT_ONLY) + val it = activities.iterator() + var resolveInfo: ResolveInfo + while (it.hasNext()) { + resolveInfo = it.next() + if (packagesToExcludeList.contains(resolveInfo.activityInfo.packageName.lowercase())) { + it.remove() + } + } + + val sendAction = mIntent?.getBooleanExtra(Intent.ACTION_SEND, false) + + if (sendAction == false) { + // add activity for copy to clipboard + val copyToClipboardIntent = Intent(requireActivity(), CopyToClipboardActivity::class.java) + val copyToClipboard = pm.queryIntentActivities(copyToClipboardIntent, 0) + if (copyToClipboard.isNotEmpty()) { + activities.add(copyToClipboard[0]) + } + } + + Collections.sort(activities, ResolveInfo.DisplayNameComparator(pm)) + mAdapter = ActivityAdapter(requireActivity(), pm, activities) + + return createSelector(sendAction ?: false) + } + + private fun createSelector(sendAction: Boolean): AlertDialog { + val titleId = if (sendAction) { + R.string.activity_chooser_send_file_title + } else { + R.string.activity_chooser_title + } + + return MaterialAlertDialogBuilder(requireActivity()) + .setTitle(titleId) + .setAdapter(mAdapter) { _, which -> + // Add the information of the chosen activity to the intent to send + val chosen = mAdapter?.getItem(which) + val actInfo = chosen?.activityInfo ?: return@setAdapter + val name = ComponentName( + actInfo.applicationInfo.packageName, + actInfo.name + ) + mIntent?.setComponent(name) + activity?.startActivity(mIntent) + } + .create() + } + + internal inner class ActivityAdapter( + context: Context, + private val mPackageManager: PackageManager, + apps: List + ) : ArrayAdapter(context, R.layout.activity_row, apps) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: newView(parent) + bindView(position, view) + return view + } + + private fun newView(parent: ViewGroup): View { + return (context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater) + .inflate(R.layout.activity_row, parent, false) + } + + private fun bindView(position: Int, row: View) { + row.findViewById(R.id.title).run { + text = getItem(position)?.loadLabel(mPackageManager) + } + + row.findViewById(R.id.icon).run { + setImageDrawable(getItem(position)?.loadIcon(mPackageManager)) + } + } + } + + companion object { + private val TAG: String = ShareLinkToDialog::class.java.simpleName + private val ARG_INTENT = ShareLinkToDialog::class.java.simpleName + + ".ARG_INTENT" + private val ARG_PACKAGES_TO_EXCLUDE = ShareLinkToDialog::class.java.simpleName + + ".ARG_PACKAGES_TO_EXCLUDE" + + @JvmStatic + fun newInstance(intent: Intent?, vararg packagesToExclude: String?): ShareLinkToDialog { + val bundle = Bundle().apply { + putParcelable(ARG_INTENT, intent) + putStringArray(ARG_PACKAGES_TO_EXCLUDE, packagesToExclude) + } + + return ShareLinkToDialog().apply { + arguments = bundle + } + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/SharePasswordDialogFragment.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/SharePasswordDialogFragment.kt new file mode 100644 index 000000000..4a1646698 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/dialog/SharePasswordDialogFragment.kt @@ -0,0 +1,247 @@ +package it.niedermann.owncloud.notes.share.dialog + + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.TextUtils +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.owncloud.android.lib.resources.shares.OCShare +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.branding.BrandedDialogFragment +import it.niedermann.owncloud.notes.branding.BrandedSnackbar +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.PasswordDialogBinding +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.shared.util.KeyboardUtils +import it.niedermann.owncloud.notes.shared.util.extensions.getParcelableArgument +import it.niedermann.owncloud.notes.shared.util.extensions.getSerializableArgument + +/** + * Dialog to input the password for sharing a file/folder. + * + * + * Triggers the share when the password is introduced. + */ +class SharePasswordDialogFragment : BrandedDialogFragment() { + + private var keyboardUtils: KeyboardUtils? = null + + private var binding: PasswordDialogBinding? = null + private var note: Note? = null + private var share: OCShare? = null + private var createShare = false + private var askForPassword = false + private var builder: MaterialAlertDialogBuilder? = null + private var listener: SharePasswordDialogListener? = null + + interface SharePasswordDialogListener { + fun shareFileViaPublicShare(note: Note?, password: String?) + fun setPasswordToShare(share: OCShare, password: String?) + } + + override fun onStart() { + super.onStart() + + val alertDialog = dialog as AlertDialog? + if (alertDialog == null) { + return + } + + val positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) as MaterialButton? + positiveButton?.setOnClickListener { + val sharePassword = binding?.sharePassword?.text + + if (sharePassword != null) { + val password = sharePassword.toString() + if (!askForPassword && TextUtils.isEmpty(password)) { + BrandedSnackbar.make( + binding!!.root, + getString(R.string.note_share_detail_activity_share_link_empty_password), + Snackbar.LENGTH_LONG + ) + .show() + return@setOnClickListener + } + if (share == null) { + listener?.shareFileViaPublicShare(note, password) + } else { + listener?.setPasswordToShare(share!!, password) + } + } + + alertDialog.dismiss() + } + } + + override fun onResume() { + super.onResume() + keyboardUtils?.showKeyboardForEditText(binding!!.sharePassword) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + note = requireArguments().getSerializableArgument(ARG_FILE, Note::class.java) + share = requireArguments().getParcelableArgument(ARG_SHARE, OCShare::class.java) + + createShare = requireArguments().getBoolean(ARG_CREATE_SHARE, false) + askForPassword = requireArguments().getBoolean(ARG_ASK_FOR_PASSWORD, false) + + // Inflate the layout for the dialog + val inflater = requireActivity().layoutInflater + binding = PasswordDialogBinding.inflate(inflater, null, false) + + // Setup layout + binding?.sharePassword?.setText(R.string.empty) + + val neutralButtonTextId: Int + val title: Int + if (askForPassword) { + title = R.string.share_password_dialog_fragment_share_link_optional_password_title + neutralButtonTextId = R.string.share_password_dialog_fragment_skip + } else { + title = R.string.share_link_password_title + neutralButtonTextId = R.string.note_share_detail_activity_cancel + } + + // Build the dialog + builder = MaterialAlertDialogBuilder(requireContext()) + builder!!.setView(binding!!.root) + .setPositiveButton(R.string.common_ok, null) + .setNegativeButton(R.string.common_delete) { _: DialogInterface?, _: Int -> callSetPassword() } + .setNeutralButton(neutralButtonTextId) { _: DialogInterface?, _: Int -> + if (askForPassword) { + callSetPassword() + } + } + .setTitle(title) + + return builder!!.create() + } + + private fun callSetPassword() { + val password = binding?.sharePassword?.text.toString().trim() + if (share == null) { + listener?.shareFileViaPublicShare(note, password) + } else { + listener?.setPasswordToShare(share!!, password) + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun applyBrand(color: Int) { + val util = BrandingUtil.of(color, requireContext()) + builder?.let { + util?.dialog?.colorMaterialAlertDialogBackground(requireContext(), it) + } + + val alertDialog = dialog as AlertDialog? + val positiveButton = alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE) as? MaterialButton? + if (positiveButton != null) { + util?.material?.colorMaterialButtonPrimaryTonal(positiveButton) + } + + val negativeButton = alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE) as? MaterialButton? + if (negativeButton != null) { + util?.material?.colorMaterialButtonPrimaryBorderless(negativeButton) + } + + val neutralButton = alertDialog?.getButton(AlertDialog.BUTTON_NEUTRAL) as? MaterialButton? + if (neutralButton != null) { + val warningColorId = + ContextCompat.getColor(requireContext(), R.color.highlight_textColor_Warning) + util?.platform?.colorTextButtons(warningColorId, neutralButton) + } + + binding?.sharePasswordContainer?.let { + util?.material?.colorTextInputLayout(it) + } + } + + companion object { + private const val ARG_FILE = "FILE" + private const val ARG_SHARE = "SHARE" + private const val ARG_CREATE_SHARE = "CREATE_SHARE" + private const val ARG_ASK_FOR_PASSWORD = "ASK_FOR_PASSWORD" + const val PASSWORD_FRAGMENT = "PASSWORD_FRAGMENT" + + /** + * Public factory method to create new SharePasswordDialogFragment instances. + * + * @param note Note bound to the public share that which + * password will be set or updated + * @param createShare When 'true', the request for password will be + * followed by the creation of a new public link + * when 'false', a public share is assumed to exist, and the password is bound to it. + * @return Dialog ready to show. + */ + @JvmStatic + fun newInstance( + note: Note?, + createShare: Boolean, + askForPassword: Boolean, + dialogListener: SharePasswordDialogListener + ): SharePasswordDialogFragment { + val bundle = Bundle().apply { + putSerializable(ARG_FILE, note) + putBoolean(ARG_CREATE_SHARE, createShare) + putBoolean(ARG_ASK_FOR_PASSWORD, askForPassword) + } + + return SharePasswordDialogFragment().apply { + listener = dialogListener + arguments = bundle + } + } + + /** + * Public factory method to create new SharePasswordDialogFragment instances. + * + * @param share OCFile bound to the public share that which password will be set or updated + * @return Dialog ready to show. + */ + @JvmStatic + fun newInstance( + share: OCShare?, + askForPassword: Boolean, + dialogListener: SharePasswordDialogListener + ): SharePasswordDialogFragment { + val bundle = Bundle().apply { + putParcelable(ARG_SHARE, share) + putBoolean(ARG_ASK_FOR_PASSWORD, askForPassword) + } + + return SharePasswordDialogFragment().apply { + listener = dialogListener + arguments = bundle + } + } + + /** + * Public factory method to create new SharePasswordDialogFragment instances. + * + * @param share OCFile bound to the public share that which password will be set or updated + * @return Dialog ready to show. + */ + fun newInstance( + share: OCShare?, + dialogListener: SharePasswordDialogListener + ): SharePasswordDialogFragment { + val bundle = Bundle().apply { + putParcelable(ARG_SHARE, share) + } + + return SharePasswordDialogFragment().apply { + listener = dialogListener + arguments = bundle + } + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/helper/AvatarLoader.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/helper/AvatarLoader.kt new file mode 100644 index 000000000..3270b4056 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/helper/AvatarLoader.kt @@ -0,0 +1,34 @@ +package it.niedermann.owncloud.notes.share.helper + +import android.content.Context +import android.net.Uri +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.persistence.entity.Account + +object AvatarLoader { + + fun load(context: Context, imageView: ImageView, account: Account) { + load(context, imageView, SingleSignOnUrl(account.accountName, account.avatarUrl)) + } + + fun load(context: Context, imageView: ImageView, account: Account, username: String) { + load(context, imageView, SingleSignOnUrl(account.accountName, getUrl(account.url, username))) + } + + fun load(context: Context, imageView: ImageView, ssoURL: Any) { + Glide + .with(context) + .load(ssoURL) + .placeholder(R.drawable.ic_account_circle_grey_24dp) + .error(R.drawable.ic_account_circle_grey_24dp) + .apply(RequestOptions.circleCropTransform()) + .into(imageView) + } + + private fun getUrl(url: String, username: String): String = + url + "/index.php/avatar/" + Uri.encode(username) + "/64" +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/helper/SharingMenuHelper.java b/app/src/main/java/it/niedermann/owncloud/notes/share/helper/SharingMenuHelper.java new file mode 100644 index 000000000..f9723a9fd --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/helper/SharingMenuHelper.java @@ -0,0 +1,104 @@ +package it.niedermann.owncloud.notes.share.helper; + + +import static com.owncloud.android.lib.resources.shares.OCShare.CREATE_PERMISSION_FLAG; +import static com.owncloud.android.lib.resources.shares.OCShare.MAXIMUM_PERMISSIONS_FOR_FILE; +import static com.owncloud.android.lib.resources.shares.OCShare.MAXIMUM_PERMISSIONS_FOR_FOLDER; +import static com.owncloud.android.lib.resources.shares.OCShare.NO_PERMISSION; +import static com.owncloud.android.lib.resources.shares.OCShare.READ_PERMISSION_FLAG; +import static com.owncloud.android.lib.resources.shares.OCShare.SHARE_PERMISSION_FLAG; + +import android.content.Context; + +import com.owncloud.android.lib.resources.shares.OCShare; + +import it.niedermann.owncloud.notes.R; + +/** + * Helper calls for visibility logic of the sharing menu. + */ +public final class SharingMenuHelper { + + private SharingMenuHelper() { + // utility class -> private constructor + } + + public static boolean isUploadAndEditingAllowed(OCShare share) { + if (share.getPermissions() == NO_PERMISSION) { + return false; + } + + return (share.getPermissions() & (share.isFolder() ? MAXIMUM_PERMISSIONS_FOR_FOLDER : + MAXIMUM_PERMISSIONS_FOR_FILE)) == (share.isFolder() ? MAXIMUM_PERMISSIONS_FOR_FOLDER : + MAXIMUM_PERMISSIONS_FOR_FILE); + } + + public static boolean isReadOnly(OCShare share) { + if (share.getPermissions() == NO_PERMISSION) { + return false; + } + + return (share.getPermissions() & ~SHARE_PERMISSION_FLAG) == READ_PERMISSION_FLAG; + } + + public static boolean isFileDrop(OCShare share) { + if (share.getPermissions() == NO_PERMISSION) { + return false; + } + + return (share.getPermissions() & ~SHARE_PERMISSION_FLAG) == CREATE_PERMISSION_FLAG; + } + + public static boolean isSecureFileDrop(OCShare share) { + if (share.getPermissions() == NO_PERMISSION) { + return false; + } + + return (share.getPermissions() & ~SHARE_PERMISSION_FLAG) == CREATE_PERMISSION_FLAG + READ_PERMISSION_FLAG; + } + + public static String getPermissionName(Context context, OCShare share) { + if (SharingMenuHelper.isUploadAndEditingAllowed(share)) { + return context.getResources().getString(R.string.share_permission_can_edit); + } else if (SharingMenuHelper.isReadOnly(share)) { + return context.getResources().getString(R.string.share_permission_view_only); + } else if (SharingMenuHelper.isSecureFileDrop(share)) { + return context.getResources().getString(R.string.share_permission_secure_file_drop); + } else if (SharingMenuHelper.isFileDrop(share)) { + return context.getResources().getString(R.string.share_permission_file_drop); + } + return null; + } + + /** + * method to get the current checked index from the list of permissions + * + */ + public static int getPermissionCheckedItem(Context context, OCShare share, String[] permissionArray) { + if (SharingMenuHelper.isUploadAndEditingAllowed(share)) { + if (share.isFolder()) { + return getPermissionIndexFromArray(context, permissionArray, R.string.link_share_allow_upload_and_editing); + } else { + return getPermissionIndexFromArray(context, permissionArray, R.string.link_share_editing); + } + } else if (SharingMenuHelper.isReadOnly(share)) { + return getPermissionIndexFromArray(context, permissionArray, R.string.link_share_view_only); + } else if (SharingMenuHelper.isFileDrop(share)) { + return getPermissionIndexFromArray(context, permissionArray, R.string.link_share_file_drop); + } + return 0;//default first item selected + } + + private static int getPermissionIndexFromArray(Context context, String[] permissionArray, int permissionName) { + for (int i = 0; i < permissionArray.length; i++) { + if (permissionArray[i].equalsIgnoreCase(context.getResources().getString(permissionName))) { + return i; + } + } + return 0; + } + + public static boolean canReshare(OCShare share) { + return (share.getPermissions() & SHARE_PERMISSION_FLAG) > 0; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/helper/UsersAndGroupsSearchProvider.java b/app/src/main/java/it/niedermann/owncloud/notes/share/helper/UsersAndGroupsSearchProvider.java new file mode 100644 index 000000000..462c65dd6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/helper/UsersAndGroupsSearchProvider.java @@ -0,0 +1,199 @@ +package it.niedermann.owncloud.notes.share.helper; + + +import static com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation.PROPERTY_CLEAR_AT; +import static com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation.PROPERTY_ICON; +import static com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation.PROPERTY_MESSAGE; +import static com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation.PROPERTY_STATUS; + +import android.app.SearchManager; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.provider.BaseColumns; + +import com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation; +import com.owncloud.android.lib.resources.shares.ShareType; +import com.owncloud.android.lib.resources.users.Status; +import com.owncloud.android.lib.resources.users.StatusType; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Iterator; +import java.util.Locale; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.share.model.UsersAndGroupsSearchConfig; +import it.niedermann.owncloud.notes.share.repository.ShareRepository; + +public class UsersAndGroupsSearchProvider { + + public static final String SHARE_TYPE = "SHARE_TYPE"; + public static final String SHARE_WITH = "SHARE_WITH"; + + private static final String[] COLUMNS = { + BaseColumns._ID, + SearchManager.SUGGEST_COLUMN_TEXT_1, + SearchManager.SUGGEST_COLUMN_TEXT_2, + SearchManager.SUGGEST_COLUMN_ICON_1, + SearchManager.SUGGEST_COLUMN_INTENT_DATA, + SHARE_WITH, + SHARE_TYPE + }; + + private static final int SEARCH = 1; + + private static final int RESULTS_PER_PAGE = 50; + private static final int REQUESTED_PAGE = 1; + + public static final String CONTENT = "content"; + + private final String AUTHORITY; + private final String DATA_USER; + private final String DATA_GROUP; + private final String DATA_ROOM; + private final String DATA_REMOTE; + private final String DATA_EMAIL; + private final String DATA_CIRCLE; + + private final ShareRepository repository; + private final Context context; + + public UsersAndGroupsSearchProvider(Context context, ShareRepository repository) { + this.context = context; + this.repository = repository; + + AUTHORITY = context.getString(R.string.users_and_groups_search_provider_authority); + DATA_USER = AUTHORITY + ".data.user"; + DATA_GROUP = AUTHORITY + ".data.group"; + DATA_ROOM = AUTHORITY + ".data.room"; + DATA_REMOTE = AUTHORITY + ".data.remote"; + DATA_EMAIL = AUTHORITY + ".data.email"; + DATA_CIRCLE = AUTHORITY + ".data.circle"; + + UriMatcher mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + mUriMatcher.addURI(AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY + "/*", SEARCH); + } + + public Cursor searchForUsersOrGroups(String userQuery, boolean isFederationShareAllowed) throws JSONException { + final var names = repository.getSharees(userQuery, REQUESTED_PAGE, RESULTS_PER_PAGE); + MatrixCursor response = null; + if (!names.isEmpty()) { + response = new MatrixCursor(COLUMNS); + + Uri userBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_USER).build(); + Uri groupBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_GROUP).build(); + Uri roomBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_ROOM).build(); + Uri remoteBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_REMOTE).build(); + Uri emailBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_EMAIL).build(); + Uri circleBaseUri = new Uri.Builder().scheme(CONTENT).authority(DATA_CIRCLE).build(); + + Iterator namesIt = names.iterator(); + JSONObject item; + String displayName; + String subline = null; + Object icon = 0; + Uri dataUri; + int count = 0; + while (namesIt.hasNext()) { + item = namesIt.next(); + dataUri = null; + displayName = null; + String userName = item.getString(GetShareesRemoteOperation.PROPERTY_LABEL); + String name = item.isNull("name") ? "" : item.getString("name"); + JSONObject value = item.getJSONObject(GetShareesRemoteOperation.NODE_VALUE); + ShareType type = ShareType.fromValue(value.getInt(GetShareesRemoteOperation.PROPERTY_SHARE_TYPE)); + String shareWith = value.getString(GetShareesRemoteOperation.PROPERTY_SHARE_WITH); + + Status status; + JSONObject statusObject = item.optJSONObject(PROPERTY_STATUS); + + if (statusObject != null) { + status = new Status( + StatusType.valueOf(statusObject.getString(PROPERTY_STATUS).toUpperCase(Locale.US)), + statusObject.isNull(PROPERTY_MESSAGE) ? "" : statusObject.getString(PROPERTY_MESSAGE), + statusObject.isNull(PROPERTY_ICON) ? "" : statusObject.getString(PROPERTY_ICON), + statusObject.isNull(PROPERTY_CLEAR_AT) ? -1 : statusObject.getLong(PROPERTY_CLEAR_AT)); + } else { + status = new Status(StatusType.OFFLINE, "", "", -1); + } + + if (UsersAndGroupsSearchConfig.INSTANCE.getSearchOnlyUsers() && type != ShareType.USER) { + // skip all types but users, as E2E secure share is only allowed to users on same server + continue; + } + + switch (type) { + case GROUP: + displayName = userName; + icon = R.drawable.ic_group; + dataUri = Uri.withAppendedPath(groupBaseUri, shareWith); + break; + + case FEDERATED: + if (isFederationShareAllowed) { + icon = R.drawable.ic_account_circle_grey_24dp; + dataUri = Uri.withAppendedPath(remoteBaseUri, shareWith); + + if (userName.equals(shareWith)) { + displayName = name; + subline = context.getString(R.string.remote); + } else { + String[] uriSplitted = shareWith.split("@"); + displayName = name; + subline = context.getString(R.string.share_known_remote_on_clarification, + uriSplitted[uriSplitted.length - 1]); + } + } + break; + + case USER: + displayName = userName; + subline = (status.getMessage() == null || status.getMessage().isEmpty()) ? null : + status.getMessage(); + icon = displayName; + dataUri = Uri.withAppendedPath(userBaseUri, shareWith); + break; + + case EMAIL: + icon = R.drawable.ic_email; + displayName = name; + subline = shareWith; + dataUri = Uri.withAppendedPath(emailBaseUri, shareWith); + break; + + case ROOM: + icon = R.drawable.ic_talk; + displayName = userName; + dataUri = Uri.withAppendedPath(roomBaseUri, shareWith); + break; + + case CIRCLE: + icon = R.drawable.ic_circles; + displayName = userName; + dataUri = Uri.withAppendedPath(circleBaseUri, shareWith); + break; + + default: + break; + } + + if (displayName != null && dataUri != null) { + response.newRow() + .add(count++) + .add(displayName) + .add(subline) + .add(icon) + .add(dataUri) + .add(SHARE_WITH, shareWith) + .add(SHARE_TYPE, type.getValue()); + } + } + } + + return response; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/listener/NoteShareItemAction.java b/app/src/main/java/it/niedermann/owncloud/notes/share/listener/NoteShareItemAction.java new file mode 100644 index 000000000..24fa5bad6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/listener/NoteShareItemAction.java @@ -0,0 +1,32 @@ +package it.niedermann.owncloud.notes.share.listener; + +import com.owncloud.android.lib.resources.shares.OCShare; +import com.owncloud.android.lib.resources.shares.ShareType; + +public interface NoteShareItemAction { + + /** + * open advanced permission for selected share + */ + void advancedPermissions(OCShare share); + + /** + * open note screen to send new email + */ + void sendNewEmail(OCShare share); + + /** + * unshare the current share + */ + void unShare(OCShare share); + + /** + * send created link only valid for {@link ShareType#PUBLIC_LINK} + */ + void sendLink(OCShare share); + + /** + * create another link only valid for {@link ShareType#PUBLIC_LINK} + */ + void addAnotherLink(OCShare share); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/listener/ShareeListAdapterListener.java b/app/src/main/java/it/niedermann/owncloud/notes/share/listener/ShareeListAdapterListener.java new file mode 100644 index 000000000..c39d208f6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/listener/ShareeListAdapterListener.java @@ -0,0 +1,23 @@ +package it.niedermann.owncloud.notes.share.listener; + +import com.owncloud.android.lib.resources.shares.OCShare; + +import it.niedermann.owncloud.notes.persistence.entity.Account; + +public interface ShareeListAdapterListener { + void copyLink(OCShare share); + + void showSharingMenuActionSheet(OCShare share); + + void copyInternalLink(); + + void createPublicShareLink(); + + void createSecureFileDrop(); + + void requestPasswordForShare(OCShare share, boolean askForPassword); + + void showPermissionsDialog(OCShare share); + + void showProfileBottomSheet(Account account, String shareWith); +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/model/CreateShareRequest.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/model/CreateShareRequest.kt new file mode 100644 index 000000000..8ae313b81 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/model/CreateShareRequest.kt @@ -0,0 +1,27 @@ +package it.niedermann.owncloud.notes.share.model + +import com.google.gson.annotations.Expose + + +data class CreateShareRequest( + @Expose + val path: String, + + @Expose + val shareType: Int, + + @Expose + val shareWith: String, + + @Expose + val publicUpload: String, + + @Expose + val password: String?, + + @Expose + val permissions: Int?, + + @Expose + val note: String? +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/model/CreateShareResponse.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/model/CreateShareResponse.kt new file mode 100644 index 000000000..28aef0a38 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/model/CreateShareResponse.kt @@ -0,0 +1,137 @@ +package it.niedermann.owncloud.notes.share.model + +import com.google.gson.annotations.Expose +import com.google.gson.annotations.SerializedName + +data class CreateShareResponse( + @Expose + val id: String, + + @Expose + @SerializedName("share_type") + val shareType: Long, + + @Expose + @SerializedName("uid_owner") + val uidOwner: String, + + @Expose + @SerializedName("displayname_owner") + val displaynameOwner: String, + + @Expose + val permissions: Long, + + @Expose + @SerializedName("can_edit") + val canEdit: Boolean, + + @Expose + @SerializedName("can_delete") + val canDelete: Boolean, + + @Expose + val stime: Long, + + @Expose + @SerializedName("uid_file_owner") + val uidFileOwner: String, + + @Expose + val note: String, + + @Expose + val label: String, + + @Expose + @SerializedName("displayname_file_owner") + val displaynameFileOwner: String, + + @Expose + val path: String, + + @Expose + @SerializedName("item_type") + val itemType: String, + + @Expose + @SerializedName("item_permissions") + val itemPermissions: Long, + + @Expose + @SerializedName("is-mount-root") + val isMountRoot: Boolean, + + @Expose + @SerializedName("mount-type") + val mountType: String, + + @Expose + val mimetype: String, + + @Expose + @SerializedName("has_preview") + val hasPreview: Boolean, + + @Expose + @SerializedName("storage_id") + val storageId: String, + + @Expose + val storage: Long, + + @Expose + @SerializedName("item_source") + val itemSource: Long, + + @Expose + @SerializedName("file_source") + val fileSource: Long, + + @Expose + @SerializedName("file_parent") + val fileParent: Long, + + @Expose + @SerializedName("file_target") + val fileTarget: String, + + @Expose + @SerializedName("item_size") + val itemSize: Long, + + @Expose + @SerializedName("item_mtime") + val itemMtime: Long, + + @Expose + @SerializedName("share_with") + val shareWith: String, + + @Expose + @SerializedName("share_with_displayname") + val shareWithDisplayname: String, + + @Expose + @SerializedName("mail_send") + val mailSend: Long, + + @Expose + @SerializedName("hide_download") + val hideDownload: Long, + + @Expose + val attributes: Any?, + + @Expose + @SerializedName("url") + val url: String?, + + @Expose + @SerializedName("token") + val token: String?, + + @Expose + @SerializedName("password") + val password: String? +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/model/CreateShareResponseExtensions.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/model/CreateShareResponseExtensions.kt new file mode 100644 index 000000000..0eb71c21a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/model/CreateShareResponseExtensions.kt @@ -0,0 +1,37 @@ +package it.niedermann.owncloud.notes.share.model + +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.ShareType + +fun List.toOCShareList(): List { + return map { response -> + response.toOCShare() + }.filter { it.id != -1L } +} + +fun CreateShareResponse.toOCShare(): OCShare { + val response = this + + return OCShare().apply { + id = response.id.toLongOrNull() ?: -1L + fileSource = response.fileSource + itemSource = response.itemSource + shareType = ShareType.fromValue(response.shareType.toInt()) + shareWith = response.shareWith + path = response.path + permissions = response.permissions.toInt() + sharedDate = response.stime + token = response.token + sharedWithDisplayName = response.shareWithDisplayname + isFolder = response.itemType == "folder" + userId = response.uidOwner + shareLink = response.url + isPasswordProtected = !response.password.isNullOrEmpty() + note = response.note + isHideFileDownload = (response.hideDownload == 1L) + label = response.label + isHasPreview = response.hasPreview + mimetype = response.mimetype + ownerDisplayName = response.displaynameOwner + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/model/QuickPermissionModel.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/model/QuickPermissionModel.kt new file mode 100644 index 000000000..45e1ccf21 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/model/QuickPermissionModel.kt @@ -0,0 +1,3 @@ +package it.niedermann.owncloud.notes.share.model + +data class QuickPermissionModel(val permissionName: String, val isSelected: Boolean) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/model/SharePasswordRequest.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/model/SharePasswordRequest.kt new file mode 100644 index 000000000..b752d79c4 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/model/SharePasswordRequest.kt @@ -0,0 +1,5 @@ +package it.niedermann.owncloud.notes.share.model + +data class SharePasswordRequest( + val password: String +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/model/UpdateSharePermissionRequest.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/model/UpdateSharePermissionRequest.kt new file mode 100644 index 000000000..99d91f924 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/model/UpdateSharePermissionRequest.kt @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.share.model + +import com.google.gson.annotations.Expose +import com.google.gson.annotations.SerializedName + +data class UpdateSharePermissionRequest( + @Expose + @SerializedName("permissions") val permissions: Int? = null, +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/model/UpdateShareRequest.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/model/UpdateShareRequest.kt new file mode 100644 index 000000000..e0b2a9628 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/model/UpdateShareRequest.kt @@ -0,0 +1,44 @@ +package it.niedermann.owncloud.notes.share.model + +import com.google.gson.annotations.Expose + +data class UpdateShareRequest( + @Expose + val share_id: Int, + + @Expose + val permissions: Int?, + + @Expose + val password: String, + + @Expose + val publicUpload: String, + + @Expose + val expireDate: String?, + + @Expose + val note: String, + + /** + * Array of ShareAttributes data class in JSON format + */ + @Expose + val attributes: String?, + + @Expose + val sendMail: String +) + +data class ShareAttributesV2( + var scope: String, + var key: String, + var value: Boolean +) + +data class ShareAttributesV1( + var scope: String, + var key: String, + var enabled: Boolean +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/model/UsersAndGroupsSearchConfig.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/model/UsersAndGroupsSearchConfig.kt new file mode 100644 index 000000000..10adce634 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/model/UsersAndGroupsSearchConfig.kt @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.share.model + +object UsersAndGroupsSearchConfig { + var searchOnlyUsers: Boolean = false + + fun reset() { + searchOnlyUsers = false + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/repository/ShareRepository.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/repository/ShareRepository.kt new file mode 100644 index 000000000..2328b1552 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/repository/ShareRepository.kt @@ -0,0 +1,416 @@ +package it.niedermann.owncloud.notes.share.repository + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.internal.LinkedTreeMap +import com.nextcloud.android.sso.model.SingleSignOnAccount +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.shares.OCShare +import com.owncloud.android.lib.resources.shares.ShareType +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.persistence.ApiProvider +import it.niedermann.owncloud.notes.persistence.ApiResult +import it.niedermann.owncloud.notes.persistence.NotesRepository +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.persistence.entity.ShareEntity +import it.niedermann.owncloud.notes.share.model.CreateShareRequest +import it.niedermann.owncloud.notes.share.model.CreateShareResponse +import it.niedermann.owncloud.notes.share.model.ShareAttributesV1 +import it.niedermann.owncloud.notes.share.model.ShareAttributesV2 +import it.niedermann.owncloud.notes.share.model.SharePasswordRequest +import it.niedermann.owncloud.notes.share.model.UpdateSharePermissionRequest +import it.niedermann.owncloud.notes.share.model.UpdateShareRequest +import it.niedermann.owncloud.notes.share.model.toOCShareList +import it.niedermann.owncloud.notes.shared.model.ApiVersion +import it.niedermann.owncloud.notes.shared.model.Capabilities +import it.niedermann.owncloud.notes.shared.model.NotesSettings +import it.niedermann.owncloud.notes.shared.model.OcsResponse +import it.niedermann.owncloud.notes.shared.util.StringConstants +import it.niedermann.owncloud.notes.shared.util.extensions.getErrorMessage +import it.niedermann.owncloud.notes.shared.util.extensions.toExpirationDateString +import org.json.JSONObject +import java.util.Date + +class ShareRepository( + private val applicationContext: Context, + private val account: SingleSignOnAccount +) { + + private val tag = "ShareRepository" + private val gson = Gson() + private val apiProvider: ApiProvider by lazy { ApiProvider.getInstance() } + private val notesRepository: NotesRepository by lazy { + NotesRepository.getInstance( + applicationContext, + ) + } + + private fun getNotesPathResponseResult(): NotesSettings? { + val notesPathCall = notesRepository.getServerSettings(account, ApiVersion.API_VERSION_1_0) + val notesPathResponse = notesPathCall.execute() + return notesPathResponse.body() + } + + private fun getNotePath(note: Note): String? { + val notesPathResponseResult = getNotesPathResponseResult() ?: return null + val notesPath = notesPathResponseResult.notesPath + val notesSuffix = notesPathResponseResult.fileSuffix + return StringConstants.PATH + notesPath + StringConstants.PATH + note.title + notesSuffix + } + + fun getShareEntitiesForSpecificNote(note: Note): List { + val path = getNotePath(note) + return notesRepository.getShareEntities(path) + } + + private fun getExpirationDate(chosenExpDateInMills: Long): String? { + if (chosenExpDateInMills == -1L) { + return null + } + + return Date(chosenExpDateInMills).toExpirationDateString() + } + + fun getCapabilities(): Capabilities = notesRepository.capabilities + + // region API calls + fun getSharesForNotesAndSaveShareEntities() { + val notesPathResponseResult = getNotesPathResponseResult() ?: return + val notesPath = notesPathResponseResult.notesPath + val remotePath = "/$notesPath" + + val shareAPI = apiProvider.getShareAPI(applicationContext, account) + val call = shareAPI.getSharesForSpecificNote(remotePath) + val entities = arrayListOf() + + try { + if (call != null) { + val respOCS = call["ocs"] as? LinkedTreeMap<*, *> + val respData = respOCS?.getList("data") + respData?.forEach { data -> + val map = data as? LinkedTreeMap<*, *> + val id = map?.get("id") as? String + val note = map?.get("note") as? String + val path = map?.get("path") as? String + val fileTarget = map?.get("file_target") as? String + val shareWith = map?.get("share_with") as? String + val shareWithDisplayName = map?.get("share_with_displayname") as? String + val uidFileOwner = map?.get("uid_file_owner") as? String + val displayNameFileOwner = map?.get("displayname_file_owner") as? String + val uidOwner = map?.get("uid_owner") as? String + val displayNameOwner = map?.get("displayname_owner") as? String + val url = map?.get("url") as? String + + id?.toInt()?.let { + val entity = ShareEntity( + id = it, + note = note, + path = path, + file_target = fileTarget, + share_with = shareWith, + share_with_displayname = shareWithDisplayName, + uid_file_owner = uidFileOwner, + displayname_file_owner = displayNameFileOwner, + uid_owner = uidOwner, + displayname_owner = displayNameOwner, + url = url + ) + + entities.add(entity) + } + } + + notesRepository.addShareEntities(entities) + } + } catch (e: Exception) { + Log_OC.d(tag, "Exception while getSharesForSpecificNote: $e") + } + } + + private fun LinkedTreeMap<*, *>.getList(key: String): ArrayList<*>? = this[key] as? ArrayList<*> + + fun getSharees( + searchString: String, + page: Int, + perPage: Int + ): ArrayList { + val shareAPI = apiProvider.getShareAPI(applicationContext, account) + val call = shareAPI.getSharees( + search = searchString, + page = page.toString(), + perPage = perPage.toString() + ) + return if (call != null) { + val respOCS = call["ocs"] as? LinkedTreeMap<*, *> + val respData = respOCS?.get("data") as? LinkedTreeMap<*, *> + val respExact = respData?.get("exact") as? LinkedTreeMap<*, *> + + val respExactUsers = respExact?.getList("users") + val respExactGroups = respExact?.getList("groups") + val respExactRemotes = respExact?.getList("remotes") + val respExactEmails = respExact?.getList("emails") + val respExactCircles = + respExact?.takeIf { it.containsKey("circles") }?.getList("circles") + val respExactRooms = respExact?.takeIf { it.containsKey("rooms") }?.getList("rooms") + + val respPartialUsers = respData?.getList("users") + val respPartialGroups = respData?.getList("groups") + val respPartialRemotes = respData?.getList("remotes") + val respPartialCircles = + respData?.takeIf { it.containsKey("circles") }?.getList("circles") + val respPartialRooms = respData?.takeIf { it.containsKey("rooms") }?.getList("rooms") + + val jsonResults = listOfNotNull( + respExactUsers, + respExactGroups, + respExactRemotes, + respExactRooms, + respExactEmails, + respExactCircles, + respPartialUsers, + respPartialGroups, + respPartialRemotes, + respPartialRooms, + respPartialCircles + ) + + return jsonResults.flatMap { jsonResult -> + jsonResult.map { linkedTreeMap -> + JSONObject(gson.toJson(linkedTreeMap)) + } + }.toCollection(ArrayList()) + } else { + ArrayList() + } + } + + fun getUpdateShareRequest( + downloadPermission: Boolean, + share: OCShare?, + noteText: String, + password: String, + sendEmail: Boolean, + chosenExpDateInMills: Long, + permission: Int + ): UpdateShareRequest { + val capabilities = getCapabilities() + val shouldUseShareAttributesV2 = (capabilities.nextcloudMajorVersion?.toInt() ?: 0) >= 30 + + val shareAttributes = arrayOf( + if (shouldUseShareAttributesV2) { + ShareAttributesV2( + scope = "permissions", + key = "download", + value = downloadPermission + ) + } else { + ShareAttributesV1( + scope = "permissions", + key = "download", + enabled = downloadPermission + ) + } + ) + + val attributes = gson.toJson(shareAttributes) + + return UpdateShareRequest( + share_id = share!!.id.toInt(), + permissions = if (permission == -1) null else permission, + password = password, + publicUpload = "false", + expireDate = getExpirationDate(chosenExpDateInMills), + note = noteText, + attributes = attributes, + sendMail = sendEmail.toString() + ) + } + + fun getShares(remoteId: Long): List? { + val shareAPI = apiProvider.getShareAPI(applicationContext, account) + val call = shareAPI.getShares(remoteId) + val response = call.execute() + + return try { + if (response.isSuccessful) { + val result = + response.body()?.ocs?.data ?: throw RuntimeException("No shares available") + result.toOCShareList() + } else { + Log_OC.d(tag, "Failed to getShares: ${response.errorBody()?.string()}") + null + } + } catch (e: Exception) { + Log_OC.d(tag, "Exception while getShares: $e") + null + } + } + + fun sendEmail(shareId: Long, requestBody: SharePasswordRequest): Boolean { + val shareAPI = apiProvider.getShareAPI(applicationContext, account) + val call = shareAPI.sendEmail(shareId, requestBody) + val response = call.execute() + + return try { + if (response.isSuccessful) { + true + } else { + Log_OC.d(tag, "Failed to send-email: ${response.errorBody()?.string()}") + false + } + } catch (e: Exception) { + Log_OC.d(tag, "Exception while send-email: $e") + false + } + } + + fun getShareFromNote(note: Note): List? { + val shareAPI = apiProvider.getShareAPI(applicationContext, account) + val path = getNotePath(note) ?: return null + val call = shareAPI.getShareFromNote(path) + val response = call.execute() + + return try { + if (response.isSuccessful) { + val body = response.body() + Log_OC.d(tag, "Response successful: $body") + body?.ocs?.data?.toOCShareList() + } else { + val errorBody = response.errorBody()?.string() + Log_OC.d(tag, "Response failed: $errorBody") + null + } + } catch (e: Exception) { + Log_OC.d(tag, "Exception while getting share from note: ", e) + null + } + } + + fun removeShare(share: OCShare, note: Note): Boolean { + val shareAPI = apiProvider.getShareAPI(applicationContext, account) + + return try { + val call = shareAPI.removeShare(share.id) + val response = call.execute() + if (response.isSuccessful) { + + if (share.shareType != null && share.shareType == ShareType.PUBLIC_LINK) { + note.setIsShared(false) + updateNote(note) + } + + Log_OC.d(tag, "Share removed successfully.") + } else { + Log_OC.d(tag, "Failed to remove share: ${response.errorBody()?.string()}") + } + response.isSuccessful + } catch (e: Exception) { + Log_OC.d(tag, "Exception while removing share", e) + false + } + } + + fun updateShare( + shareId: Long, + requestBody: UpdateShareRequest + ): ApiResult?> { + val shareAPI = apiProvider.getShareAPI(applicationContext, account) + val call = shareAPI.updateShare(shareId, requestBody) + val response = call.execute() + return try { + if (response.isSuccessful) { + Log_OC.d(tag, "Share updated successfully: ${response.body().toString()}") + ApiResult.Success( + data = response.body(), + message = applicationContext.getString(R.string.note_share_created) + ) + } else { + Log_OC.d(tag, "Failed to update share: ${response.errorBody()?.string()}") + ApiResult.Error(message = response.getErrorMessage() ?: "") + } + } catch (e: Exception) { + Log_OC.d(tag, "Exception while updating share", e) + ApiResult.Error(message = e.message ?: "") + } + } + + fun updateNote(note: Note) = notesRepository.updateNote(note) + + fun addShare( + note: Note, + shareType: ShareType, + shareWith: String, + publicUpload: String = "false", + password: String = "", + permissions: Int = 0, + shareNote: String = "" + ): ApiResult?> { + val defaultErrorMessage = + applicationContext.getString(R.string.note_share_activity_cannot_created) + val notesPathCall = notesRepository.getServerSettings(account, ApiVersion.API_VERSION_1_0) + val notesPathResponse = notesPathCall.execute() + val notesPathResponseResult = + notesPathResponse.body() ?: return ApiResult.Error(message = defaultErrorMessage) + val notesPath = notesPathResponseResult.notesPath + val notesSuffix = notesPathResponseResult.fileSuffix + + val requestBody = CreateShareRequest( + path = StringConstants.PATH + notesPath + StringConstants.PATH + note.title + notesSuffix, + shareType = shareType.value, + shareWith = shareWith, + publicUpload = publicUpload, + password = password, + permissions = permissions, + note = shareNote + ) + + val shareAPI = apiProvider.getShareAPI(applicationContext, account) + val call = shareAPI.addShare(request = requestBody) + val response = call.execute() + + return try { + if (response.isSuccessful) { + val createShareResponse = response.body() + Log_OC.d(tag, "Response successful: $createShareResponse") + ApiResult.Success( + data = createShareResponse, + message = applicationContext.getString(R.string.note_share_created) + ) + } else { + val errorMessage = response.getErrorMessage() + if (errorMessage == null) { + return ApiResult.Error(message = defaultErrorMessage) + } + Log_OC.d(tag, "Response failed: $errorMessage") + ApiResult.Error(message = errorMessage) + } + } catch (e: Exception) { + Log_OC.d(tag, "Exception while creating share", e) + ApiResult.Error(message = defaultErrorMessage) + } + } + + fun updateSharePermission( + shareId: Long, + permissions: Int? = null, + ): ApiResult?> { + val shareAPI = apiProvider.getShareAPI(applicationContext, account) + val requestBody = UpdateSharePermissionRequest(permissions = permissions) + + return try { + val call = shareAPI.updateSharePermission(shareId, requestBody) + val response = call.execute() + if (response.isSuccessful) { + Log_OC.d(tag, "Share updated successfully: ${response.body()}") + ApiResult.Success(response.body()) + } else { + Log_OC.d(tag, "Failed to update share: ${response.errorBody()?.string()}") + ApiResult.Error(message = response.getErrorMessage() ?: "", code = null) + } + } catch (e: Exception) { + Log_OC.d(tag, "Exception while updating share", e) + ApiResult.Error(message = e.message ?: "", code = null) + } + } + // endregion +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserViewHolder.java index fbc04e79d..1804644ac 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserViewHolder.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/account/AccountChooserViewHolder.java @@ -11,13 +11,9 @@ import androidx.core.util.Consumer; import androidx.recyclerview.widget.RecyclerView; -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.RequestOptions; - -import it.niedermann.nextcloud.sso.glide.SingleSignOnUrl; -import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.databinding.ItemAccountChooseBinding; import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.share.helper.AvatarLoader; public class AccountChooserViewHolder extends RecyclerView.ViewHolder { private final ItemAccountChooseBinding binding; @@ -28,14 +24,7 @@ protected AccountChooserViewHolder(ItemAccountChooseBinding binding) { } public void bind(Account localAccount, Consumer targetAccountConsumer) { - Glide - .with(binding.accountItemAvatar.getContext()) - .load(new SingleSignOnUrl(localAccount.getAccountName(), localAccount.getUrl() + "/index.php/avatar/" + Uri.encode(localAccount.getUserName()) + "/64")) - .placeholder(R.drawable.ic_account_circle_grey_24dp) - .error(R.drawable.ic_account_circle_grey_24dp) - .apply(RequestOptions.circleCropTransform()) - .into(binding.accountItemAvatar); - + AvatarLoader.INSTANCE.load(binding.accountItemAvatar.getContext(), binding.accountItemAvatar, localAccount); binding.accountLayout.setOnClickListener((v) -> targetAccountConsumer.accept(localAccount)); binding.accountName.setText(localAccount.getDisplayName()); binding.accountHost.setText(Uri.parse(localAccount.getUrl()).getHost()); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java index 806b94e90..e7f1f98d2 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java @@ -10,8 +10,26 @@ import androidx.annotation.ColorInt; import androidx.annotation.Nullable; +import androidx.room.Entity; +import androidx.room.PrimaryKey; -public class Capabilities { +import com.owncloud.android.lib.resources.shares.OCShare; + +import java.io.Serializable; + +@Entity(tableName = "capabilities") +public class Capabilities implements Serializable { + @PrimaryKey + public int id = 1; + + /** + * 30(Major) .0(Minor). 5(Micro) + */ + private String nextcloudMajorVersion = null; + private String nextcloudMinorVersion = null; + private String nextcloudMicroVersion = null; + + private boolean federationShare = false; private String apiVersion = null; @ColorInt @@ -23,6 +41,43 @@ public class Capabilities { private boolean directEditingAvailable; + private boolean publicPasswordEnforced; + private boolean askForOptionalPassword; + private boolean isReSharingAllowed; + private int defaultPermission = OCShare.NO_PERMISSION; + + public boolean isReSharingAllowed() { + return isReSharingAllowed; + } + + public void setReSharingAllowed(boolean value) { + this.isReSharingAllowed = value; + } + + public boolean getPublicPasswordEnforced() { + return publicPasswordEnforced; + } + + public void setPublicPasswordEnforced(boolean value) { + this.publicPasswordEnforced = value; + } + + public int getDefaultPermission() { + return defaultPermission; + } + + public void setDefaultPermission(int value) { + this.defaultPermission = value; + } + + public boolean getAskForOptionalPassword() { + return askForOptionalPassword; + } + + public void setAskForOptionalPassword(boolean value) { + this.askForOptionalPassword = value; + } + public void setApiVersion(String apiVersion) { this.apiVersion = apiVersion; } @@ -36,6 +91,41 @@ public String getETag() { return eTag; } + @Nullable + public String getNextcloudMajorVersion() { + return nextcloudMajorVersion; + } + + @Nullable + public String getNextcloudMinorVersion() { + return nextcloudMinorVersion; + } + + @Nullable + public String getNextcloudMicroVersion() { + return nextcloudMicroVersion; + } + + public void setNextcloudMajorVersion(@Nullable String nextcloudMajorVersion) { + this.nextcloudMajorVersion = nextcloudMajorVersion; + } + + public void setNextcloudMinorVersion(@Nullable String nextcloudMinorVersion) { + this.nextcloudMinorVersion = nextcloudMinorVersion; + } + + public void setNextcloudMicroVersion(@Nullable String nextcloudMicroVersion) { + this.nextcloudMicroVersion = nextcloudMicroVersion; + } + + public boolean getFederationShare() { + return federationShare; + } + + public void setFederationShare(boolean value) { + this.federationShare = value; + } + public void setETag(@Nullable String eTag) { this.eTag = eTag; } @@ -69,6 +159,12 @@ public void setDirectEditingAvailable(boolean directEditingAvailable) { public String toString() { return "Capabilities{" + "apiVersion='" + apiVersion + '\'' + + ", nextcloudMajorVersion='" + nextcloudMajorVersion + '\'' + + ", nextcloudMinorVersion='" + nextcloudMinorVersion + '\'' + + ", nextcloudMicroVersion='" + nextcloudMicroVersion + '\'' + + ", federationShare=" + federationShare + + ", publicPasswordEnforced=" + publicPasswordEnforced + + ", askForOptionalPassword=" + askForOptionalPassword + ", color=" + color + ", textColor=" + textColor + ", eTag='" + eTag + '\'' + diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java index e59939d47..a8c97a2ab 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java @@ -10,18 +10,25 @@ import static java.util.Collections.singletonList; import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; +import android.content.Intent; import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.Rect; +import android.net.Uri; import android.os.Build; +import android.text.TextUtils; import android.util.TypedValue; import android.view.View; import android.view.WindowInsets; import androidx.annotation.NonNull; +import androidx.annotation.StringRes; import androidx.core.view.ViewCompat; +import com.google.android.material.snackbar.Snackbar; + import java.util.Collection; import java.util.List; import java.util.Locale; @@ -108,4 +115,78 @@ public static boolean isSoftKeyboardVisible(@NonNull View parentView) { final int heightDiff = parentView.getRootView().getHeight() - (rect.bottom - rect.top); return heightDiff >= estimatedKeyboardHeight; } + + static public void startLinkIntent(Activity activity, @StringRes int link) { + startLinkIntent(activity, activity.getString(link)); + } + + static public void startLinkIntent(Activity activity, String url) { + if (!TextUtils.isEmpty(url)) { + startLinkIntent(activity, Uri.parse(url)); + } + } + + static public void startLinkIntent(Activity activity, Uri uri) { + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + DisplayUtils.startIntentIfAppAvailable(intent, activity, R.string.no_browser_available); + } + + static public void startIntentIfAppAvailable(Intent intent, Activity activity, @StringRes int error) { + if (intent.resolveActivity(activity.getPackageManager()) != null) { + activity.startActivity(intent); + } else { + DisplayUtils.showSnackMessage(activity, error); + } + } + + /** + * Show a temporary message in a {@link Snackbar} bound to the content view. + * + * @param activity The {@link Activity} to which's content view the {@link Snackbar} is bound. + * @param messageResource The resource id of the string resource to use. Can be formatted text. + * @return The created {@link Snackbar} + */ + public static Snackbar showSnackMessage(Activity activity, @StringRes int messageResource) { + return showSnackMessage(activity.findViewById(android.R.id.content), messageResource); + } + + /** + * Show a temporary message in a {@link Snackbar} bound to the content view. + * + * @param activity The {@link Activity} to which's content view the {@link Snackbar} is bound. + * @param message Message to show. + * @return The created {@link Snackbar} + */ + public static Snackbar showSnackMessage(Activity activity, String message) { + final Snackbar snackbar = Snackbar.make(activity.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG); + snackbar.show(); + return snackbar; + } + + /** + * Show a temporary message in a {@link Snackbar} bound to the given view. + * + * @param view The view the {@link Snackbar} is bound to. + * @param messageResource The resource id of the string resource to use. Can be formatted text. + * @return The created {@link Snackbar} + */ + public static Snackbar showSnackMessage(View view, @StringRes int messageResource) { + final Snackbar snackbar = Snackbar.make(view, messageResource, Snackbar.LENGTH_LONG); + snackbar.show(); + return snackbar; + } + + /** + * Show a temporary message in a {@link Snackbar} bound to the given view. + * + * @param view The view the {@link Snackbar} is bound to. + * @param message The message. + * @return The created {@link Snackbar} + */ + public static Snackbar showSnackMessage(View view, String message) { + final Snackbar snackbar = Snackbar.make(view, message, Snackbar.LENGTH_LONG); + snackbar.show(); + return snackbar; + } + } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/FilesSpecificViewThemeUtils.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/FilesSpecificViewThemeUtils.kt new file mode 100644 index 000000000..82a0977d2 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/FilesSpecificViewThemeUtils.kt @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package it.niedermann.owncloud.notes.shared.util + +import android.content.Context +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.annotation.Px +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.shares.ShareType +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.branding.BrandingUtil + +object FilesSpecificViewThemeUtils { + + const val TAG = "FilesSpecificViewThemeUtils" + + private object AvatarPadding { + @Px + const val SMALL = 4 + + @Px + const val LARGE = 8 + } + + fun createAvatar(type: ShareType?, avatar: ImageView, context: Context) { + fun createAvatarBase(@DrawableRes icon: Int, padding: Int = AvatarPadding.SMALL) { + avatar.setImageResource(icon) + avatar.background = ResourcesCompat.getDrawable( + context.resources, + R.drawable.round_bgnd, + null + ) + avatar.cropToPadding = true + avatar.setPadding(padding, padding, padding, padding) + } + + val androidViewThemeUtils = BrandingUtil.getInstance(context).platform + + when (type) { + ShareType.GROUP -> { + createAvatarBase(R.drawable.ic_group) + androidViewThemeUtils.colorImageViewBackgroundAndIcon(avatar) + } + ShareType.ROOM -> { + createAvatarBase(R.drawable.first_run_talk, AvatarPadding.LARGE) + androidViewThemeUtils.colorImageViewBackgroundAndIcon(avatar) + } + ShareType.CIRCLE -> { + createAvatarBase(R.drawable.ic_circles) + + val backgroundColor = ContextCompat.getColor(context, R.color.nc_grey) + avatar.background.colorFilter = + BlendModeColorFilterCompat + .createBlendModeColorFilterCompat(backgroundColor, BlendModeCompat.SRC_IN) + + val foregroundColor = ContextCompat.getColor(context, R.color.icon_on_nc_grey) + avatar.drawable.mutate().colorFilter = + BlendModeColorFilterCompat + .createBlendModeColorFilterCompat(foregroundColor, BlendModeCompat.SRC_IN) + } + ShareType.EMAIL -> { + createAvatarBase(R.drawable.ic_email, AvatarPadding.LARGE) + androidViewThemeUtils.colorImageViewBackgroundAndIcon(avatar) + } + else -> Log_OC.d(TAG, "Unknown share type") + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/StringConstants.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/StringConstants.kt new file mode 100644 index 000000000..305da7ca4 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/StringConstants.kt @@ -0,0 +1,5 @@ +package it.niedermann.owncloud.notes.shared.util + +object StringConstants { + const val PATH = "/" +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/clipboard/ClipboardUtil.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/clipboard/ClipboardUtil.kt new file mode 100644 index 000000000..9e07f56e0 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/clipboard/ClipboardUtil.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package it.niedermann.owncloud.notes.shared.util.clipboard + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.text.TextUtils +import android.widget.Toast +import com.owncloud.android.lib.common.utils.Log_OC +import it.niedermann.owncloud.notes.R + +/** + * Helper implementation to copy a string into the system clipboard. + */ +object ClipboardUtil { + private val TAG = ClipboardUtil::class.java.name + + @JvmStatic + @JvmOverloads + @Suppress("TooGenericExceptionCaught") + fun copyToClipboard(activity: Activity, text: String?, showToast: Boolean = true) { + if (!TextUtils.isEmpty(text)) { + try { + val clip = ClipData.newPlainText( + activity.getString( + R.string.clipboard_label, + activity.getString(R.string.app_name) + ), + text + ) + (activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(clip) + if (showToast) { + Toast.makeText(activity, R.string.clipboard_text_copied, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(activity, R.string.clipboard_unexpected_error, Toast.LENGTH_SHORT).show() + Log_OC.e(TAG, "Exception caught while copying to clipboard", e) + } + } else { + Toast.makeText(activity, R.string.clipboard_no_text_to_copy, Toast.LENGTH_SHORT).show() + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/clipboard/CopyToClipboardActivity.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/clipboard/CopyToClipboardActivity.kt new file mode 100644 index 000000000..4c5ed9587 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/clipboard/CopyToClipboardActivity.kt @@ -0,0 +1,16 @@ +package it.niedermann.owncloud.notes.shared.util.clipboard + +import android.app.Activity +import android.content.Intent +import android.os.Bundle + +/** + * Activity copying the text of the received Intent into the system clipboard. + */ +class CopyToClipboardActivity : Activity() { + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ClipboardUtil.copyToClipboard(this, intent.getCharSequenceExtra(Intent.EXTRA_TEXT).toString()) + finish() + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/BundleExtensions.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/BundleExtensions.kt new file mode 100644 index 000000000..9fb4c9e78 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/BundleExtensions.kt @@ -0,0 +1,54 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 Alper Ozturk + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package it.niedermann.owncloud.notes.shared.util.extensions + +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import java.io.Serializable + +@Suppress("TopLevelPropertyNaming") + +fun Bundle?.getSerializableArgument(key: String, type: Class): T? { + if (this == null) { + return null + } + + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.getSerializable(key, type) + } else { + @Suppress("UNCHECKED_CAST", "DEPRECATION") + if (type.isInstance(this.getSerializable(key))) { + this.getSerializable(key) as T + } else { + null + } + } + } catch (e: ClassCastException) { + null + } +} + +fun Bundle?.getParcelableArgument(key: String, type: Class): T? { + if (this == null) { + return null + } + + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.getParcelable(key, type) + } else { + @Suppress("DEPRECATION") + this.getParcelable(key) + } + } catch (e: ClassCastException) { + e.printStackTrace() + null + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/CallExtensions.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/CallExtensions.kt new file mode 100644 index 000000000..46c40e711 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/CallExtensions.kt @@ -0,0 +1,17 @@ +package it.niedermann.owncloud.notes.shared.util.extensions + +import it.niedermann.owncloud.notes.shared.model.OcsResponse +import org.json.JSONObject +import retrofit2.Response + +fun Response>.getErrorMessage(): String? { + if (isSuccessful) { + return null + } + + val errorBody = errorBody()?.string() ?: return null + val jsonObject = JSONObject(errorBody) + return jsonObject.getJSONObject("ocs") + .getJSONObject("meta") + .getString("message") +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/DateExtensions.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/DateExtensions.kt new file mode 100644 index 000000000..3426b180a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/DateExtensions.kt @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.shared.util.extensions + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +fun Date.toExpirationDateString(): String { + return SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(this) +} diff --git a/app/src/main/res/drawable-night/round_bgnd.xml b/app/src/main/res/drawable-night/round_bgnd.xml new file mode 100644 index 000000000..44d973897 --- /dev/null +++ b/app/src/main/res/drawable-night/round_bgnd.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable-night/shared_via_link.xml b/app/src/main/res/drawable-night/shared_via_link.xml new file mode 100644 index 000000000..bd1841046 --- /dev/null +++ b/app/src/main/res/drawable-night/shared_via_link.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml new file mode 100644 index 000000000..b2172e676 --- /dev/null +++ b/app/src/main/res/drawable/divider.xml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/file_calendar.xml b/app/src/main/res/drawable/file_calendar.xml new file mode 100644 index 000000000..2fdb050e8 --- /dev/null +++ b/app/src/main/res/drawable/file_calendar.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/first_run_talk.xml b/app/src/main/res/drawable/first_run_talk.xml new file mode 100644 index 000000000..b2ed96f57 --- /dev/null +++ b/app/src/main/res/drawable/first_run_talk.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml new file mode 100644 index 000000000..5aee27252 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseline_check_24.xml b/app/src/main/res/drawable/ic_baseline_check_24.xml new file mode 100644 index 000000000..c11536bf9 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_check_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_circles.xml b/app/src/main/res/drawable/ic_circles.xml new file mode 100644 index 000000000..3f152e7da --- /dev/null +++ b/app/src/main/res/drawable/ic_circles.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_contact_book.xml b/app/src/main/res/drawable/ic_contact_book.xml new file mode 100644 index 000000000..1ca1a2799 --- /dev/null +++ b/app/src/main/res/drawable/ic_contact_book.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_content_copy.xml b/app/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 000000000..a2b6c8e58 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 000000000..0f6e822c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_dots_vertical.xml b/app/src/main/res/drawable/ic_dots_vertical.xml new file mode 100644 index 000000000..5c2616239 --- /dev/null +++ b/app/src/main/res/drawable/ic_dots_vertical.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 000000000..9126482d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/main/res/drawable/ic_email.xml b/app/src/main/res/drawable/ic_email.xml new file mode 100644 index 000000000..c6e736593 --- /dev/null +++ b/app/src/main/res/drawable/ic_email.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_external.xml b/app/src/main/res/drawable/ic_external.xml new file mode 100644 index 000000000..599123616 --- /dev/null +++ b/app/src/main/res/drawable/ic_external.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_group.xml b/app/src/main/res/drawable/ic_group.xml new file mode 100644 index 000000000..aad10ce3f --- /dev/null +++ b/app/src/main/res/drawable/ic_group.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 000000000..216121712 --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_plus.xml b/app/src/main/res/drawable/ic_plus.xml new file mode 100644 index 000000000..4f0bc1a41 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_talk.xml b/app/src/main/res/drawable/ic_talk.xml new file mode 100644 index 000000000..8957c64bd --- /dev/null +++ b/app/src/main/res/drawable/ic_talk.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/round_bgnd.xml b/app/src/main/res/drawable/round_bgnd.xml new file mode 100644 index 000000000..8c21e85d5 --- /dev/null +++ b/app/src/main/res/drawable/round_bgnd.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/shared_via_link.xml b/app/src/main/res/drawable/shared_via_link.xml new file mode 100644 index 000000000..d5ca0a47e --- /dev/null +++ b/app/src/main/res/drawable/shared_via_link.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_note_share.xml b/app/src/main/res/layout/activity_note_share.xml new file mode 100644 index 000000000..ae4b8d5e0 --- /dev/null +++ b/app/src/main/res/layout/activity_note_share.xml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_note_share_detail.xml b/app/src/main/res/layout/activity_note_share_detail.xml new file mode 100644 index 000000000..f412c1d13 --- /dev/null +++ b/app/src/main/res/layout/activity_note_share_detail.xml @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_row.xml b/app/src/main/res/layout/activity_row.xml new file mode 100644 index 000000000..ffb8e2e6c --- /dev/null +++ b/app/src/main/res/layout/activity_row.xml @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/item_add_public_share.xml b/app/src/main/res/layout/item_add_public_share.xml new file mode 100644 index 000000000..c31635628 --- /dev/null +++ b/app/src/main/res/layout/item_add_public_share.xml @@ -0,0 +1,44 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_internal_share_link.xml b/app/src/main/res/layout/item_internal_share_link.xml new file mode 100644 index 000000000..50157edff --- /dev/null +++ b/app/src/main/res/layout/item_internal_share_link.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_note_share.xml b/app/src/main/res/layout/item_note_share.xml new file mode 100644 index 000000000..d0423af00 --- /dev/null +++ b/app/src/main/res/layout/item_note_share.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_note_share_action.xml b/app/src/main/res/layout/item_note_share_action.xml new file mode 100644 index 000000000..64f3d2604 --- /dev/null +++ b/app/src/main/res/layout/item_note_share_action.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_notes_list_note_item_grid.xml b/app/src/main/res/layout/item_notes_list_note_item_grid.xml index 5b842046b..77c1ef076 100644 --- a/app/src/main/res/layout/item_notes_list_note_item_grid.xml +++ b/app/src/main/res/layout/item_notes_list_note_item_grid.xml @@ -95,7 +95,7 @@ android:padding="@dimen/spacer_1hx" android:textColor="?android:textColorPrimary" android:textSize="@dimen/secondary_font_size" - app:chipBackgroundColor="@color/primary" + app:chipBackgroundColor="@color/defaultBrand" app:chipEndPadding="@dimen/spacer_1x" app:chipMinHeight="0dp" app:chipStartPadding="@dimen/spacer_1x" diff --git a/app/src/main/res/layout/item_quick_share_permissions.xml b/app/src/main/res/layout/item_quick_share_permissions.xml new file mode 100644 index 000000000..bd2cf72ef --- /dev/null +++ b/app/src/main/res/layout/item_quick_share_permissions.xml @@ -0,0 +1,42 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/item_share_link_share.xml b/app/src/main/res/layout/item_share_link_share.xml new file mode 100644 index 000000000..0d9b9d833 --- /dev/null +++ b/app/src/main/res/layout/item_share_link_share.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_share_share.xml b/app/src/main/res/layout/item_share_share.xml new file mode 100644 index 000000000..108afe34e --- /dev/null +++ b/app/src/main/res/layout/item_share_share.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_suggestion_adapter.xml b/app/src/main/res/layout/item_suggestion_adapter.xml new file mode 100644 index 000000000..bf73e1bbc --- /dev/null +++ b/app/src/main/res/layout/item_suggestion_adapter.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/layout/password_dialog.xml b/app/src/main/res/layout/password_dialog.xml new file mode 100644 index 000000000..c93e7acbb --- /dev/null +++ b/app/src/main/res/layout/password_dialog.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/profile_bottom_sheet_action.xml b/app/src/main/res/layout/profile_bottom_sheet_action.xml new file mode 100644 index 000000000..06c5e60be --- /dev/null +++ b/app/src/main/res/layout/profile_bottom_sheet_action.xml @@ -0,0 +1,37 @@ + + + + + + + + diff --git a/app/src/main/res/layout/profile_bottom_sheet_fragment.xml b/app/src/main/res/layout/profile_bottom_sheet_fragment.xml new file mode 100644 index 000000000..38660f9a4 --- /dev/null +++ b/app/src/main/res/layout/profile_bottom_sheet_fragment.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/quick_sharing_permissions_bottom_sheet_fragment.xml b/app/src/main/res/layout/quick_sharing_permissions_bottom_sheet_fragment.xml new file mode 100644 index 000000000..ba3d23e8f --- /dev/null +++ b/app/src/main/res/layout/quick_sharing_permissions_bottom_sheet_fragment.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 4200f876f..707ba348e 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -13,9 +13,12 @@ #121212 #f5f5f5 + #E3E3E3 #eee5e5ee #55eeeeff + #A5A5A5 + #737373 #2a2a2a @color/primary @@ -32,4 +35,7 @@ #dd000000 #d8d8d8 + + #222222 + #ffffff \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index ce764c365..1a688fe65 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -14,8 +14,13 @@ #ffffff #121212 #d40000 + #333333 + #616161 + #FFFFFF + #e53935 #0082C9 + #666666 #555566 #2233334a @@ -30,7 +35,8 @@ #cccccc #999999 #ffffff - + #ededed + #000000 #757575 @color/grey600 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 5daaa6b03..c2c7d4080 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -9,6 +9,8 @@ ~ SPDX-License-Identifier: GPL-3.0-or-later --> + 0dp + 2dp 4dp @@ -17,11 +19,25 @@ 24dp 36dp 40dp + 140dp + 24dp + 180dp + 100dp 36dp + 196dip + 48dp + 40dp + 16sp + 56dp + 24dp + 28dp + 0dp + 40dp + 100dp 42dp @@ -33,6 +49,7 @@ 14sp + 12sp 16sp 18sp 22sp @@ -48,4 +65,8 @@ 4dp 26dp 20dp + + 48dp + + 56dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b596e5f7d..919f5fe49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,12 +32,143 @@ Favorite Preview Share + Loading… + Search in %1$s Search all notes Choose a category + + com.nextcloud.android.providers.UsersAndGroupsSearchProvider + com.nextcloud.android.providers.UsersAndGroupsSearchProvider.action.SHARE_WITH + + + + @string/link_share_view_only + @string/link_share_allow_upload_and_editing + @string/link_share_file_drop + + + @string/link_share_view_only + @string/link_share_editing + + + + note activity icon + note share icon + note share external icon + note share copy icon + note share more icon + note share icon + note share user icon + note share contact icon + Shared with you by %1$s + Share note + Name, Federated Cloud ID or email address… + Share link + Policy or permissions prevent resharing + Could not retrieve URL + \"%1$s\" has been shared with you + Contact permission is required. + You are not allowed to create a public share. + + Share created + You are not allowed to create a share. + + + + Advanced Settings + Hide download + Note to recipient + Note + Next + Share and Copy Link + Confirm + Set Note + Send Share + Name + Link Name + You must enter a password + Password + Please select at least one permission to share. + Label cannot be empty + Cancel + Failed to create a share + + + Enter an optional password + Skip + + Enter a password + OK + Delete + Send email + No app available to handle links + No App available to handle mail address + No actions for this user + + Failed to remove share + Failed to pick email address. + Failed to update UI + No app available to select contacts + Send link to… + + Send + Internal share link only works for users with access to this folder + Internal share link only works for users with access to this file + Share internal link + Delete Link + Settings + Send new email + Sharing + Share %1$s + Expires %1$s + %1$s + Set expiration date + Share link + Send link + Password-protected + Set password + Share with… + Unset + Add another link + Add new public share link + New name + Share link (%1$s) + Share link + Allow resharing + View only + Editing + Allow upload and editing + File drop (upload only) + Could not retrieve shares + View only + Can edit + File drop + Secure file drop + Share Permissions + + %1$d download remaining + %1$d downloads remaining + + Username + %1$s (group) + %1$s (remote) + %1$s (conversation) + on %1$s + (remote) + + + + Copy link + Share + Link copied + Received no text to copy to clipboard + Unexpected error while copying to clipboard + Text copied from %1$s + Today Yesterday This week diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 36c07e913..6720f1725 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -35,6 +35,21 @@ @style/tabStyle + + + + + + +