diff --git a/LICENSES/BSD-2-Clause.txt b/LICENSES/BSD-2-Clause.txt new file mode 100644 index 000000000..5f662b354 --- /dev/null +++ b/LICENSES/BSD-2-Clause.txt @@ -0,0 +1,9 @@ +Copyright (c) + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4dbf5231..6298b8fba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,9 +6,11 @@ * SPDX-FileCopyrightText: 2020 Hui-Ouyang16 <11710106@mail.sustech.edu.cn> * SPDX-License-Identifier: GPL-3.0-or-later */ +@file:Suppress("MagicNumber") plugins { id("com.android.application") + id("com.google.devtools.ksp") } val kotlinVersion = rootProject.extra["kotlinVersion"] as String @@ -130,6 +132,7 @@ dependencies { implementation("commons-httpclient:commons-httpclient:3.1") { exclude(group = "commons-logging", module = "commons-logging") } + implementation("org.apache.jackrabbit:jackrabbit-webdav:2.13.5") implementation("com.github.nextcloud:android-library:2.26.1") { exclude(group = "org.ogce", module = "xpp3") @@ -156,7 +159,8 @@ dependencies { // Glide val glideVersion = "5.0.9" implementation("com.github.bumptech.glide:glide:$glideVersion") - annotationProcessor("com.github.bumptech.glide:compiler:$glideVersion") + ksp("com.github.bumptech.glide:ksp:$glideVersion") + implementation("com.caverock:androidsvg-aar:1.4") // Android X implementation("androidx.appcompat:appcompat:1.8.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 111a0dc01..2f8eb23ce 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,7 +58,7 @@ + android:name=".itemdetails.ItemDetailsActivity" /> diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/ActivitiesDataProvider.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/ActivitiesDataProvider.kt new file mode 100644 index 000000000..26568ec9f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/ActivitiesDataProvider.kt @@ -0,0 +1,123 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities + +import android.content.Context +import android.util.Log +import androidx.lifecycle.LifecycleCoroutineScope +import com.nextcloud.android.sso.helper.SingleAccountHelper +import it.niedermann.owncloud.notes.activities.ActivitiesFragment.Companion.TAG +import it.niedermann.owncloud.notes.activities.adapter.ActivitiesData +import it.niedermann.owncloud.notes.activities.repository.ActivitiesRepository +import it.niedermann.owncloud.notes.activities.repository.model.ActivitiesResult +import it.niedermann.owncloud.notes.activities.repository.VersionsRepository +import it.niedermann.owncloud.notes.activities.repository.model.VersionsResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ActivitiesDataProvider ( + val context: Context, + val lifecycleScope: LifecycleCoroutineScope, + val noteId: Long, + val listener: OnPageFetchedListener +) { + val data = ActivitiesData() + private var job: Job? = null + + private val ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(context) + private val activitiesRepo = ActivitiesRepository(context, ssoAccount) + private val versionsRepo = VersionsRepository(context, ssoAccount) + + fun fetchFirstPage() { + job?.cancel() + job = lifecycleScope.launch { + val (activitiesResult, versionsResult) = withContext(Dispatchers.IO) { + val activities = async { + activitiesRepo.fetchActivities(noteId) + } + val versions = async { + versionsRepo.fetchVersions(noteId) + } + activities.await() to versions.await() + } + + if (!parseActivitiesResult(activitiesResult)) return@launch + + if (!parseVersionsResult(versionsResult)) return@launch + + listener.onPageFetched() + } + } + + fun fetchNextPage() { + if (job?.isActive == true) return + + if (!data.activitiesMore) return + + job = lifecycleScope.launch { + val activitiesResult = withContext(Dispatchers.IO) { + activitiesRepo.fetchActivities(noteId, data.activitiesLastGiven) + } + + if (!parseActivitiesResult(activitiesResult)) return@launch + + listener.onPageFetched() + } + } + + private fun parseActivitiesResult(activitiesResult: ActivitiesResult): Boolean { + return when (activitiesResult) { + is ActivitiesResult.Success -> { + data.activitiesLastGiven = activitiesResult.lastGiven + data.activitiesMore = activitiesResult.lastGiven != null + data.activities.addAll(activitiesResult.activities) + true + } + + is ActivitiesResult.FailedNoCapability -> { + Log.d(TAG, "Server has no Activity endpoint") + data.activitiesMore = false + true + } + + is ActivitiesResult.Failed -> { + Log.e(TAG, "Fetching activities failed") + listener.onPageFetchError() + false + } + } + } + + private fun parseVersionsResult(versionsResult: VersionsResult): Boolean { + return when (versionsResult) { + is VersionsResult.Success -> { + data.versions.addAll(versionsResult.versions) + true + } + is VersionsResult.Failed -> { + Log.e(TAG, "Fetching versions failed") + false + } + } + } + + fun reset() { + data.activities.clear() + data.versions.clear() + data.activitiesLastGiven = null + data.activitiesMore = true + } + + interface OnPageFetchedListener { + fun onPageFetched() + fun onPageFetchError() + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/ActivitiesFragment.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/ActivitiesFragment.kt new file mode 100644 index 000000000..a9bdd561a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/ActivitiesFragment.kt @@ -0,0 +1,178 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities + +import android.annotation.SuppressLint +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.android.sso.helper.SingleAccountHelper +import com.owncloud.android.lib.resources.files.model.FileVersion +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.activities.adapter.ActivityListAdapter +import it.niedermann.owncloud.notes.activities.adapter.OnActivityClickListener +import it.niedermann.owncloud.notes.activities.adapter.model.Activity +import it.niedermann.owncloud.notes.activities.repository.ActivitiesRepository +import it.niedermann.owncloud.notes.activities.repository.model.RestoreVersionsResult +import it.niedermann.owncloud.notes.activities.repository.VersionsRepository +import it.niedermann.owncloud.notes.branding.BrandedFragment +import it.niedermann.owncloud.notes.branding.BrandedSnackbar +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.FragmentActivitiesBinding +import it.niedermann.owncloud.notes.persistence.NotesRepository +import it.niedermann.owncloud.notes.persistence.entity.Account +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.shared.util.extensions.getSerializableArgument +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ActivitiesFragment : BrandedFragment(), OnActivityClickListener, ActivitiesDataProvider.OnPageFetchedListener { + + lateinit var binding : FragmentActivitiesBinding + private lateinit var note: Note + private lateinit var account: Account + private lateinit var adapter: ActivityListAdapter + + private lateinit var activityDataProvider: ActivitiesDataProvider + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + adapter = ActivityListAdapter(requireContext(), this) + binding = FragmentActivitiesBinding.inflate(inflater, container, false) + + initializeArguments() + initializeList() + + return binding.root + } + + private fun initializeArguments() { + val bundler = arguments + note = bundler.getSerializableArgument(ARG_NOTE, Note::class.java) + ?: throw IllegalArgumentException("Argument $ARG_NOTE is mandatory") + account = bundler.getSerializableArgument(ARG_ACCOUNT, Account::class.java) + ?: throw IllegalArgumentException("Argument $ARG_ACCOUNT is mandatory") + } + + private fun initializeList() { + val id = note.remoteId + if (id == null) { + Log.e(TAG, "Unable to load list: note has no remoteId") + return + } + + activityDataProvider = ActivitiesDataProvider( + requireContext(), + lifecycleScope, + id, + this + ) + binding.list.adapter = adapter + binding.swipeContainingList.setOnRefreshListener { + // Reload list + activityDataProvider.reset() + activityDataProvider.fetchFirstPage() + } + + activityDataProvider.fetchFirstPage() + } + + override fun applyBrand(color: Int) { + adapter.applyBrand(color) + val util = BrandingUtil.of(color, requireContext()) + util.platform.colorViewBackground(binding.getRoot()) + util.androidx.themeSwipeRefreshLayout(binding.swipeContainingList) + } + + override fun onActivityItemClick(activity: Activity) = Unit + + override fun onVersionItemClick(version: FileVersion) { + showProgress(true) + lifecycleScope.launch(Dispatchers.IO) { + // Restore the version + val ssoAccount = + SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()) + val repository = VersionsRepository(requireContext(), ssoAccount) + val result = repository.restoreVersion(version) + withContext(Dispatchers.Main) { + when (result) { + RestoreVersionsResult.Success -> syncNote() + RestoreVersionsResult.FailedUnsupported -> + showMessageAndHideProgress(R.string.version_restore_failed_unsupported) + else -> showMessageAndHideProgress(R.string.version_restore_failed) + } + } + } + } + + private fun syncNote() { + // Sync the restored note + val noteRepo = NotesRepository.getInstance(binding.root.context) + noteRepo.addCallbackPull(account, { + // Used in node editor: RESULT_OK means reload the note + requireActivity().setResult(android.app.Activity.RESULT_OK) + + // Reload list + activityDataProvider.reset() + activityDataProvider.fetchFirstPage() + + // Notify user + showMessageAndHideProgress(R.string.version_restored) + }) + noteRepo.scheduleSync(account, false) + } + + private fun showProgress(show: Boolean) { binding.swipeContainingList.isRefreshing = show } + + private fun showMessageAndHideProgress(@StringRes message: Int) { + showProgress(false) + BrandedSnackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() + } + + override fun lastActivityReached() { + if (adapter.itemCount < ActivitiesRepository.PAGE_SIZE) + return + activityDataProvider.fetchNextPage() + } + + @SuppressLint("NotifyDataSetChanged") + override fun onPageFetched() { + showProgress(false) + binding.emptyList.visibility = View.GONE + adapter.items.clear() + adapter.items.addAll( + activityDataProvider.data.getAggregatedList(requireContext()) + ) + adapter.lastActivity = activityDataProvider.data.activities.lastOrNull() + adapter.notifyDataSetChanged() + } + + override fun onPageFetchError() { + showProgress(false) + if (adapter.items.isEmpty()) binding.emptyList.visibility = View.VISIBLE + BrandedSnackbar.make(binding.root, R.string.activities_error, Snackbar.LENGTH_SHORT).show() + } + + companion object { + val TAG = ActivitiesFragment::class.simpleName + const val ARG_NOTE = "NOTE" + const val ARG_ACCOUNT = "ACCOUNT" + + fun newInstance(note: Note, account: Account) : ActivitiesFragment = ActivitiesFragment().apply { + arguments = Bundle().apply { + putSerializable(ARG_NOTE, note) + putSerializable(ARG_ACCOUNT, account) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivitiesData.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivitiesData.kt new file mode 100644 index 000000000..12c2712e6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivitiesData.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.adapter + +import android.content.Context +import com.owncloud.android.lib.resources.files.model.FileVersion +import it.niedermann.owncloud.notes.activities.adapter.model.Activity +import it.niedermann.owncloud.notes.activities.adapter.model.ActivityListItem +import it.niedermann.owncloud.notes.activities.adapter.model.ActivityListItem.FileVersionItem + +data class ActivitiesData( + val activities: ArrayList = arrayListOf(), + val versions: ArrayList = arrayListOf(), + var activitiesLastGiven: Long? = null, + var activitiesMore: Boolean = true +) { + fun getAggregatedList(context: Context) : List { + val items = arrayListOf() + items.addAll( + activities.map { ActivityListItem.ActivityItem(it) } + ) + items.addAll( + versions.map { FileVersionItem(context, it) } + ) + items.sortDescending() + return items + } + + fun isEmpty() : Boolean = activities.isEmpty() && versions.isEmpty() +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivityListAdapter.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivityListAdapter.kt new file mode 100644 index 000000000..f776ef29a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivityListAdapter.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.android.common.core.utils.DateFormatter +import it.niedermann.owncloud.notes.activities.adapter.model.Activity +import it.niedermann.owncloud.notes.activities.adapter.model.ActivityListItem +import it.niedermann.owncloud.notes.activities.adapter.viewholder.ActivityViewHolder +import it.niedermann.owncloud.notes.branding.Branded +import it.niedermann.owncloud.notes.databinding.ActivityListItemBinding +import java.util.Calendar + +class ActivityListAdapter ( + private val context: Context, + private val listener: OnActivityClickListener +) : RecyclerView.Adapter(), Branded { + + val items = arrayListOf() + var lastActivity: Activity? = null + private var brandColor: Int? = null + private val dateFormatter = DateFormatter(context) + + val calendar: Calendar = Calendar.getInstance() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): RecyclerView.ViewHolder = + ActivityViewHolder( + ActivityListItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + dateFormatter, + calendar + ) + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + // Apply brand + val holder = (holder as ActivityViewHolder) + brandColor?.apply { holder.applyBrand(this) } + + // Retrieve item and bind vh + val item = items[position] + if (lastActivity != null && (item as? ActivityListItem.ActivityItem)?.activity == lastActivity) { + listener.lastActivityReached() + } + holder.bind(item, listener) + } + + override fun getItemCount(): Int = items.size + + override fun applyBrand(color: Int) { + brandColor = color + } + +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/OnActivityClickListener.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/OnActivityClickListener.kt new file mode 100644 index 000000000..c84d2d6c8 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/OnActivityClickListener.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.adapter + +import com.owncloud.android.lib.resources.files.model.FileVersion +import it.niedermann.owncloud.notes.activities.adapter.model.Activity + +interface OnActivityClickListener { + fun onActivityItemClick(activity: Activity) + fun onVersionItemClick(version: FileVersion) + fun lastActivityReached() +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/model/Activity.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/model/Activity.kt new file mode 100644 index 000000000..3cd1a16f4 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/model/Activity.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.adapter.model + +import com.google.gson.annotations.SerializedName +import java.util.Date + +data class Activity( + @SerializedName("activity_id") val id: Long, + val app: String, + val type: String, + val user: String, + val subject: String, + val message: String?, + val icon: String?, + val link: String?, + val datetime: Date, + @SerializedName("object_id") val objectId: Long?, + @SerializedName("object_name") val objectName: String? +) : Comparable { + + override fun compareTo(other: Activity): Int = + (datetime.time - other.datetime.time).toInt() + +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/model/ActivityListItem.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/model/ActivityListItem.kt new file mode 100644 index 000000000..791830338 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/model/ActivityListItem.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.adapter.model + +import android.content.Context +import com.owncloud.android.lib.resources.files.model.FileVersion +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.util.HumanReadableFormatter + +sealed class ActivityListItem : Comparable { + abstract fun getTimestamp(): Long + abstract fun getTitle(): String? + abstract fun getMessage(): String? + open fun getIconUrl(): String? = null + open fun canBeRestored(): Boolean = false + + override fun compareTo(other: ActivityListItem): Int = + getTimestamp().compareTo(other.getTimestamp()) + + data class ActivityItem(val activity: Activity) : ActivityListItem() { + override fun getTimestamp(): Long = activity.datetime.time + override fun getTitle(): String = activity.subject + override fun getMessage(): String? = activity.message + override fun getIconUrl(): String? = activity.icon + } + + data class FileVersionItem(val context: Context, val fileVersion: FileVersion) : ActivityListItem() { + override fun getTimestamp(): Long = fileVersion.modifiedTimestamp + override fun getTitle(): String = context.getString(R.string.new_version_was_created) + override fun getMessage(): String = HumanReadableFormatter.formatBytes(fileVersion.fileLength) ?: "" + override fun canBeRestored(): Boolean = true + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/viewholder/ActivityViewHolder.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/viewholder/ActivityViewHolder.kt new file mode 100644 index 000000000..95c1a4fe8 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/viewholder/ActivityViewHolder.kt @@ -0,0 +1,98 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.adapter.viewholder + +import android.view.View +import androidx.core.net.toUri +import com.bumptech.glide.Glide +import com.nextcloud.android.common.core.utils.DateFormatter +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.activities.adapter.model.ActivityListItem +import it.niedermann.owncloud.notes.activities.adapter.OnActivityClickListener +import it.niedermann.owncloud.notes.branding.BrandedViewHolder +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.ActivityListItemBinding +import java.util.Calendar + + +class ActivityViewHolder( + val binding: ActivityListItemBinding, + val dateFormatter: DateFormatter, + val calendar: Calendar +) : BrandedViewHolder(binding.root) { + + fun bind(item: ActivityListItem, listener: OnActivityClickListener) { + bindDateTime(item) + bindSubject(item) + bindMessage(item) + bindIcon(item) + bindActionIcon(item) + binding.root.setOnClickListener { + when (item) { + is ActivityListItem.ActivityItem -> listener.onActivityItemClick(item.activity) + is ActivityListItem.FileVersionItem -> listener.onVersionItemClick(item.fileVersion) + } + } + } + + private fun bindDateTime(activity: ActivityListItem) { + binding.datetime.apply { + visibility = View.VISIBLE + text = dateFormatter.getConditionallyRelativeFormattedTimeSpan( + calendar.apply { + timeInMillis = activity.getTimestamp() + } + ) + } + } + + private fun bindSubject(activity: ActivityListItem) { + binding.subject.text = activity.getTitle() + } + + private fun bindMessage(activity: ActivityListItem) { + binding.message.apply { + when { + activity.getMessage().isNullOrEmpty() -> visibility = View.GONE + else -> { + visibility = View.VISIBLE + text = activity.getMessage() + } + } + } + } + + private fun bindIcon(activity: ActivityListItem) { + val iconUrl = activity.getIconUrl() + if (iconUrl.isNullOrEmpty()) { + binding.icon.setImageResource(R.drawable.ic_activity) + return + } + + Glide + .with(binding.icon.context) + .load(iconUrl.toUri()) + .placeholder( R.drawable.ic_activity) + .error(R.drawable.ic_activity) + .into(binding.icon) + } + + private fun bindActionIcon(activity: ActivityListItem) { + binding.actionIcon.visibility = + if (activity.canBeRestored()) View.VISIBLE + else View.GONE + } + + override fun applyBrand(color: Int) { + val util = BrandingUtil.of(color, binding.root.context) + util.platform.colorViewBackground(binding.root) + util.platform.colorImageView(binding.icon, ColorRole.ON_SURFACE_VARIANT) + } + +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/ActivitiesRepository.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/ActivitiesRepository.kt new file mode 100644 index 000000000..032ee1215 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/ActivitiesRepository.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.repository + +import android.content.Context +import android.util.Log +import com.nextcloud.android.sso.model.SingleSignOnAccount +import it.niedermann.owncloud.notes.activities.repository.model.ActivitiesResult +import it.niedermann.owncloud.notes.persistence.ApiProvider +import okio.IOException +import java.net.HttpURLConnection + +class ActivitiesRepository( + private val context: Context, + private val ssoAccount: SingleSignOnAccount +) { + + private val api by lazy { ApiProvider.getInstance().getActivitiesAPI(context, ssoAccount) } + + fun fetchActivities(noteId: Long, since: Long? = null): ActivitiesResult { + try { + val response = api.fetchActivities(noteId, since, PAGE_SIZE).execute() + + if (response.isSuccessful || response.code() == HttpURLConnection.HTTP_NOT_MODIFIED) { + return ActivitiesResult.Success( + response.body()?.ocs?.data ?: listOf(), + response.headers()[HEADER_LAST_GIVEN]?.toLongOrNull() + ) + } + + // Activity capability not present + if (response.code() == HttpURLConnection.HTTP_NOT_FOUND) { + return ActivitiesResult.FailedNoCapability + } + } catch (e: IOException) { + Log.e(TAG, "Unable to fetch activities: $e") + } + + return ActivitiesResult.Failed() + } + + companion object { + val TAG = ActivitiesRepository::class.java.simpleName + const val PAGE_SIZE = 50 + private const val HEADER_LAST_GIVEN = "X-Activity-Last-Given" + } + +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/VersionsRepository.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/VersionsRepository.kt new file mode 100644 index 000000000..9878894e1 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/VersionsRepository.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.repository + +import android.content.Context +import com.nextcloud.android.sso.model.SingleSignOnAccount +import com.owncloud.android.lib.resources.files.model.FileVersion +import it.niedermann.owncloud.notes.activities.repository.model.RestoreVersionsResult +import it.niedermann.owncloud.notes.activities.repository.model.VersionsResult +import it.niedermann.owncloud.notes.persistence.ApiProvider + +class VersionsRepository ( + private val context: Context, + private val ssoAccount: SingleSignOnAccount +) { + private val api by lazy { ApiProvider.getInstance().getVersionsAPI(context, ssoAccount) } + + fun fetchVersions(noteId: Long): VersionsResult { + return api.fetchVersions(noteId) + } + + fun restoreVersion(version: FileVersion): RestoreVersionsResult { + return api.restoreVersion(version) + } + +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/model/ActivitiesResult.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/model/ActivitiesResult.kt new file mode 100644 index 000000000..9dcd61df6 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/model/ActivitiesResult.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.repository.model + +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.activities.adapter.model.Activity + +sealed class ActivitiesResult(open val descriptionId: Int? = null) { + + data class Success(val activities: List, val lastGiven: Long?) : ActivitiesResult() + + data object FailedNoCapability : ActivitiesResult() + + data class Failed( + override val descriptionId: Int = R.string.activities_fetch_failed + ) : ActivitiesResult(descriptionId) + +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/model/RestoreVersionsResult.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/model/RestoreVersionsResult.kt new file mode 100644 index 000000000..bfc24e564 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/model/RestoreVersionsResult.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.repository.model + +enum class RestoreVersionsResult{ + Success, Failed, FailedUnsupported +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/model/VersionsResult.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/model/VersionsResult.kt new file mode 100644 index 000000000..f525224ab --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/model/VersionsResult.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.activities.repository.model + +import com.owncloud.android.lib.resources.files.model.FileVersion + +sealed class VersionsResult { + + data class Success(val versions: List) : VersionsResult() + data object Failed : VersionsResult() + +} \ No newline at end of file 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 f64eec2e7..d8ebc97c8 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 @@ -46,6 +46,7 @@ import it.niedermann.owncloud.notes.edit.category.CategoryDialogFragment.CategoryDialogListener; import it.niedermann.owncloud.notes.edit.title.EditTitleDialogFragment; import it.niedermann.owncloud.notes.edit.title.EditTitleDialogFragment.EditTitleListener; +import it.niedermann.owncloud.notes.itemdetails.ItemDetailsActivity; import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Note; @@ -305,9 +306,10 @@ public boolean onOptionsItemSelected(MenuItem item) { .newInstance(new ArrayList<>(repo.getAccounts()), note.getAccountId()) .show(requireActivity().getSupportFragmentManager(), BaseNoteFragment.class.getSimpleName())); return true; - } else if (itemId == R.id.menu_share) { - shareNote(); - return false; + } else if (itemId == R.id.menu_details) { + if (getActivity() instanceof EditNoteActivity editNoteActivity) + editNoteActivity.openDetails(note, localAccount); + return true; } else if (itemId == MENU_ID_PIN) { pinNoteToHome(); return true; 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 e94d30973..6dc366466 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 @@ -19,6 +19,8 @@ import android.widget.Toast; 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.core.graphics.Insets; @@ -45,6 +47,7 @@ import it.niedermann.owncloud.notes.accountpicker.AccountPickerListener; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.databinding.ActivityEditBinding; +import it.niedermann.owncloud.notes.itemdetails.ItemDetailsActivity; import it.niedermann.owncloud.notes.main.MainActivity; import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; @@ -71,6 +74,14 @@ public class EditNoteActivity extends LockedActivity implements BaseNoteFragment private BaseNoteFragment fragment; private NotesRepository repo; + private final ActivityResultLauncher detailsLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == RESULT_OK) { + refreshExistingNote(getAccountId(), getNoteId()); + } + }); + @Override protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -201,6 +212,10 @@ private void launchNoteFragment() { } } + private void refreshExistingNote(long accountId, long noteId) { + launchExistingNote(accountId, noteId, null, true); + } + /** * Starts a {@link NoteEditFragment} or {@link NotePreviewFragment} for an existing note. * The type of fragment (view-mode) is chosen based on the user preferences. @@ -421,6 +436,10 @@ public void close() { } + public void openDetails(Note note, Account localAccount) { + detailsLauncher.launch(ItemDetailsActivity.getActivityStartIntent(this, note, localAccount)); + } + @Override public void onNoteUpdated(Note note) { if (note != null) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteReadonlyFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteReadonlyFragment.java index 7a1126f59..9e17cc55e 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteReadonlyFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteReadonlyFragment.java @@ -28,7 +28,7 @@ public void onPrepareOptionsMenu(@NonNull Menu menu) { menu.findItem(R.id.menu_preview).setVisible(false); menu.findItem(R.id.menu_cancel).setVisible(false); menu.findItem(R.id.menu_delete).setVisible(false); - menu.findItem(R.id.menu_share).setVisible(false); + menu.findItem(R.id.menu_details).setVisible(false); menu.findItem(R.id.menu_move).setVisible(false); menu.findItem(R.id.menu_category).setVisible(false); menu.findItem(R.id.menu_title).setVisible(false); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/itemactions/ItemActionBottomSheet.kt b/app/src/main/java/it/niedermann/owncloud/notes/itemactions/ItemActionBottomSheet.kt new file mode 100644 index 000000000..4cb7a49ab --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/itemactions/ItemActionBottomSheet.kt @@ -0,0 +1,109 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.itemactions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.branding.Branded +import it.niedermann.owncloud.notes.branding.BrandedBottomSheetDialogFragment +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.ActionsBottomSheetItemBinding +import it.niedermann.owncloud.notes.databinding.ItemActionBottomSheetBinding +import it.niedermann.owncloud.notes.itemdetails.ItemDetailsActivity +import it.niedermann.owncloud.notes.persistence.entity.Account +import it.niedermann.owncloud.notes.persistence.entity.Note + + +class ItemActionBottomSheet( + val note: Note, + val account: Account +) : BrandedBottomSheetDialogFragment(R.layout.item_action_bottom_sheet) { + + lateinit var binding: ItemActionBottomSheetBinding + private var actionViews : List? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = ItemActionBottomSheetBinding.inflate(layoutInflater, container, false) + + setTitle() + addActions( + listOf( + NoteAction.SEE_DETAILS, + NoteAction.SEND_SHARE + ) + ) + + return binding.root + } + + fun setTitle() { + binding.title.text = note.title + } + + fun addActions(actions: List) { + actionViews = actions.map { action -> inflateActionView(action) } + actionViews?.forEach { binding.fileActionsList.addView(it.binding.root) } + } + + private fun inflateActionView(action: NoteAction): ActionView { + val itemBinding = ActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false) + .apply { + text.setText(action.title) + icon.setImageResource(action.icon) + root.setOnClickListener { onActionSelected(action) } + } + return ActionView(itemBinding) + } + + fun onActionSelected(action: NoteAction) { + when (action) { + NoteAction.SEE_DETAILS -> openInfo(ItemDetailsActivity.PAGE.ACTIVITIES) + NoteAction.SEND_SHARE -> openInfo(ItemDetailsActivity.PAGE.SHARE) + } + dismiss() + } + + override fun applyBrand(color: Int) { + actionViews?.forEach { it.applyBrand(color) } + } + + private fun openInfo(page: ItemDetailsActivity.PAGE) { + val context = context ?: return + val intent = ItemDetailsActivity.getActivityStartIntent(context, note, account, page) + startActivity(intent); + } + + private data class ActionView( + val binding: ActionsBottomSheetItemBinding + ) : Branded { + + override fun applyBrand(color: Int) { + binding.apply { + val brandingUtil = BrandingUtil.of(color, root.context) + brandingUtil.platform.colorImageView(icon, ColorRole.PRIMARY) + } + } + + } + + + companion object { + @JvmField + val TAG: String = ItemActionBottomSheet::class.simpleName ?: "ItemActionBottomSheet" + } + +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/itemactions/NoteAction.kt b/app/src/main/java/it/niedermann/owncloud/notes/itemactions/NoteAction.kt new file mode 100644 index 000000000..bbefa9e53 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/itemactions/NoteAction.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.itemactions + +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import it.niedermann.owncloud.notes.R + +enum class NoteAction( + @param:IdRes val id: Int, + @param:StringRes val title: Int, + @param:DrawableRes val icon: Int +) { + SEE_DETAILS(R.id.action_see_details, R.string.action_see_details, R.drawable.ic_info_outline_grey600_24dp), + SEND_SHARE(R.id.action_send_share, R.string.action_send_share, R.drawable.ic_share_white_24dp), +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/itemdetails/ItemDetailsActivity.kt b/app/src/main/java/it/niedermann/owncloud/notes/itemdetails/ItemDetailsActivity.kt new file mode 100644 index 000000000..a84bc92c7 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/itemdetails/ItemDetailsActivity.kt @@ -0,0 +1,148 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.itemdetails + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.text.format.DateUtils +import androidx.appcompat.content.res.AppCompatResources +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.tabs.TabLayoutMediator +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.activities.ActivitiesFragment +import it.niedermann.owncloud.notes.branding.BrandedActivity +import it.niedermann.owncloud.notes.branding.BrandedFragment +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.ActivityItemDetailsBinding +import it.niedermann.owncloud.notes.persistence.entity.Account +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.share.NoteShareFragment +import it.niedermann.owncloud.notes.shared.util.extensions.getSerializableArgument + +class ItemDetailsActivity : BrandedActivity() { + + lateinit var binding : ActivityItemDetailsBinding + lateinit var note: Note + lateinit var account: Account + lateinit var page: PAGE + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + parseArguments() + + binding = ActivityItemDetailsBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } + + loadHeaderData() + loadFragments() + } + + @Suppress("ThrowsCount") + private fun parseArguments() { + note = intent.extras.getSerializableArgument(ARG_NOTE, Note::class.java) + ?: throw RuntimeException("Argument $ARG_NOTE is mandatory") + account = intent.extras.getSerializableArgument(ARG_ACCOUNT, Account::class.java) + ?: throw RuntimeException("Argument $ARG_ACCOUNT is mandatory") + page = intent.extras.getSerializableArgument(ARG_PAGE, PAGE::class.java) + ?: throw RuntimeException("Argument $ARG_PAGE is mandatory") + } + + private fun loadHeaderData() { + binding.filename.text = note.title + binding.favorite.setImageResource( + if (note.favorite) R.drawable.ic_star_yellow_24dp + else R.drawable.ic_star_border_grey_ccc_24dp + ) + binding.lastModificationTimestamp.text = note.modified?.let { + DateUtils.formatDateTime( + this, + it.timeInMillis, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME + ) + } + } + + private fun loadFragments() { + val pages = listOf( + PageTab( + PAGE.ACTIVITIES, + getString(R.string.activities_title), + AppCompatResources.getDrawable(this, R.drawable.nav_activity), + ActivitiesFragment.newInstance(note, account) + ), + PageTab( + PAGE.SHARE, + getString(R.string.share), + AppCompatResources.getDrawable(this, R.drawable.ic_share_white_24dp), + NoteShareFragment.newInstance(note, account) + ) + ) + binding.pager.adapter = PagerAdapter(pages) + + // Select page + binding.pager.currentItem = pages.indexOf(pages.find { it.page == page }) + + // Set tabs + TabLayoutMediator(binding.tabLayout, binding.pager) { tab, position -> + tab.text = pages[position].title + tab.icon = pages[position].icon + }.attach() + } + + override fun applyBrand(color: Int) { + val util = BrandingUtil.of(color, this) + util.material.themeToolbar(binding.toolbar) + util.platform.colorViewBackground(window.decorView) + util.platform.colorViewBackground(binding.getRoot()) + util.platform.colorViewBackground(binding.tabLayout) + util.material.themeTabLayout(binding.tabLayout) + } + + private data class PageTab( + val page: PAGE, + val title: String, + val icon: Drawable?, + val fragment: BrandedFragment + ) + + private inner class PagerAdapter(val fragments: List) : + FragmentStateAdapter(this@ItemDetailsActivity) + { + override fun getItemCount() = fragments.size + override fun createFragment(position: Int) = fragments[position].fragment + } + + public enum class PAGE { ACTIVITIES, SHARE } + + companion object { + const val ARG_NOTE: String = "note" + const val ARG_ACCOUNT: String = "account" + const val ARG_PAGE: String = "page" + + @JvmStatic + fun getActivityStartIntent(context: Context, note: Note, account: Account): Intent = + getActivityStartIntent(context, note, account, PAGE.ACTIVITIES) + + @JvmStatic + fun getActivityStartIntent(context: Context, note: Note, account: Account, page: PAGE): Intent { + val bundle = Bundle() + bundle.putSerializable(ARG_NOTE, note) + bundle.putSerializable(ARG_ACCOUNT, account) + bundle.putSerializable(ARG_PAGE, page) + + val intent = Intent(context, ItemDetailsActivity::class.java) + intent.putExtras(bundle) + return intent + } + } + +} 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 2f8fe3855..11c2b4673 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 @@ -101,6 +101,7 @@ import it.niedermann.owncloud.notes.exception.ExceptionDialogFragment; import it.niedermann.owncloud.notes.exception.IntendedOfflineException; import it.niedermann.owncloud.notes.importaccount.ImportAccountActivity; +import it.niedermann.owncloud.notes.itemactions.ItemActionBottomSheet; import it.niedermann.owncloud.notes.main.items.ItemAdapter; import it.niedermann.owncloud.notes.main.items.grid.GridItemDecoration; import it.niedermann.owncloud.notes.main.items.list.NotesListViewItemTouchHelper; @@ -115,7 +116,6 @@ 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.NoteShareActivity; import it.niedermann.owncloud.notes.shared.model.CategorySortingMethod; import it.niedermann.owncloud.notes.shared.model.IResponseCallback; import it.niedermann.owncloud.notes.shared.model.NavigationCategory; @@ -636,6 +636,7 @@ public void onSelectionChanged() { if (tracker.hasSelection()) { int selected = tracker.getSelection().size(); mActionMode.setTitle(getResources().getQuantityString(R.plurals.ab_selected, selected, selected)); + mActionMode.invalidate(); } else { mActionMode.finish(); mActionMode = null; @@ -717,7 +718,7 @@ public void applyBrand(int color) { util.platform.colorNavigationView(binding.navigationView); util.material.themeFAB(activityBinding.fabCreate); util.notes.themeSearchCardView(binding.activityNotesListView.searchBar.searchBarCardWrapper); - util.platform.colorViewBackground(getWindow().getDecorView(), ColorRole.SURFACE_CONTAINER); + if (mActionMode == null) util.platform.colorViewBackground(getWindow().getDecorView(), ColorRole.SURFACE_CONTAINER); util.platform.colorViewBackground(binding.getRoot(), ColorRole.SURFACE_CONTAINER); util.platform.colorViewBackground(binding.activityNotesListView.activityNotesListView, ColorRole.SURFACE_CONTAINER); util.platform.colorTextView(binding.activityNotesListView.searchBar.searchText, ColorRole.ON_SURFACE_VARIANT); @@ -916,12 +917,14 @@ public void onNoteFavoriteClick(int position, View view) { } @Override - public void openShare(int position) { + public void openInfoBottomSheet(int position) { if (adapter.getItem(position) instanceof Note note) { - mainViewModel.getCurrentAccount().observe(this, (account) -> { - final Intent intent = NoteShareActivity.getActivityStartIntent(note, account, this); - startActivity(intent); - }); + Account account = mainViewModel.getCurrentAccount().getValue(); + if (account == null) return; + + final var fragment = new ItemActionBottomSheet(note, account); + fragment.show(getSupportFragmentManager(), ItemActionBottomSheet.TAG); + } } 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 a80ac4325..6af623a80 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 @@ -8,7 +8,8 @@ import android.content.Context; import android.content.Intent; -import android.os.Bundle; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; import android.util.TypedValue; import android.view.Menu; import android.view.MenuItem; @@ -18,6 +19,7 @@ import androidx.annotation.NonNull; import androidx.appcompat.view.ActionMode; import androidx.appcompat.view.ActionMode.Callback; +import androidx.core.content.ContextCompat; import androidx.core.graphics.drawable.DrawableCompat; import androidx.fragment.app.FragmentManager; import androidx.lifecycle.LifecycleOwner; @@ -33,7 +35,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.itemdetails.ItemDetailsActivity; import it.niedermann.owncloud.notes.shared.util.ShareUtil; public class MultiSelectedActionModeCallback implements Callback { @@ -58,6 +60,7 @@ public class MultiSelectedActionModeCallback implements Callback { private final FragmentManager fragmentManager; @NonNull private final MainActivity mainActivity; + private Drawable systemBarsBackground; public MultiSelectedActionModeCallback( @NonNull MainActivity mainActivity, @@ -86,6 +89,9 @@ public MultiSelectedActionModeCallback( @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { + final var decorView = mainActivity.getWindow().getDecorView(); + systemBarsBackground = decorView.getBackground(); + decorView.setBackground(new ColorDrawable(ContextCompat.getColor(context, R.color.action_mode_background))); // inflate contextual menu mode.getMenuInflater().inflate(R.menu.menu_list_context_multiple, menu); menu.findItem(R.id.menu_move).setVisible(canMoveNoteToAnotherAccounts); @@ -102,7 +108,9 @@ public boolean onCreateActionMode(ActionMode mode, Menu menu) { @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - return false; + // Info menu makes sense only for single selection + menu.findItem(R.id.menu_details).setVisible(tracker.getSelection().size() == 1); + return true; } /** @@ -159,23 +167,12 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { selection.add(sel); } tracker.clearSelection(); - - 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)); - final Intent intent = NoteShareActivity.getActivityStartIntent(note, account, mainActivity); - mainActivity.startActivity(intent); - }}); - }); - } else { - ShareUtil.openShareDialog(context, + executor.submit(() -> { + final var noteContents = mainViewModel.collectNoteContents(selection); + mainActivity.runOnUiThread(() -> ShareUtil.openShareDialog(context, context.getResources().getQuantityString(R.plurals.share_multiple, selection.size(), selection.size()), - mainViewModel.collectNoteContents(selection)); - } - + noteContents)); + }); 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(); @@ -186,12 +183,31 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { .show(fragmentManager, CategoryDialogFragment.class.getSimpleName()); }); return true; + } else if (itemId == R.id.menu_details) { + final var selection = new ArrayList(tracker.getSelection().size()); + for (final var sel : tracker.getSelection()) { + selection.add(sel); + } + tracker.clearSelection(); + + 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)); + Intent detailsIntent = ItemDetailsActivity.getActivityStartIntent(context, note, account); + mainActivity.startActivity(detailsIntent); + }}); + }); + } } return false; } @Override public void onDestroyActionMode(ActionMode mode) { + mainActivity.getWindow().getDecorView().setBackground(systemBarsBackground); if (mode != null) { mode.finish(); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/NoteViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/NoteViewHolder.java index 706c5bd26..6a93fb581 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/items/NoteViewHolder.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/NoteViewHolder.java @@ -114,16 +114,11 @@ protected void bindSearchableContent(@NonNull Context context, @NonNull TextView } } - protected void bindNoteSharedIcon(@NonNull Context context, boolean shared, @NonNull MaterialButton noteShareIcon, int color) { - if (shared) { - noteShareIcon.setVisibility(VISIBLE); - noteShareIcon.setOnClickListener(view -> noteClickListener.openShare(getLayoutPosition())); - } else { - noteShareIcon.setVisibility(GONE); - } + protected void bindNoteDetailsIcon(@NonNull Context context, @NonNull MaterialButton noteDetailsIcon, int color) { + noteDetailsIcon.setOnClickListener(view -> noteClickListener.openInfoBottomSheet(getLayoutPosition())); final var util = BrandingUtil.of(color, context); - util.material.colorMaterialButtonContent(noteShareIcon, ColorRole.PRIMARY); + util.material.colorMaterialButtonContent(noteDetailsIcon, ColorRole.SECONDARY); } public abstract void showSwipe(float dX); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolder.java index 67c5c53d9..0f5505e74 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolder.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolder.java @@ -55,7 +55,7 @@ public void bind(boolean isSelected, @NonNull Note note, boolean showCategory, @ bindModified(binding.noteModified, note.getModified()); bindSearchableContent(context, binding.noteTitle, searchQuery, note.getTitle(), color); bindSearchableContent(context, binding.noteExcerpt, searchQuery, note.getExcerpt().replace(EXCERPT_LINE_SEPARATOR, "\n"), color); - bindNoteSharedIcon(context, note.isShared(), binding.noteShared, color); + bindNoteDetailsIcon(context, binding.noteDetails, color); binding.noteExcerpt.setVisibility(TextUtils.isEmpty(note.getExcerpt()) ? GONE : VISIBLE); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolderOnlyTitle.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolderOnlyTitle.java index 0699dd237..597b86d22 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolderOnlyTitle.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/grid/NoteViewGridHolderOnlyTitle.java @@ -46,7 +46,7 @@ public void bind(boolean isSelected, @NonNull Note note, boolean showCategory, i bindFavorite(binding.noteFavorite, note.getFavorite()); bindModified(binding.noteModified, note.getModified()); bindSearchableContent(context, binding.noteTitle, searchQuery, note.getTitle(), color); - bindNoteSharedIcon(context, note.isShared(), binding.noteShared, color); + bindNoteDetailsIcon(context, binding.noteDetails, color); } @Nullable diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewListHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewListHolder.java index 771db1737..f67ccb469 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewListHolder.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/items/list/NoteViewListHolder.java @@ -74,7 +74,7 @@ public void bind(boolean isSelected, @NonNull Note note, boolean showCategory, @ bindSearchableContent(context, binding.noteExcerpt, searchQuery, note.getExcerpt(), color); } - bindNoteSharedIcon(context, note.isShared(), binding.noteShared, color); + bindNoteDetailsIcon(context, binding.noteDetails, color); } @NonNull 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 8143a9a9e..d7cd26067 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 @@ -26,12 +26,14 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import it.niedermann.owncloud.notes.persistence.sync.ActivitiesAPI; import it.niedermann.owncloud.notes.persistence.sync.CapabilitiesDeserializer; 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.persistence.sync.UserStatusAPI; +import it.niedermann.owncloud.notes.persistence.sync.VersionsAPI; import it.niedermann.owncloud.notes.shared.model.ApiVersion; import it.niedermann.owncloud.notes.shared.model.Capabilities; import retrofit2.NextcloudRetrofitApiBuilder; @@ -52,6 +54,8 @@ public class ApiProvider { private static final String API_USER_STATUS = "/ocs/v2.php/apps/user_status/api/v1/"; 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 String API_ENDPOINT_ACTIVITY = "/ocs/v2.php/apps/activity/api/v2/"; + private static final String API_ENDPOINT_DAV = "/remote.php/dav/"; private static final Map API_CACHE = new ConcurrentHashMap<>(); @@ -60,6 +64,8 @@ public class ApiProvider { 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<>(); + private static final Map API_CACHE_ACTIVITIES = new ConcurrentHashMap<>(); + private static final Map API_CACHE_VERSIONS = new ConcurrentHashMap<>(); public static ApiProvider getInstance() { @@ -145,6 +151,24 @@ private synchronized NextcloudAPI getNextcloudAPI(@NonNull Context context, @Non } } + public synchronized ActivitiesAPI getActivitiesAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { + if (API_CACHE_ACTIVITIES.containsKey(ssoAccount.name)) { + return API_CACHE_ACTIVITIES.get(ssoAccount.name); + } + final var api = new NextcloudRetrofitApiBuilder(getNextcloudAPI(context, ssoAccount), API_ENDPOINT_ACTIVITY).create(ActivitiesAPI.class); + API_CACHE_ACTIVITIES.put(ssoAccount.name, api); + return api; + } + + public synchronized VersionsAPI getVersionsAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { + if (API_CACHE_VERSIONS.containsKey(ssoAccount.name)) { + return API_CACHE_VERSIONS.get(ssoAccount.name); + } + final var api = new VersionsAPI(getNextcloudAPI(context, ssoAccount), ssoAccount, API_ENDPOINT_DAV); + API_CACHE_VERSIONS.put(ssoAccount.name, api); + return api; + } + /** * Invalidates the API cache for the given {@param ssoAccount} * @@ -163,6 +187,8 @@ public synchronized void invalidateAPICache(@NonNull SingleSignOnAccount ssoAcco API_CACHE_OCS.remove(ssoAccount.name); API_CACHE_USER_STATUS.remove(ssoAccount.name); API_CACHE_FILES_SHARING.remove(ssoAccount.name); + API_CACHE_ACTIVITIES.remove(ssoAccount.name); + API_CACHE_VERSIONS.remove(ssoAccount.name); } /** @@ -183,5 +209,7 @@ public synchronized void invalidateAPICache() { API_CACHE_OCS.clear(); API_CACHE_USER_STATUS.clear(); API_CACHE_FILES_SHARING.clear(); + API_CACHE_ACTIVITIES.clear(); + API_CACHE_VERSIONS.clear(); } } 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 index 759d1f7fa..e0c270f4b 100644 --- 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 @@ -10,6 +10,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +@Suppress("ConstructorParameterNaming") @Entity(tableName = "share_table") data class ShareEntity( @PrimaryKey(autoGenerate = true) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ActivitiesAPI.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ActivitiesAPI.kt new file mode 100644 index 000000000..4c47d8202 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ActivitiesAPI.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.persistence.sync + +import it.niedermann.owncloud.notes.activities.adapter.model.Activity +import it.niedermann.owncloud.notes.shared.model.OcsResponse +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Query + +interface ActivitiesAPI { + @GET("activity/filter?format=json&object_type=files&sort=desc") + fun fetchActivities( + @Query("object_id") fileId: Long, + @Query("since") since: Long?, + @Query("limit") limit: Int + ): Call>> +} 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 index c6a15e80e..98ffb02ec 100644 --- 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 @@ -47,7 +47,10 @@ interface ShareAPI { fun addShare(@Body request: CreateShareRequest): Call> @PUT("shares/{shareId}?format=json") - fun updateShare(@Path("shareId") shareId: Long, @Body request: UpdateShareRequest): Call> + fun updateShare( + @Path("shareId") shareId: Long, + @Body request: UpdateShareRequest + ): Call> @PUT("shares/{shareId}?format=json") fun updateSharePermission( diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/VersionsAPI.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/VersionsAPI.kt new file mode 100644 index 000000000..bed0e5984 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/VersionsAPI.kt @@ -0,0 +1,119 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.persistence.sync + +import android.net.Uri +import android.util.Log +import com.nextcloud.android.sso.aidl.NextcloudRequest +import com.nextcloud.android.sso.api.NextcloudAPI +import com.nextcloud.android.sso.api.Response +import com.nextcloud.android.sso.exceptions.NextcloudUnsupportedMethodException +import com.nextcloud.android.sso.model.SingleSignOnAccount +import com.owncloud.android.lib.common.network.WebdavEntry +import com.owncloud.android.lib.common.network.WebdavUtils +import com.owncloud.android.lib.resources.files.model.FileVersion +import it.niedermann.owncloud.notes.activities.repository.model.RestoreVersionsResult +import it.niedermann.owncloud.notes.activities.repository.model.VersionsResult +import org.apache.jackrabbit.webdav.DavConstants +import org.apache.jackrabbit.webdav.MultiStatus +import org.apache.jackrabbit.webdav.client.methods.PropFindMethod +import org.apache.jackrabbit.webdav.xml.DomUtil +import java.io.ByteArrayOutputStream + + +class VersionsAPI ( + private val nextcloudAPI: NextcloudAPI, + private val ssoAccount: SingleSignOnAccount, + private val davEndpoint: String +) { + fun fetchVersions(fileId: Long) : VersionsResult { + val uri = "${getDavPath()}versions/${ssoAccount.userId}/versions/$fileId" + val propSet = WebdavUtils.getFileVersionPropSet() + + val query = PropFindMethod(uri, propSet, DavConstants.DEPTH_1) + + // We manually build the request to pass it to nextcloudAPI (required for authentication) + val requestBodyOutStr = ByteArrayOutputStream() + query.requestEntity.writeRequest(requestBodyOutStr) + val requestBody = requestBodyOutStr.toString() + val request = NextcloudRequest.Builder() + .setMethod("PROPFIND") + .setUrl(uri) + .setRequestBody(requestBody) + .build() + + return try { + val response = nextcloudAPI.performNetworkRequestV2(request) + response.body.use { VersionsResult.Success(readData(response, fileId)) } + } catch (e: Exception) { + Log.e(TAG, "Unable to fetch versions: $e") + VersionsResult.Failed + } + } + + fun restoreVersion(version: FileVersion): RestoreVersionsResult { + // Version restore is a move from the versions file path to the original one + val versionsPath = "${getDavPath()}versions/${ssoAccount.userId}" + val source = "$versionsPath/versions/${version.localId}/${Uri.encode(version.fileName)}" + val destination = "$versionsPath/restore/${version.localId}" + val request = NextcloudRequest.Builder() + .setMethod("MOVE") + .setUrl(source) + .setAdditionalParameter(NextcloudRequest.ADDITIONAL_PARAMETER_MOVE_DESTINATION, destination) + .setAdditionalParameter(NextcloudRequest.ADDITIONAL_PARAMETER_MOVE_OVERWRITE, true.toString()) + .build() + + try { + val response = nextcloudAPI.performNetworkRequestV2(request) + Log.i(TAG, response.toString()) + response.body.close() + return RestoreVersionsResult.Success + } catch (e: NextcloudUnsupportedMethodException) { + Log.e(TAG, "restoreVersion used an unsupported method: ${e.message}") + return RestoreVersionsResult.FailedUnsupported + } catch (e: Exception) { + Log.e(TAG, "restoreVersion failed: ${e.message}") + return RestoreVersionsResult.Failed + } + } + + /** + * Read the data retrieved from the server about the file versions. + * + * @param remoteData Full response got from the server with the version data. + * @param fileId File id the versions refer to + */ + private fun readData(response: Response, fileId: Long): List { + // Use jackrabbit tp parse a webdav MultiStatus from response + val ms = MultiStatus.createFromXml(DomUtil.parseDocument(response.body).documentElement) + val responses = ms.responses + // Each response is a version + val versions = arrayListOf() + + // parse data from remote folder + val splitElement = getDavPath() + + // loop to update every child + for (resp in responses) { + versions.add( + FileVersion( + fileId, + WebdavEntry(resp, splitElement) + ) + ) + } + return versions + } + + private fun getDavPath(): String { + return davEndpoint + } + + companion object { + val TAG = "VersionsAPI" + } +} 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 deleted file mode 100644 index a69cc8c0c..000000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareActivity.java +++ /dev/null @@ -1,903 +0,0 @@ -/* - * Nextcloud Notes - Android Client - * - * SPDX-FileCopyrightText: 2015-2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ -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 android.view.inputmethod.InputMethodManager; - -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.common.ui.theme.utils.ColorRole; -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.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -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.UpdateShareRequest; -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; -import it.niedermann.owncloud.notes.util.DateUtil; - -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 static Intent getActivityStartIntent(@NonNull Note note, @NonNull Account account, @NonNull Activity activity) { - Bundle bundle = new Bundle(); - bundle.putSerializable(NoteShareActivity.ARG_NOTE, note); - bundle.putSerializable(NoteShareActivity.ARG_ACCOUNT, account); - - Intent intent = new Intent(activity, NoteShareActivity.class); - intent.putExtras(bundle); - return intent; - } - - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - executorService = Executors.newSingleThreadScheduledExecutor(); - binding = ActivityNoteShareBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - setSupportActionBar(binding.toolbar); - binding.toolbar.setNavigationOnClickListener(v -> backPressed()); - 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() { - backPressed(); - } - }); - } - - private void backPressed() { - 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.fetchSharesForNotesAndSaveShareEntities(); - - runOnUiThread(() -> { - binding.fileName.setText(note.getTitle()); - 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) { - Log_OC.e(TAG, "Exception at NoteShareActivity.init: " + 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_CLASS_TEXT); - binding.searchView.setFocusable(true); - binding.searchView.setFocusableInTouchMode(true); - - 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.setOnQueryTextFocusChangeListener((v, hasFocus) -> { - if (hasFocus) { - InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - imm.showSoftInput(binding.searchView.findFocus(), InputMethodManager.SHOW_IMPLICIT); - } - }); - - 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, ""); - 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 showShareExpirationSnackbar(OCShare share) { - String expirationDescription = getString( - R.string.share_expires, - SimpleDateFormat.getDateInstance().format(new Date(share.getExpirationDate())) - ); - Snackbar snackbar = Snackbar - .make(binding.getRoot(), expirationDescription, Snackbar.LENGTH_LONG) - .setAction(R.string.dismiss, view -> { - // Clicking on empty action will dismiss snackbar - }); - final var util = BrandingUtil.of(BrandingUtil.readBrandMainColor(this), this); - util.material.themeSnackbar(snackbar); - snackbar.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 remoteNotes; - if (note != null) { - remoteNotes = repository.fetchSharesFromNote(note); - } else { - remoteNotes = new ArrayList<>(); - } - - runOnUiThread(() -> { - shares.clear(); - shares.addAll(remoteNotes); - - adapter.removeAll(); - adapter.addShares(shares); - addPublicShares(adapter); - setShareWithYou(); - - isInitializingShares = false; - }); - } catch (Exception e) { - Log_OC.d(TAG, "Exception while updateShareeListAdapter: " + e); - } - }); - } - - 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, false); - } - - - @Override - public void sendNewEmail(OCShare share) { - modifyExistingShare(share, NoteShareDetailActivity.SCREEN_TYPE_NOTE, true); - } - - @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, boolean sendEmail) { - 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()); - bundle.putBoolean(NoteShareDetailActivity.ARG_SEND_EMAIL, sendEmail); - - 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) { - if (note == null) { - Log_OC.e(TAG, "note is null, cannot update share"); - return; - } - - executorService.submit(() -> { - try { - final var updatedShares = repository.fetchSharesFromNote(note); - - runOnUiThread(() -> { - if (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.material.themeToolbar(binding.toolbar); - util.notes.themeContentSearchView(binding.searchView); - util.platform.colorImageView(binding.searchViewIcon, ColorRole.ON_SURFACE_VARIANT); - util.platform.colorImageView(binding.pickContactEmailBtn, ColorRole.ON_SURFACE_VARIANT); - util.platform.colorCircularProgressBar(binding.loadingLayoutIndicator, ColorRole.PRIMARY); - util.platform.themeHorizontalProgressBar(binding.progressBar); - util.platform.colorViewBackground(getWindow().getDecorView()); - util.platform.colorViewBackground(binding.getRoot()); - util.material.colorMaterialButtonPrimaryOutlined(binding.btnShareButton); - util.platform.colorTextView(binding.title, ColorRole.ON_SURFACE); - util.platform.colorTextView(binding.fileName, ColorRole.ON_SURFACE_VARIANT); - util.notes.themeSearchCardView(binding.searchCardWrapper); - } - - @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(() -> { - { - boolean isDownloadAndAllowsSyncEnabled = repository.isAllowDownloadAndSync(share); - - String attributes = UpdateShareRequest.Companion.createAttributes( - repository.getCapabilities(), - isDownloadAndAllowsSyncEnabled, - share.getShareType() - ); - - final var requestBody = new UpdateShareRequest( - share.getPermissions(), - password, - share.getNote(), - share.getLabel(), - DateUtil.INSTANCE.getExpirationDate(share.getExpirationDate()), - Boolean.toString(share.isHideFileDownload()), - attributes - ); - 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 index 3e5a6ceb2..0f25393b6 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareDetailActivity.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareDetailActivity.kt @@ -626,6 +626,7 @@ class NoteShareDetailActivity : } } + @Suppress("ForbiddenComment") private suspend fun createShare(password: String) { if (note == null || shareeName == null) { Log_OC.d(TAG, "validateShareProcessSecond cancelled") diff --git a/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareFragment.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareFragment.kt new file mode 100644 index 000000000..706f0193a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareFragment.kt @@ -0,0 +1,903 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.share + +import android.Manifest +import android.app.Activity +import android.app.Activity.RESULT_OK +import android.app.SearchManager +import android.content.Context.INPUT_METHOD_SERVICE +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.provider.ContactsContract +import android.text.InputType +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.VisibleForTesting +import androidx.appcompat.widget.SearchView +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.core.view.isVisible +import androidx.core.view.size +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.android.common.ui.theme.utils.ColorRole +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 it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.branding.BrandedFragment +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.entity.Account +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.persistence.isError +import it.niedermann.owncloud.notes.persistence.isSuccess +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.QuickSharingPermissionsBottomSheetDialog.QuickPermissionSharingBottomSheetActions +import it.niedermann.owncloud.notes.share.dialog.ShareLinkToDialog.Companion.newInstance +import it.niedermann.owncloud.notes.share.dialog.SharePasswordDialogFragment +import it.niedermann.owncloud.notes.share.dialog.SharePasswordDialogFragment.Companion.newInstance +import it.niedermann.owncloud.notes.share.dialog.SharePasswordDialogFragment.SharePasswordDialogListener +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.UpdateShareRequest +import it.niedermann.owncloud.notes.share.model.UpdateShareRequest.Companion.createAttributes +import it.niedermann.owncloud.notes.share.model.UsersAndGroupsSearchConfig.reset +import it.niedermann.owncloud.notes.share.model.UsersAndGroupsSearchConfig.searchOnlyUsers +import it.niedermann.owncloud.notes.share.model.toOCShare +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.copyToClipboard +import it.niedermann.owncloud.notes.shared.util.extensions.getSerializableArgument +import it.niedermann.owncloud.notes.util.DateUtil.getExpirationDate +import java.text.SimpleDateFormat +import java.util.Collections +import java.util.Date +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import kotlin.text.toInt + +@Suppress("LargeClass") +class NoteShareFragment : BrandedFragment(), ShareeListAdapterListener, NoteShareItemAction, + QuickPermissionSharingBottomSheetActions, SharePasswordDialogListener { + private var executorService: ScheduledExecutorService? = null + private var future: Future<*>? = null + private var binding: ActivityNoteShareBinding? = null + private var note: Note? = null + private var account: Account? = null + private var repository: ShareRepository? = null + private var capabilities: Capabilities? = null + private val resultLauncher: ActivityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == RESULT_OK && it.data != null) { + updateShareeListAdapter() + } + } + + private val shares: MutableList = + Collections.synchronizedList(ArrayList()) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + executorService = Executors.newSingleThreadScheduledExecutor() + binding = ActivityNoteShareBinding.inflate(layoutInflater) + initializeArguments() + return binding!!.getRoot() + } + + private fun initializeArguments() { + val bundler = arguments + note = bundler.getSerializableArgument(ARG_NOTE, Note::class.java) + account = bundler.getSerializableArgument(ARG_ACCOUNT, Account::class.java) + + requireNotNull(note) { "Note cannot be null" } + + requireNotNull(account) { "Account cannot be null" } + + executorService!!.submit { + try { + val context = context ?: return@submit + val ssoAcc = + SingleAccountHelper.getCurrentSingleSignOnAccount(context) + repository = ShareRepository(context, ssoAcc) + capabilities = repository!!.getCapabilities() + repository!!.fetchSharesForNotesAndSaveShareEntities() + + activity?.runOnUiThread { + binding!!.searchContainer.visibility = View.VISIBLE + binding!!.sharesList.visibility = View.VISIBLE + binding!!.sharesList.setAdapter( + ShareeListAdapter( + activity, + ArrayList(), + this, + account + ) + ) + binding!!.sharesList.setLayoutManager(LinearLayoutManager(context)) + binding!!.pickContactEmailBtn.setOnClickListener { checkContactPermission() } + binding!!.btnShareButton.setOnClickListener { + ShareUtil.openShareDialog( + context, + note!!.title, + note!!.content + ) + } + + if (note!!.readonly) { + setupReadOnlySearchView() + } else { + setupSearchView() + } + + updateShareeListAdapter() + binding!!.loadingLayout.visibility = View.GONE + } + } catch (e: Exception) { + Log_OC.e(TAG, "Exception at NoteShareActivity.init: $e") + } + } + } + + public override fun onStart() { + super.onStart() + + // note cannot be encrypted - logic ported from files app + searchOnlyUsers = false + } + + public override fun onStop() { + super.onStop() + reset() + } + + private fun disableSearchView(view: View) { + view.setEnabled(false) + + if (view is ViewGroup) { + for (i in 0..(androidx.appcompat.R.id.search_close_btn) + closeButton.setOnClickListener { + binding!!.progressBar.visibility = View.GONE + binding!!.searchView.setQuery("", false) + } + + binding!!.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + // return true to prevent the query from being processed; + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + if (newText.isNotEmpty()) { + binding!!.progressBar.visibility = 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(Runnable schedule@{ + if (capabilities == null) { + Log_OC.d(TAG, "Capabilities cannot be null") + return@schedule + } + val isFederationShareAllowed = capabilities!!.federationShare + try { + val cursor = + provider.searchForUsersOrGroups(newText, isFederationShareAllowed) + + if (cursor == null || cursor.count == 0) { + return@schedule + } + + activity?.runOnUiThread { + if (binding!!.searchView.isVisible) { + suggestionAdapter.swapCursor(cursor) + } + binding!!.progressBar.visibility = View.GONE + } + } catch (e: Exception) { + Log_OC.d(TAG, "Exception setupSearchView.onQueryTextChange: $e") + activity?.runOnUiThread { binding!!.progressBar.visibility = View.GONE } + } + }, SEARCH_DELAY_MS, TimeUnit.MILLISECONDS) + } else { + binding!!.progressBar.visibility = View.GONE + suggestionAdapter.swapCursor(null) + } + return false + } + }) + + binding!!.searchView.setOnQueryTextFocusChangeListener { _: View?, hasFocus: Boolean -> + if (hasFocus) { + val imm = context?.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput( + binding!!.searchView.findFocus(), + InputMethodManager.SHOW_IMPLICIT + ) + } + } + + binding!!.searchView.setOnSuggestionListener(object : SearchView.OnSuggestionListener { + override fun onSuggestionSelect(position: Int): Boolean { + return false + } + + override fun onSuggestionClick(position: Int): Boolean { + val cursor = suggestionAdapter.cursor + if (cursor != null && cursor.moveToPosition(position)) { + val suggestion = + cursor.getString(cursor.getColumnIndexOrThrow(SearchManager.SUGGEST_COLUMN_TEXT_1)) + binding!!.searchView.setQuery(suggestion, false) + + val shareWith = + cursor.getString(cursor.getColumnIndexOrThrow(UsersAndGroupsSearchProvider.SHARE_WITH)) + val shareType = + cursor.getInt(cursor.getColumnIndexOrThrow(UsersAndGroupsSearchProvider.SHARE_TYPE)) + navigateNoteShareDetail(shareWith, shareType) + } + return true + } + }) + } + + private fun navigateNoteShareDetail(shareWith: String?, shareType: Int) { + val bundle = Bundle() + + bundle.putSerializable(NoteShareDetailActivity.ARG_NOTE, note) + bundle.putString(NoteShareDetailActivity.ARG_SHAREE_NAME, shareWith) + bundle.putInt(NoteShareDetailActivity.ARG_SHARE_TYPE, shareType) + + val intent = Intent(context, NoteShareDetailActivity::class.java) + intent.putExtras(bundle) + resultLauncher.launch(intent) + } + + private fun accountOwnsFile(): Boolean { + if (shares.isEmpty()) { + return true + } + + val share = shares[0] + val ownerDisplayName = share.ownerDisplayName + return TextUtils.isEmpty(ownerDisplayName) || account!!.accountName + .split("@".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[0].equals( + ownerDisplayName, + ignoreCase = true + ) + } + + private fun setShareWithYou() { + if (accountOwnsFile()) { + binding!!.sharedWithYouContainer.visibility = View.GONE + } else { + if (shares.isEmpty()) { + return + } + + val share = shares[0] + + binding!!.sharedWithYouUsername.text = String.format( + getString(R.string.note_share_activity_shared_with_you), + share.ownerDisplayName + ) + context?.apply { AvatarLoader.load(this, binding!!.sharedWithYouAvatar, account!!) } + binding!!.sharedWithYouAvatar.setVisibility(View.VISIBLE) + + val description = share.note + + if (!TextUtils.isEmpty(description)) { + binding!!.sharedWithYouNote.setText(description) + binding!!.sharedWithYouNoteContainer.visibility = View.VISIBLE + } else { + binding!!.sharedWithYouNoteContainer.visibility = View.GONE + } + } + } + + override fun copyInternalLink() { + if (account == null) { + DisplayUtils.showSnackMessage( + view, + getString(R.string.note_share_activity_could_not_retrieve_url) + ) + return + } + + val link = createInternalLink() + showShareLinkDialog(link) + } + + override fun createPublicShareLink() { + if (capabilities == null) { + Log_OC.d(TAG, "Capabilities cannot be null") + return + } + + if (capabilities!!.publicPasswordEnforced || capabilities!!.askForOptionalPassword) { + // password enforced by server, request to the user before trying to create + requestPasswordForShareViaLink(true, capabilities!!.askForOptionalPassword) + } else { + executorService!!.submit { + val result: ApiResult?> = + repository!!.addShare(note!!, ShareType.PUBLIC_LINK, "") + activity?.runOnUiThread { + if (result is ApiResult.Success?> && + binding!!.sharesList.adapter is ShareeListAdapter + ) { + DisplayUtils.showSnackMessage(view, result.message) + note!!.setIsShared(true) + repository!!.updateNote(note!!) + val adapter = binding!!.sharesList.adapter as ShareeListAdapter + adapter.addShare(result.data!!.ocs.data!!.toOCShare()) + } else if (result is ApiResult.Error) { + DisplayUtils.showSnackMessage(view, result.message) + } + } + } + } + } + + fun requestPasswordForShareViaLink(createShare: Boolean, askForPassword: Boolean) { + val dialog = newInstance(note, createShare, askForPassword, this) + dialog.show(parentFragmentManager, SharePasswordDialogFragment.PASSWORD_FRAGMENT) + } + + override fun createSecureFileDrop() = Unit + + private fun showShareLinkDialog(link: String?) { + val intentToShareLink = 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!!.title) + ) + + val chooserDialog: DialogFragment = newInstance(intentToShareLink, context?.packageName) + chooserDialog.show(parentFragmentManager, FTAG_CHOOSER_DIALOG) + } + + private fun createInternalLink(): String { + val baseUri = account!!.url.toUri() + return baseUri.toString() + "/index.php/f/" + note!!.remoteId + } + + override fun copyLink(share: OCShare) { + if (!note!!.isShared) { + return + } + + if (TextUtils.isEmpty(share.shareLink)) { + copyAndShareFileLink(share.shareLink) + } else { + context?.apply { copyToClipboard(this, share.shareLink) } + } + } + + private fun copyAndShareFileLink(link: String?) { + context?.apply { copyToClipboard(this, link, false) } + val snackbar = Snackbar + .make( + requireView(), + R.string.clipboard_text_copied, + Snackbar.LENGTH_LONG + ) + .setAction( + R.string.share + ) { showShareLinkDialog(link) } + snackbar.show() + } + + override fun showSharingMenuActionSheet(share: OCShare?) { + NoteShareActivityShareItemActionBottomSheetDialog(activity, this, share).show() + } + + override fun showShareExpirationSnackbar(share: OCShare) { + val expirationDescription = getString( + R.string.share_expires, + SimpleDateFormat.getDateInstance().format(Date(share.expirationDate)) + ) + val snackbar = Snackbar + .make(binding!!.getRoot(), expirationDescription, Snackbar.LENGTH_LONG) + .setAction(R.string.dismiss) { } + context?.apply { + val util = BrandingUtil.of(BrandingUtil.readBrandMainColor(this), this) + util.material.themeSnackbar(snackbar) + } + snackbar.show() + } + + override fun showPermissionsDialog(share: OCShare?) { + QuickSharingPermissionsBottomSheetDialog(activity, this, share).show() + } + + override fun requestPasswordForShare(share: OCShare?, askForPassword: Boolean) { + val dialog = newInstance(share, askForPassword, this) + dialog.show(parentFragmentManager, SharePasswordDialogFragment.PASSWORD_FRAGMENT) + } + + override fun showProfileBottomSheet(account: Account?, shareWith: String?) = Unit + + private var isInitializingShares = false + + fun updateShareeListAdapter() { + if (isInitializingShares) { + Log_OC.d(TAG, "Shares initialization already in progress") + return + } + + isInitializingShares = true + + executorService!!.submit(Runnable submit@{ + try { + val adapter = binding!!.sharesList.adapter as ShareeListAdapter? + + if (adapter == null) { + activity?.runOnUiThread { + DisplayUtils.showSnackMessage( + view, + getString(R.string.could_not_retrieve_shares) + ) + } + isInitializingShares = false + return@submit + } + val remoteNotes: List = if (note != null) { + repository!!.fetchSharesFromNote(note!!) + } else { + listOf() + } + + activity?.runOnUiThread { + shares.clear() + shares.addAll(remoteNotes) + + adapter.removeAll() + adapter.addShares(shares) + addPublicShares(adapter) + setShareWithYou() + isInitializingShares = false + } + } catch (e: Exception) { + Log_OC.d(TAG, "Exception while updateShareeListAdapter: $e") + } + }) + } + + private fun addPublicShares(adapter: ShareeListAdapter) { + val publicShares: MutableList = ArrayList() + + if (containsNoNewPublicShare(adapter.shares)) { + val ocShare = OCShare() + ocShare.shareType = ShareType.NEW_PUBLIC_LINK + publicShares.add(ocShare) + } else { + adapter.removeNewPublicShare() + } + + adapter.addShares(publicShares) + } + + private fun checkContactPermission() { + val context = context ?: return + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED + ) { + pickContactEmail() + } else { + requestContactPermissionLauncher.launch(Manifest.permission.READ_CONTACTS) + } + } + + private fun pickContactEmail() { + val context = context ?: return + val intent = Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI) + + if (intent.resolveActivity(context.packageManager) != null) { + onContactSelectionResultLauncher.launch(intent) + } else { + DisplayUtils.showSnackMessage( + view, + getString(R.string.file_detail_sharing_fragment_no_contact_app_message) + ) + } + } + + private fun handleContactResult(contactUri: Uri) { + // Define the projection to get all email addresses. + val projection = arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS) + + val cursor = context?.contentResolver?.query(contactUri, projection, null, null, null) + if (cursor == null) { + DisplayUtils.showSnackMessage(view, 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(view, 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. + val columnIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS) + if (columnIndex == -1) { + DisplayUtils.showSnackMessage(view, 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. + val email = cursor.getString(columnIndex) + binding!!.searchView.post { + binding!!.searchView.setQuery(email, false) + binding!!.searchView.requestFocus() + } + + cursor.close() + } + + private fun containsNoNewPublicShare(shares: MutableList): Boolean { + for (share in shares) { + if (share.shareType != null && share.shareType == ShareType.NEW_PUBLIC_LINK) { + return false + } + } + + return true + } + + private fun isReshareForbidden(share: OCShare): Boolean { + return (share.shareType != null && ShareType.FEDERATED == share.shareType) || + capabilities != null && !capabilities!!.isReSharingAllowed + } + + @VisibleForTesting + fun search(query: String?) { + val searchView = binding?.searchView + searchView?.setQuery(query, true) + } + + override fun advancedPermissions(share: OCShare) { + modifyExistingShare(share, NoteShareDetailActivity.SCREEN_TYPE_PERMISSION, false) + } + + + override fun sendNewEmail(share: OCShare) { + modifyExistingShare(share, NoteShareDetailActivity.SCREEN_TYPE_NOTE, true) + } + + override fun unShare(share: OCShare) { + executorService!!.submit(Runnable { + val result = repository!!.removeShare(share, note!!) + activity?.runOnUiThread(Runnable { + if (result) { + val adapter = binding!!.sharesList.adapter as ShareeListAdapter? + if (adapter == null) { + DisplayUtils.showSnackMessage( + view, + getString(R.string.email_pick_failed) + ) + return@Runnable + } + adapter.remove(share) + } else { + DisplayUtils.showSnackMessage( + view, + getString(R.string.failed_the_remove_share) + ) + } + }) + }) + } + + override fun sendLink(share: OCShare?) = Unit + + override fun addAnotherLink(share: OCShare?) { + createPublicShareLink() + } + + private fun modifyExistingShare(share: OCShare, screenTypePermission: Int, sendEmail: Boolean) { + val bundle = 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, this.expDateShown) + bundle.putBoolean(NoteShareDetailActivity.ARG_SEND_EMAIL, sendEmail) + + val intent = Intent(context, NoteShareDetailActivity::class.java) + intent.putExtras(bundle) + resultLauncher.launch(intent) + } + + private val expDateShown: Boolean + get() { + try { + if (capabilities == null) { + Log_OC.d(TAG, "Capabilities cannot be null") + return false + } + + val majorVersionAsString = capabilities!!.nextcloudMajorVersion + if (majorVersionAsString != null) { + val majorVersion = majorVersionAsString.toInt() + return majorVersion >= EXPIRATION_DATE_MIN_MAJOR_VERSION + } + + return false + } catch (_: NumberFormatException) { + Log_OC.d(TAG, "Exception while getting expDateShown") + return false + } + } + + override fun onQuickPermissionChanged(share: OCShare, permission: Int) { + executorService!!.submit { + val result: ApiResult?> = + repository!!.updateSharePermission(share.id, permission) + activity?.runOnUiThread { + if (result.isSuccess()) { + updateShare(share) + } else if (result is ApiResult.Error) { + DisplayUtils.showSnackMessage(view, result.message) + } + } + } + } + + private fun updateShare(share: OCShare) { + if (note == null) { + Log_OC.e(TAG, "note is null, cannot update share") + return + } + + executorService!!.submit(Runnable { + try { + val updatedShares: List = repository!!.fetchSharesFromNote(note!!) + + activity?.runOnUiThread(Runnable { + if (binding!!.sharesList.adapter is ShareeListAdapter) { + var updatedShare: OCShare? = null + for (i in updatedShares.indices) { + if (updatedShares[i]!!.id == share.id) { + updatedShare = updatedShares[i] + break + } + } + + if (updatedShare == null) { + return@Runnable + } + + val adapter = binding!!.sharesList.adapter as ShareeListAdapter + adapter.updateShare(updatedShare) + } + }) + } catch (e: Exception) { + Log_OC.d(TAG, "Exception while refreshing share info: $e") + } + }) + } + + private val requestContactPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean? -> + if (isGranted == true) { + pickContactEmail() + } else { + BrandedSnackbar.make( + binding!!.getRoot(), + getString(R.string.note_share_activity_contact_no_permission), + Snackbar.LENGTH_LONG + ) + .show() + } + } + + private val onContactSelectionResultLauncher = + registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ActivityResultCallback { result: ActivityResult? -> + if (result!!.resultCode == RESULT_OK) { + val intent = result.data + if (intent == null) { + BrandedSnackbar.make( + binding!!.getRoot(), + getString(R.string.email_pick_failed), + Snackbar.LENGTH_LONG + ) + .show() + return@ActivityResultCallback + } + + val contactUri = intent.data + if (contactUri == null) { + BrandedSnackbar.make( + binding!!.getRoot(), + getString(R.string.email_pick_failed), + Snackbar.LENGTH_LONG + ) + .show() + return@ActivityResultCallback + } + + handleContactResult(contactUri) + } + }) + + override fun applyBrand(color: Int) { + val context = context ?: return + val util = BrandingUtil.of(color, context) + util.notes.themeContentSearchView(binding!!.searchView) + util.platform.colorImageView(binding!!.searchViewIcon, ColorRole.ON_SURFACE_VARIANT) + util.platform.colorImageView(binding!!.pickContactEmailBtn, ColorRole.ON_SURFACE_VARIANT) + util.platform.colorCircularProgressBar(binding!!.loadingLayoutIndicator, ColorRole.PRIMARY) + util.platform.themeHorizontalProgressBar(binding!!.progressBar) + util.platform.colorViewBackground(binding!!.getRoot()) + util.material.colorMaterialButtonPrimaryOutlined(binding!!.btnShareButton) + util.notes.themeSearchCardView(binding!!.searchCardWrapper) + } + + override fun onDestroy() { + executorService!!.shutdown() + super.onDestroy() + } + + override fun shareFileViaPublicShare(note: Note?, password: String?) { + if (note == null || password == null) { + Log_OC.d(TAG, "note or password is null, cannot create a public share") + return + } + + executorService!!.submit { + val result: ApiResult?> = repository!!.addShare( + note, + ShareType.PUBLIC_LINK, + "", + "false", + password, + repository!!.getCapabilities().defaultPermission + ) + activity?.runOnUiThread { + if (result is ApiResult.Success?> && + binding!!.sharesList.adapter is ShareeListAdapter + ) { + val adapter = binding!!.sharesList.adapter as ShareeListAdapter + adapter.addShare(result.data!!.ocs.data!!.toOCShare()) + } else if (result is ApiResult.Error) { + DisplayUtils.showSnackMessage(view, result.message) + } + } + } + } + + override fun setPasswordToShare(share: OCShare, password: String?) { + if (password == null) { + Log_OC.d(TAG, "password is null, cannot update a public share") + return + } + + executorService!!.submit { + run { + val isDownloadAndAllowsSyncEnabled = repository!!.isAllowDownloadAndSync(share) + val attributes = createAttributes( + repository!!.getCapabilities(), + isDownloadAndAllowsSyncEnabled, + share.shareType + ) + + val requestBody = UpdateShareRequest( + share.permissions, + password, + share.note, + share.label, + getExpirationDate(share.expirationDate), + share.isHideFileDownload.toString(), + attributes + ) + val result: ApiResult?> = + repository!!.updateShare(share.id, requestBody) + activity?.runOnUiThread { + if (result.isSuccess()) { + val intent = Intent(context, MainActivity::class.java) + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + activity?.startActivity(intent) + } else if (result.isError()) { + val error = result as ApiResult.Error + DisplayUtils.showSnackMessage(view, error.message) + } + } + } + } + } + + companion object { + private const val TAG = "NoteShareFragment" + const val ARG_NOTE: String = "NOTE" + const val ARG_ACCOUNT: String = "ACCOUNT" + const val FTAG_CHOOSER_DIALOG: String = "CHOOSER_DIALOG" + + private const val SEARCH_DELAY_MS: Long = 500 + private const val EXPIRATION_DATE_MIN_MAJOR_VERSION = 18 + + fun newInstance(note: Note, account: Account) : NoteShareFragment { + val f = NoteShareFragment() + + val bundle = Bundle() + bundle.putSerializable(ARG_NOTE, note) + bundle.putSerializable(ARG_ACCOUNT, account) + + f.arguments = bundle + return f + } + } +} 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 index 08d10bd5b..77483feaf 100644 --- 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 @@ -43,6 +43,7 @@ class SuggestionAdapter(context: Context, cursor: Cursor?, private val account: return binding.root } + @Suppress("NestedBlockDepth") override fun bindView(view: View, context: Context, cursor: Cursor) { val suggestion = cursor.getString(cursor.getColumnIndexOrThrow(SearchManager.SUGGEST_COLUMN_TEXT_1)) 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 index 615e13933..ffb118973 100644 --- 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 @@ -28,6 +28,7 @@ import it.niedermann.owncloud.notes.shared.util.DisplayUtils /** * Show actions of an user */ +@Suppress("ForbiddenComment") class ProfileBottomSheetDialog( private val fileActivity: FragmentActivity, private val account: Account, 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/ShareLinkToDialog.kt similarity index 100% rename from app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ShareLinkDialog.kt rename to app/src/main/java/it/niedermann/owncloud/notes/share/dialog/ShareLinkToDialog.kt 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 index ec9f8c10f..5d6d4d27a 100644 --- 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 @@ -43,12 +43,15 @@ data class UpdateShareRequest( val attributes: String? = null ) { companion object { + const val SHARE_ATTRIBUTES_V2_MIN_VERSION = 30 + fun createAttributes(capabilities: Capabilities, allowDownloadAndSync: Boolean, type: ShareType?): String { if (type == null || (type != ShareType.INTERNAL && type != ShareType.USER)) { return "[]" } - val shouldUseShareAttributesV2 = (capabilities.nextcloudMajorVersion?.toInt() ?: 0) >= 30 + val shouldUseShareAttributesV2 = + (capabilities.nextcloudMajorVersion?.toInt() ?: 0) >= SHARE_ATTRIBUTES_V2_MIN_VERSION val shareAttributes = arrayOf( if (shouldUseShareAttributesV2) { 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 index 442fd8b5e..9e7aac04f 100644 --- 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 @@ -68,6 +68,7 @@ class ShareRepository(private val applicationContext: Context, private val accou fun getCapabilities(): Capabilities = notesRepository.capabilities // region API calls + @Suppress("NestedBlockDepth") fun fetchSharesForNotesAndSaveShareEntities() { val notesPathResponseResult = getNotesPathResponseResult() ?: return val notesPath = notesPathResponseResult.notesPath @@ -135,7 +136,8 @@ class ShareRepository(private val applicationContext: Context, private val accou val entity = getShareByPathAndDisplayName(share) val attributes = entity?.attributes ?: return false val capabilities = notesRepository.capabilities - val shouldUseShareAttributesV2 = (capabilities.nextcloudMajorVersion?.toInt() ?: 0) >= 30 + val shouldUseShareAttributesV2 = + (capabilities.nextcloudMajorVersion?.toInt() ?: 0) >= UpdateShareRequest.SHARE_ATTRIBUTES_V2_MIN_VERSION return if (shouldUseShareAttributesV2) { ShareAttributesV2.getAttributes(attributes).first().value } else { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NoteClickListener.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NoteClickListener.java index 0f1a4084f..feca07cd4 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NoteClickListener.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/NoteClickListener.java @@ -13,5 +13,5 @@ public interface NoteClickListener { void onNoteFavoriteClick(int position, View v); - void openShare(int position); + void openInfoBottomSheet(int position); } \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/CustomAppGlideModule.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/CustomAppGlideModule.java deleted file mode 100644 index 53b3e8f2a..000000000 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/CustomAppGlideModule.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Nextcloud Notes - Android Client - * - * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package it.niedermann.owncloud.notes.shared.util; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.UiThread; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.Registry; -import com.bumptech.glide.annotation.GlideModule; -import com.bumptech.glide.module.AppGlideModule; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -@GlideModule -public class CustomAppGlideModule extends AppGlideModule { - - private static final String TAG = CustomAppGlideModule.class.getSimpleName(); - private static final ExecutorService clearDiskCacheExecutor = Executors.newSingleThreadExecutor(); - - @Override - public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { - super.registerComponents(context, glide, registry); - } - - @UiThread - public static void clearCache(@NonNull Context context) { - Log.i(TAG, "Clearing Glide memory cache"); - Glide.get(context).clearMemory(); - clearDiskCacheExecutor.submit(() -> { - Log.i(TAG, "Clearing Glide disk cache"); - Glide.get(context.getApplicationContext()).clearDiskCache(); - }); - } -} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/CustomAppGlideModule.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/CustomAppGlideModule.kt new file mode 100644 index 000000000..b166b2620 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/CustomAppGlideModule.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.shared.util + +import android.content.Context +import android.graphics.drawable.PictureDrawable +import android.util.Log +import androidx.annotation.UiThread +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.module.AppGlideModule +import com.caverock.androidsvg.SVG +import it.niedermann.owncloud.notes.shared.util.svg.SvgDecoder +import it.niedermann.owncloud.notes.shared.util.svg.SvgDrawableTranscoder +import java.io.InputStream +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@GlideModule +class CustomAppGlideModule : AppGlideModule() { + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + super.registerComponents(context, glide, registry) + + registry + .register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder()) + .append(InputStream::class.java, SVG::class.java, SvgDecoder()) + } + + companion object { + private val TAG: String = CustomAppGlideModule::class.java.getSimpleName() + private val clearDiskCacheExecutor: ExecutorService = Executors.newSingleThreadExecutor() + + @JvmStatic + @UiThread + fun clearCache(context: Context) { + Log.i(TAG, "Clearing Glide memory cache") + Glide.get(context).clearMemory() + clearDiskCacheExecutor.submit { + Log.i(TAG, "Clearing Glide disk cache") + Glide.get(context.applicationContext).clearDiskCache() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ExtendedFabUtil.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ExtendedFabUtil.kt index d201e2d0b..c8ade93f5 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ExtendedFabUtil.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ExtendedFabUtil.kt @@ -32,13 +32,13 @@ object ExtendedFabUtil { ) animation.setAnimationListener( object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) {} + override fun onAnimationStart(animation: Animation) = Unit override fun onAnimationEnd(animation: Animation) { extendedFab.visibility = View.GONE } - override fun onAnimationRepeat(animation: Animation) {} + override fun onAnimationRepeat(animation: Animation) = Unit }, ) extendedFab.startAnimation(animation) 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 index 0e46f8210..a4ef95a3e 100644 --- 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 @@ -6,7 +6,6 @@ */ package it.niedermann.owncloud.notes.shared.util.clipboard -import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -24,26 +23,26 @@ object ClipboardUtil { @JvmStatic @JvmOverloads @Suppress("TooGenericExceptionCaught") - fun copyToClipboard(activity: Activity, text: String?, showToast: Boolean = true) { + fun copyToClipboard(context: Context, text: String?, showToast: Boolean = true) { if (!TextUtils.isEmpty(text)) { try { val clip = ClipData.newPlainText( - activity.getString( + context.getString( R.string.clipboard_label, - activity.getString(R.string.app_name) + context.getString(R.string.app_name) ), text ) - (activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(clip) + (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(clip) if (showToast) { - Toast.makeText(activity, R.string.clipboard_text_copied, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.clipboard_text_copied, Toast.LENGTH_SHORT).show() } } catch (e: Exception) { - Toast.makeText(activity, R.string.clipboard_unexpected_error, Toast.LENGTH_SHORT).show() + Toast.makeText(context, 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() + Toast.makeText(context, R.string.clipboard_no_text_to_copy, Toast.LENGTH_SHORT).show() } } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/svg/SvgDecoder.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/svg/SvgDecoder.kt new file mode 100644 index 000000000..8d97a7043 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/svg/SvgDecoder.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2014 Google, Inc. All rights reserved. + * SPDX-License-Identifier: BSD-2-Clause + * + * Borrowed from: + * https://github.com/bumptech/glide/blob/master/samples/svg/src/main/java/com/bumptech/glide/samples/svg/ + * SvgDecoder.java + */ +package it.niedermann.owncloud.notes.shared.util.svg + +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import com.bumptech.glide.load.resource.SimpleResource +import com.caverock.androidsvg.SVG +import com.caverock.androidsvg.SVGParseException +import java.io.IOException +import java.io.InputStream + +/** + * Decodes an SVG internal representation from an [InputStream]. + */ +class SvgDecoder : ResourceDecoder { + @Throws(IOException::class) + override fun decode(source: InputStream, width: Int, height: Int, options: Options): Resource { + try { + val svg = SVG.getFromInputStream(source) + return SimpleResource(svg) + } catch (ex: SVGParseException) { + throw IOException("Cannot load SVG from stream", ex) + } + } + + @Throws(IOException::class) + override fun handles(source: InputStream, options: Options): Boolean = true +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/svg/SvgDrawableTranscoder.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/svg/SvgDrawableTranscoder.kt new file mode 100644 index 000000000..7033f5104 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/svg/SvgDrawableTranscoder.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2014 Google, Inc. All rights reserved. + * SPDX-License-Identifier: BSD-2-Clause + * + * Borrowed from: + * https://github.com/bumptech/glide/blob/master/samples/svg/src/main/java/com/bumptech/glide/samples/svg/ + * SvgDrawableTranscoder.java + */ +package it.niedermann.owncloud.notes.shared.util.svg + +import android.graphics.Picture +import android.graphics.drawable.PictureDrawable +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.engine.Resource +import com.bumptech.glide.load.resource.SimpleResource +import com.bumptech.glide.load.resource.transcode.ResourceTranscoder +import com.caverock.androidsvg.SVG + +/** + * Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]). + */ +class SvgDrawableTranscoder : ResourceTranscoder { + override fun transcode(toTranscode: Resource, options: Options): Resource { + val svg = toTranscode.get() + val picture = svg.renderToPicture() + val drawable = PictureDrawable(picture) + return SimpleResource(drawable) + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/util/HumanReadableFormatter.kt b/app/src/main/java/it/niedermann/owncloud/notes/util/HumanReadableFormatter.kt new file mode 100644 index 000000000..8224c3da1 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/util/HumanReadableFormatter.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package it.niedermann.owncloud.notes.util + +import java.util.Locale + +object HumanReadableFormatter { + + private const val BITS_PER_UNIT = 10 + + @JvmStatic + fun formatBytes(bytes: Long): String? { + if (bytes < 0) { + return null + } + + val highestSetBit = Long.SIZE_BITS - 1 - bytes.countLeadingZeroBits() + val unitIndex = (highestSetBit / BITS_PER_UNIT).coerceIn(0, ByteUnit.entries.lastIndex) + val unit = ByteUnit.entries[unitIndex] + val size = bytes.toDouble() / (1L shl (BITS_PER_UNIT * unitIndex)) + + val pattern = "%.${unit.decimals}f" + val formattedSize = String.format(Locale.ROOT, pattern, size) + return "$formattedSize ${unit.suffix}" + } + + enum class ByteUnit(val suffix: String, val decimals: Int) { + BYTES("B", 0), + KILOBYTES("KB", 0), + MEGABYTES("MB", 1), + GIGABYTES("GB", 1), + TERABYTES("TB", 1), + PETABYTES("PB", 2), + EXABYTES("EB", 2) + } + +} diff --git a/app/src/main/res/drawable/file_text.xml b/app/src/main/res/drawable/file_text.xml new file mode 100644 index 000000000..9ec2d79bb --- /dev/null +++ b/app/src/main/res/drawable/file_text.xml @@ -0,0 +1,17 @@ + + + + diff --git a/app/src/main/res/drawable/ic_activity.xml b/app/src/main/res/drawable/ic_activity.xml new file mode 100644 index 000000000..010b857e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_activity.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 000000000..4195575e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/nav_activity.xml b/app/src/main/res/drawable/nav_activity.xml new file mode 100644 index 000000000..4bc862029 --- /dev/null +++ b/app/src/main/res/drawable/nav_activity.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/layout/actions_bottom_sheet_item.xml b/app/src/main/res/layout/actions_bottom_sheet_item.xml new file mode 100644 index 000000000..9781486e9 --- /dev/null +++ b/app/src/main/res/layout/actions_bottom_sheet_item.xml @@ -0,0 +1,45 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_item_details.xml b/app/src/main/res/layout/activity_item_details.xml new file mode 100644 index 000000000..63b1cef6b --- /dev/null +++ b/app/src/main/res/layout/activity_item_details.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_list_item.xml b/app/src/main/res/layout/activity_list_item.xml new file mode 100644 index 000000000..89f05fcb7 --- /dev/null +++ b/app/src/main/res/layout/activity_list_item.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_note_share.xml b/app/src/main/res/layout/activity_note_share.xml index ffeb8cf6b..121dab920 100644 --- a/app/src/main/res/layout/activity_note_share.xml +++ b/app/src/main/res/layout/activity_note_share.xml @@ -41,43 +41,6 @@ - - - - - - - - - - - - + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_action_bottom_sheet.xml b/app/src/main/res/layout/item_action_bottom_sheet.xml new file mode 100644 index 000000000..9a521e51f --- /dev/null +++ b/app/src/main/res/layout/item_action_bottom_sheet.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + 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 2191dba0c..94cd41a3a 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 @@ -87,8 +87,9 @@ - - + + diff --git a/app/src/main/res/layout/item_notes_list_note_item_grid_only_title.xml b/app/src/main/res/layout/item_notes_list_note_item_grid_only_title.xml index 363a4306e..9de193b11 100644 --- a/app/src/main/res/layout/item_notes_list_note_item_grid_only_title.xml +++ b/app/src/main/res/layout/item_notes_list_note_item_grid_only_title.xml @@ -76,8 +76,9 @@ - - + + diff --git a/app/src/main/res/layout/item_notes_list_note_item_with_excerpt.xml b/app/src/main/res/layout/item_notes_list_note_item_with_excerpt.xml index ac3482c03..65e857cf0 100644 --- a/app/src/main/res/layout/item_notes_list_note_item_with_excerpt.xml +++ b/app/src/main/res/layout/item_notes_list_note_item_with_excerpt.xml @@ -168,24 +168,20 @@ tools:maxLength="15" tools:text="@tools:sample/lorem/random" /> - - + + diff --git a/app/src/main/res/menu/menu_list_context_multiple.xml b/app/src/main/res/menu/menu_list_context_multiple.xml index 1a8491212..5f79dccbc 100644 --- a/app/src/main/res/menu/menu_list_context_multiple.xml +++ b/app/src/main/res/menu/menu_list_context_multiple.xml @@ -15,6 +15,13 @@ android:title="@string/menu_change_category" app:showAsAction="ifRoom" /> + + @color/material_dynamic_secondary20 @color/transparent + + #49454F + \ 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 f3a39be8a..c5649baf1 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -59,4 +59,9 @@ @color/high_emphasis_text @color/transparent + + #CAC4D0 + @color/secondary_text_color + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index b42e9c41b..0d1b39e15 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -26,14 +26,13 @@ 180dp 100dp - 18dp - 40dp 16dp 196dip 48dp 40dp + 32dp 16sp 56dp 24dp @@ -82,4 +81,18 @@ 56dp 60dp 24dp + + @dimen/spacer_2x + @dimen/spacer_1x + + 32dp + 32dp + 24dp + 24dp + 16dp + 24dp + -3dp + + 112dp + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 000000000..98d624e27 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9dd008cac..a8bf9b3f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -546,4 +546,26 @@ Share expires on %1$s Dismiss Account not found! + + Details + + Activities + Unable to fetch activities + Activity + No events like additions, changes and shares yet. + An error occurred loading activities + New version was created + Version restored + Version restore not supported: please upgrade Nextcloud Files app + Could not restore version + + Note.md + Favorite +  • + 12:23:45 + This is a placeholder + + Details + Send/share + diff --git a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt index ed2495f92..928a1297d 100644 --- a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt +++ b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt @@ -82,7 +82,8 @@ class NoteUtilTest : TestCase() { } private fun testTitleMatchingExcerpts() { - // content actually starts with title → return max. 200 characters starting with the first character after the title + // content actually starts with title → + // return max. 200 characters starting with the first character after the title assertEquals("", NoteUtil.generateNoteExcerpt("Title", "Title")) assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\nFoo", "Title")) assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("Title\nTitle\nBar", "Title")) diff --git a/build.gradle b/build.gradle index c0b12026b..e2781d93b 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:9.5.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:2.3.12" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 000000000..5712ca319 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,406 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +processors: + active: true + exclude: + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ClassCountProcessor' + # - 'PackageCountProcessor' + # - 'KtFileCountProcessor' + +console-reports: + active: true + exclude: + # - 'ProjectStatisticsReport' + # - 'ComplexityReport' + # - 'NotificationReport' + # - 'FindingsReport' + # - 'BuildFailureReport' + +comments: + active: true + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!]$) + UndocumentedPublicClass: + active: false + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: false + +complexity: + active: true + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + CyclomaticComplexMethod: + active: true + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + excludes: ['**/androidTest/**'] + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 120 + excludes: ['**/androidTest/**'] + LongParameterList: + active: false + functionThreshold: 7 + constructorThreshold: 6 + ignoreDefaultParameters: false + MethodOverloading: + active: false + threshold: 6 + NestedBlockDepth: + active: true + threshold: 4 + StringLiteralDuplication: + active: false + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: false + thresholdInFiles: 15 + thresholdInClasses: 15 + thresholdInInterfaces: 15 + thresholdInObjects: 15 + thresholdInEnums: 11 + ignoreDeprecated: true + ignorePrivate: false + ignoreOverridden: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: false + methodNames: [toString,hashCode,equals,finalize] + InstanceOfCheckForException: + active: false + NotImplementedDeclaration: + active: false + PrintStackTrace: + active: false + RethrowCaughtException: + active: false + ReturnFromFinally: + active: false + SwallowedException: + active: false + ignoredExceptionTypes: [InterruptedException,NumberFormatException,ParseException,MalformedURLException] + ThrowingExceptionFromFinally: + active: false + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: false + exceptions: [IllegalArgumentException,IllegalStateException,IOException] + ThrowingNewInstanceOfSameException: + active: false + TooGenericExceptionCaught: + active: false + exceptionNames: + - ArrayIndexOutOfBoundsException + - Error + - Exception + - IllegalMonitorStateException + - NullPointerException + - IndexOutOfBoundsException + - RuntimeException + - Throwable + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + TooGenericExceptionThrown: + active: false + exceptionNames: + - Error + - Exception + - Throwable + - RuntimeException + +naming: + active: true + ClassNaming: + active: true + classPattern: '[A-Z$][a-zA-Z0-9$]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '^[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + functionPattern: '^([a-z$A-Z][a-zA-Z$0-9]*)|(`.*`)$' + excludeClassPattern: '$^' + ignoreOverridden: true + excludes: + - "**/*Test.kt" + - "**/*IT.kt" + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + MatchingDeclarationName: + active: true + MemberNameEqualsClassName: + active: false + ignoreOverridden: true + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '^[a-z]+(\.[a-z][A-Za-z0-9]*)*$' + TopLevelPropertyNaming: + active: false + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + +performance: + active: true + ArrayPrimitive: + active: false + ForEachOnRange: + active: true + SpreadOperator: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + DuplicateCaseInWhenExpression: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: false + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + InvalidRange: + active: false + IteratorHasNextCallsNextMethod: + active: false + IteratorNotThrowingNoSuchElementException: + active: false + LateinitUsage: + active: false + ignoreAnnotated: [] + ignoreOnClassesPattern: "" + UnconditionalJumpStatementInLoop: + active: false + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: false + UnsafeCast: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: false + +style: + active: true + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: [to] + EqualsNullCall: + active: false + EqualsOnSignatureLine: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenComment: + active: true + values: 'TODO:,FIXME:,STOPSHIP:' + ForbiddenImport: + active: false + imports: [] + ForbiddenVoid: + active: false + FunctionOnlyReturningConstant: + active: false + ignoreOverridableFunction: true + excludedFunctions: [describeContents] + LoopWithTooManyJumpStatements: + active: false + maxJumpCount: 1 + MagicNumber: + active: true + ignoreNumbers: ["-1","0","1","2"] + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + excludes: + - "**/*Test.kt" + - "**/*IT.kt" + MandatoryBracesIfStatements: + active: false + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + MayBeConst: + active: false + ModifierOrder: + active: true + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: false + NoTabs: + active: false + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: false + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: false + max: 2 + excludedFunctions: [equals] + excludeLabeled: false + excludeReturnFromLambda: true + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 2 + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableLength: 5 + UnnecessaryAbstractClass: + active: false + ignoreAnnotated: + - "dagger.Module" + UnnecessaryApply: + active: false + UnnecessaryInheritance: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedPrivateClass: + active: false + UnusedPrivateMember: + active: false + allowedNames: "(_|ignored|expected|serialVersionUID)" + UseDataClass: + active: false + ignoreAnnotated: [] + UtilityClassWithPublicConstructor: + active: false + VarCouldBeVal: + active: false + WildcardImport: + active: true + excludeImports: [java.util.*,kotlinx.android.synthetic.*] diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys index def305c44..efe50afdb 100644 --- a/gradle/verification-keyring.keys +++ b/gradle/verification-keyring.keys @@ -698,6 +698,63 @@ dCBwdXJwb3NlcykgPHQud2FybmVrZUBnbXgubmV0Pg== =q1C6 -----END PGP PUBLIC KEY BLOCK----- +pub AB2DA4527F6FFC0B +uid Egor Andreevici + +sub 1A94B14C6A03458D +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGI8r9sBEACZJBV2TNUSsLRo89uC4lfmQxfNDqkE0uZghfFY/p0fr6fkBybO +WDkPFskAPD32fzrWxZd2kkyCRyUrOmAUC22q8hw96t28+RqZymvetIa0f8GQGgkO +/ZTiD6Nnv4JoeSfUkJConDk2J/2a0pdxcC/MWLB2I0X6pg4znRHtNjgGsyDe8uW0 +xGK4cyRdQH0A9T0TynKjdB4tBNS6gB7uI4GE+J0jFb56QxzEZ2+t+vaN9QornDgu +yNqZMAp7Fnou1AjmsMalkC9bTO1JrF6P5ndsBPiaLKJHeqHvssC78SGM2CvPjRnS +YVM/pTmcgEPX9590p8WLM050DFMbBMejPx5UDDf461rDcbkatL4XgqopLGDN/+Hp +As7dkGxc5utHm/qrXspaLVpRGaiZ+UiI3m1PJDcOozWXuGSrI2fGw/i0HKY5VHmI +6IVtSrjMu24Fh+t/GK39Li+xZZuw8jZcuL/28kbwVFeeBigz6AKnqvK041iBYfLM +J9y/7B2W1yVr4rvCgBahVvMhJfhnXzNqstWEp+zVLtF3CdNti0eAvJHnoxW8Pu67 +Lx98QyaWHHw+S9sF380O0sQiipldY5X80brrI73MmMnW6bYda6F/57JJ4ae2Hq/Y +Bd9uc8fuInkpBld5uwc698ndl4fB94rm3Z4nFCIv0mKpmEaSo35luiKS6QARAQAB +tCNFZ29yIEFuZHJlZXZpY2kgPGVnb3JAc3F1YXJldXAuY29tPokCUgQTAQgAPBYh +BMWqV/Sjjrp7f5FW3astpFJ/b/wLBQJiPK/bAhsDBQsJCAcCAyICAQYVCgkICwIE +FgIDAQIeBwIXgAAKCRCrLaRSf2/8CwwSD/0RzN1XI+Mj65XgEO7VDZNvKeBLeXJd +Lw1CtCfu71f2KRKhrZ6AaXMSkyv62ZjAcli1bVX5QJW9YFouHBUtKuhi3XsebbET +eSdOggneMr6ZaVB1O1WQYMZFLQmuKeeYkf40BAZTWZLK7+GdPbZ3/c941r0r3bkx +7hg/Kya+3G8/hiYEUxyVf86KJupPlHcrmMHeqirDDhsJJj/WBRZa4sHze1kzz+uE +fyB8DMc+GYbCWnCTh7Fr2ncl2bVlB2EqYgdl5AVh2yTauSPsDM82nw4j2kYm7YvI +0jJ7gdOXAgE1SMUcfsO1WJwwhPDYMIlVgdsGxdxwgCWamaTigxdnJjbOyCMeaH6A +m+t8C6QM+8sER798NVyt6yS8FfvkEspfdPfFqHcqhDhQmRSXbclwT9FCCgSWQjK9 +6VuXsoYPmGDCTnrz9doXZ6lR8YAv5ITFkU/ptCWqLXFGQeyWdecATIxXJfvpVqyd +6OSAgg9JMYKX34iP9CfUpk0LI7H3qIx44LxZK7okxXymo8pKCzy5g39kFkF24wEy +ib8RbTQYJK8+BlJFxGPUHPonMW0OYhWprVQbE5FLj2alvtcjYOA2hlg+KJFW4H2I +vdUsa3r7X39MrtbbfYxUX/QUnysU0uXLerEXFjP4zN/0DfPiaAVC/vzzOQAFZVXE +hqiqR+MiAJ7WmrkCDQRiPK/bARAAu5QxSoH1Yn2McbB6GIuplKt1aIMaZg41meli +sR1EnriOoNnbw3iTI8dx7p7JBIJ2gMCNxu/mct3GcAIaBgj/5Jf9XYVF1bHcazv6 +RPYsvVBYDV1GVaLitG9wlDS/y0wtb3SR7xmhkrwIRJQAjSQtqvRB3lRHguTtatEk +MpTscjgbDjAvzsYx9vtF3jM3dXIK/1rFrC0kOweUZAWJYCNxbdAvJWioas5fKbTK +e4s6KXKhhVVIp/4RIr2dByg5mAK99ZuVyKGhtFE6y0uk+BU4H2ZWXehMPfm9Tjk7 +oqkMC2OqEB1t0Ep9xCQtvzbqCxhNFPuHU+OWTBy7ARnrNKEkh2Bppv607jjHOHxh +JW3sjrl9sH1DAQNR2ZKob70ocUoyqDT4FNG9/H+CjbsZqzFqmKcbAQA2fiIO5NTw +wOnfbcRlmHuY4qrZ5LmhSGnlkrHs9Uld4mosJZXOb69RXIL/d1SCih0wPMBbLl0T +I9FfJD7YDBASxEqN0lmYHGo8qu9Vg5KPSVQW8Fg9Tmig4aPSgpT6nHyqiuUcoZyO +nICX9TMraPXMoXBxXiWrzu8HqtsmzFPpqOmWfvg97X6nco8obpJRGMODUQQMYjeQ +48SBbGVe1utEZ4Yt47ArxLKmh9jp1jdoGkLT+8T0Z1FQEnS1d4/xGPaCFIz4+kXG +QoaJdOkAEQEAAYkCNgQYAQgAIBYhBMWqV/Sjjrp7f5FW3astpFJ/b/wLBQJiPK/b +AhsMAAoJEKstpFJ/b/wLWUkP/25L1N5zJdTsoooTjutFAiVvy8IbXx0XGm0F10pB +MLAbKwkyDyOvZ62DSc/xwmXvZE4GfL+dpvmZUIwmtReYDy4byrMbdF+Yw8xLmnp2 +xeoKsrh8VacVniEkPbKKrVFjBrhoV/oEGHi+ilKq2KeftWHm8mLk/QQ/AXGuum24 +wBmRBBY1NPCiPk8+HoRFY2qrz+cboK5oAp3agCF+LmOBFKUBkId1BxvQZViQGKkm +2uoQ0kiFpy4TlcxWwATtFvaE/d6w/RqdjAwYpZcnbbAh8HthSE1ogjZSWkypEdwr ++EmBlHWaXWxVHsJghB/YVKfC7HS16IalHw8aGxdXsrAU9rnOdajY5NDwdRJDTH5T +M9VsHSIEFOkSr0HBcQ+Ghff9H4QxfeiEo6UsuSZB3ZaRk6VE1GYFRj06mmPB0uv+ +C+6wl5znv6chfQzFPf82KiHO4kxgR+UK3SoxSilCWFh46YXv0IWkg7jeFtJZWLN+ +LGmDS2vF9haurM4dwlz2IOFm7CLSVixCaDPqpOh7OqnwRVHznBy7/DYzmaS0lSAu +TFhh5mq+ofWaIpmdlGiqxuUSkNg/Zj+HBxG+AyH8Lhi9WGUafgJeZ7fKBJbtw6Jp +Wl9bUuiEBpnRFkn12RBe7vkyfZGDR2u/WbxTrhDdrrjoFCFo/ZoYzRY46LR+Hdyt +aEmE +=lnZo +-----END PGP PUBLIC KEY BLOCK----- + pub AC7A514BC9F9BB70 uid Punyashloka Biswal diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 8c45513f2..dd469b5fe 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -113,6 +113,7 @@ + @@ -286,6 +287,7 @@ +