diff --git a/app/build.gradle b/app/build.gradle index e8ee3ed7b..ac38883d0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,6 +8,7 @@ */ apply plugin: 'com.android.application' +apply plugin: 'com.google.devtools.ksp' android { namespace = 'it.niedermann.owncloud.notes' @@ -130,6 +131,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.0") { exclude group: 'org.ogce', module: 'xpp3' @@ -155,7 +157,8 @@ dependencies { // Glide 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..c2b06f0bd --- /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.ActivitiesResult +import it.niedermann.owncloud.notes.activities.repository.VersionsRepository +import it.niedermann.owncloud.notes.activities.repository.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..cf05bc3ba --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/ActivitiesFragment.kt @@ -0,0 +1,180 @@ +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.model.Activity +import it.niedermann.owncloud.notes.activities.repository.ActivitiesRepository +import it.niedermann.owncloud.notes.activities.repository.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 + +/* + * 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 + */ +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) { + // No action + } + + override fun onVersionItemClick(version: FileVersion) { + binding.swipeContainingList.isRefreshing = true + // Restore the version + val ssoAccount = + SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()) + val repository = VersionsRepository(requireContext(), ssoAccount) + lifecycleScope.launch(Dispatchers.IO) { + val result = repository.restoreVersion(version) + withContext(Dispatchers.Main) { + when (result) { + RestoreVersionsResult.Success -> syncNote() + RestoreVersionsResult.FailedUnsupported -> showMessage(R.string.version_restore_failed_unsupported) + else -> showMessage(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 + showMessage(R.string.version_restored) + }) + noteRepo.scheduleSync(account, false) + } + + private fun showMessage(@StringRes message: Int) { + 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() { + binding.swipeContainingList.isRefreshing = 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() { + binding.swipeContainingList.isRefreshing = 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 { + val f = ActivitiesFragment() + + val bundle = Bundle() + bundle.putSerializable(ARG_NOTE, note) + bundle.putSerializable(ARG_ACCOUNT, account) + + f.arguments = bundle + return f + } + } +} \ 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..678dd9aa4 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivitiesData.kt @@ -0,0 +1,33 @@ +/* + * 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.model.Activity + +class ActivitiesData { + val activities = arrayListOf() + val versions = arrayListOf() + var activitiesLastGiven: Long? = null + var activitiesMore: Boolean = true + + fun getAggregatedList(context: Context) : List { + val items = arrayListOf() + items.addAll( + activities.map { ActivityListItem(context, it) } + ) + items.addAll( + versions.map { ActivityListItem(context, it) } + ) + items.sortDescending() + return items + } + + fun isEmpty() : Boolean = activities.isEmpty() && versions.isEmpty() +} \ No newline at end of file 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..0a86fdfbf --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivityListAdapter.kt @@ -0,0 +1,144 @@ +/* + * 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.View +import android.view.ViewGroup +import androidx.core.net.toUri +import androidx.recyclerview.widget.RecyclerView +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.model.Activity +import it.niedermann.owncloud.notes.branding.Branded +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 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) + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): RecyclerView.ViewHolder = + ActivityViewHolder( + ActivityListItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + dateFormatter + ) + + 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.activity == lastActivity) { + listener.lastActivityReached() + } + holder.bind(item, listener) + } + + override fun getItemCount(): Int = items.size + + override fun applyBrand(color: Int) { + brandColor = color + } + + private class ActivityViewHolder( + val binding: ActivityListItemBinding, + val dateFormatter: DateFormatter + ) : BrandedViewHolder(binding.root) { + + fun bind(item: ActivityListItem, listener: OnActivityClickListener) { + bindDateTime(item) + bindSubject(item) + bindMessage(item) + bindIcon(item) + bindActionIcon(item) + binding.root.setOnClickListener { + when { + item.activity != null -> listener.onActivityItemClick(item.activity) + item.fileVersion != null -> listener.onVersionItemClick(item.fileVersion) + } + } + } + + private fun bindDateTime(activity: ActivityListItem) { + binding.datetime.apply { + visibility = View.VISIBLE + text = dateFormatter.getConditionallyRelativeFormattedTimeSpan( + Calendar.getInstance().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/adapter/ActivityListItem.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivityListItem.kt new file mode 100644 index 000000000..825fbdac5 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivityListItem.kt @@ -0,0 +1,60 @@ +/* + * 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.R +import it.niedermann.owncloud.notes.activities.model.Activity +import it.niedermann.owncloud.notes.util.HumanReadableFormatter + +class ActivityListItem : Comparable { + val context: Context + val activity: Activity? + val fileVersion: FileVersion? + + constructor(context: Context, activity: Activity) { + this.context = context + this.activity = activity + this.fileVersion = null + } + + constructor(context: Context, fileVersion: FileVersion) { + this.context = context + this.activity = null + this.fileVersion = fileVersion + } + + fun getTimestamp() : Long = + when { + activity != null -> activity.datetime.time + fileVersion != null -> fileVersion.modifiedTimestamp + else -> 0 + } + + fun getTitle() : String? = + when { + activity != null -> activity.subject + fileVersion != null -> context.getString(R.string.new_version_was_created) + else -> null + } + + fun getMessage() : String? = + when { + activity != null -> activity.message + fileVersion != null -> HumanReadableFormatter.formatBytes(fileVersion.fileLength) + else -> null + } + + fun getIconUrl() : String? = activity?.icon + + fun canBeRestored() : Boolean = fileVersion != null + + override fun compareTo(other: ActivityListItem): Int = + getTimestamp().compareTo(other.getTimestamp()) +} \ 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..0893426ef --- /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.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/model/Activity.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/model/Activity.kt new file mode 100644 index 000000000..7874f4cba --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/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.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/repository/ActivitiesRepository.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/ActivitiesRepository.kt new file mode 100644 index 000000000..3d631112e --- /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.persistence.ApiProvider +import okio.IOException +import java.net.HttpURLConnection +import kotlin.getValue + +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/ActivitiesResult.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/ActivitiesResult.kt new file mode 100644 index 000000000..34a6e8632 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/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 + +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.activities.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/RestoreVersionsResult.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/RestoreVersionsResult.kt new file mode 100644 index 000000000..b9844efa0 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/RestoreVersionsResult.kt @@ -0,0 +1,14 @@ +/* + * 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 com.owncloud.android.lib.resources.files.model.FileVersion + +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/VersionsRepository.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/VersionsRepository.kt new file mode 100644 index 000000000..1d3203bbc --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/VersionsRepository.kt @@ -0,0 +1,30 @@ +/* + * 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.persistence.ApiProvider +import it.niedermann.owncloud.notes.persistence.sync.VersionsAPI + +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/VersionsResult.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/VersionsResult.kt new file mode 100644 index 000000000..96f4b167e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/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 + +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..d1e701b3f 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)getActivity()).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..84d27e89a --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/itemactions/NoteAction.kt @@ -0,0 +1,23 @@ +package it.niedermann.owncloud.notes.itemactions + +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import it.niedermann.owncloud.notes.R + +/* + * 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 + */ + +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..b43794b66 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/itemdetails/ItemDetailsActivity.kt @@ -0,0 +1,147 @@ +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 + + +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +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() + } + + 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_ACCOUNT 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( + Page( + PAGE.ACTIVITIES, + getString(R.string.activities_title), + AppCompatResources.getDrawable(this, R.drawable.nav_activity), + ActivitiesFragment.newInstance(note, account) + ), + Page( + 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(binding.getRoot()) + util.platform.colorViewBackground(binding.tabLayout) + util.material.themeTabLayout(binding.tabLayout) + } + + private data class Page ( + 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..0c65ce160 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; @@ -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..470d3e7e1 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,6 @@ import android.content.Context; import android.content.Intent; -import android.os.Bundle; import android.util.TypedValue; import android.view.Menu; import android.view.MenuItem; @@ -33,7 +32,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 { @@ -102,7 +101,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 +160,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,6 +176,24 @@ 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; } 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/sync/ActivitiesAPI.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ActivitiesAPI.kt new file mode 100644 index 000000000..15611bf20 --- /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.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/VersionsAPI.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/VersionsAPI.kt new file mode 100644 index 000000000..b69de8312 --- /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.RestoreVersionsResult +import it.niedermann.owncloud.notes.activities.repository.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/NoteShareFragment.kt b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareFragment.kt new file mode 100644 index 000000000..179eeab85 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareFragment.kt @@ -0,0 +1,908 @@ +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 + + +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +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) + } + + // TODO: + override fun createSecureFileDrop() { + } + + 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 packagesToExclude = arrayOf(context?.packageName) + val chooserDialog: DialogFragment = newInstance(intentToShareLink, *packagesToExclude) + 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) + } + + // TODO: + override fun showProfileBottomSheet(account: Account?, shareWith: String?) { + } + + 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) + + // FIXME: + 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) + ) + } + }) + }) + } + + // TODO: + override fun sendLink(share: OCShare?) { + } + + 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 >= 18 + } + + 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 + + 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/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/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..77d882ccc --- /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..99448dabb --- /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..6daf4abbb --- /dev/null +++ b/app/src/main/res/layout/item_action_bottom_sheet.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + 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..7312406ce 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -26,8 +26,6 @@ 180dp 100dp - 18dp - 40dp 16dp @@ -82,4 +80,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/build.gradle b/build.gradle index 85903d40a..198e0880d 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:9.4.1' 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/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 7e810119a..82b2eaddb 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -15,6 +15,7 @@ + @@ -113,6 +114,7 @@ + @@ -421,6 +423,11 @@ + + + + + @@ -547,6 +554,14 @@ + + + + + + + + @@ -600,6 +615,14 @@ + + + + + + + + @@ -624,6 +647,14 @@ + + + + + + + + @@ -708,6 +739,11 @@ + + + + + @@ -737,6 +773,14 @@ + + + + + + + + @@ -784,6 +828,14 @@ + + + + + + + + @@ -884,6 +936,11 @@ + + + + + @@ -899,6 +956,11 @@ + + + + + @@ -963,6 +1025,14 @@ + + + + + + + + @@ -1042,6 +1112,11 @@ + + + + + @@ -1106,6 +1181,14 @@ + + + + + + + + @@ -1185,6 +1268,11 @@ + + + + + @@ -1249,6 +1337,14 @@ + + + + + + + + @@ -1328,6 +1424,11 @@ + + + + + @@ -1392,6 +1493,14 @@ + + + + + + + + @@ -1482,6 +1591,14 @@ + + + + + + + + @@ -1497,6 +1614,11 @@ + + + + + @@ -1561,6 +1683,14 @@ + + + + + + + + @@ -1679,6 +1809,11 @@ + + + + + @@ -1743,6 +1878,14 @@ + + + + + + + + @@ -1822,6 +1965,11 @@ + + + + + @@ -1866,6 +2014,14 @@ + + + + + + + + @@ -1913,6 +2069,11 @@ + + + + + @@ -1937,6 +2098,14 @@ + + + + + + + + @@ -1952,6 +2121,11 @@ + + + + + @@ -2016,6 +2190,14 @@ + + + + + + + + @@ -2095,6 +2277,11 @@ + + + + + @@ -2159,6 +2346,14 @@ + + + + + + + + @@ -2238,6 +2433,11 @@ + + + + + @@ -2302,6 +2502,14 @@ + + + + + + + + @@ -2381,6 +2589,11 @@ + + + + + @@ -2445,6 +2658,14 @@ + + + + + + + + @@ -2524,6 +2745,11 @@ + + + + + @@ -2588,6 +2814,14 @@ + + + + + + + + @@ -2652,6 +2886,19 @@ + + + + + + + + + + + + + @@ -2667,6 +2914,11 @@ + + + + + @@ -2731,6 +2983,14 @@ + + + + + + + + @@ -2810,6 +3070,11 @@ + + + + + @@ -2874,6 +3139,14 @@ + + + + + + + + @@ -2991,6 +3264,14 @@ + + + + + + + + @@ -2999,6 +3280,14 @@ + + + + + + + + @@ -3082,6 +3371,9 @@ + + + @@ -3120,6 +3412,14 @@ + + + + + + + + @@ -3169,6 +3469,9 @@ + + + @@ -3322,6 +3625,14 @@ + + + + + + + + @@ -3466,6 +3777,14 @@ + + + + + + + + @@ -3610,6 +3929,14 @@ + + + + + + + + @@ -3703,6 +4030,14 @@ + + + + + + + + @@ -3784,6 +4119,12 @@ +<<<<<<< HEAD +======= + + + +>>>>>>> fa63072b (gradlew --write-verification-metadata) @@ -3822,6 +4163,14 @@ + + + + + + + + @@ -3878,6 +4227,11 @@ + + + + + @@ -3944,6 +4298,14 @@ + + + + + + + + @@ -3992,6 +4354,14 @@ + + + + + + + + @@ -4044,6 +4414,9 @@ + + + @@ -4144,6 +4517,14 @@ + + + + + + + + @@ -4197,6 +4578,14 @@ + + + + + + + + @@ -4245,6 +4634,14 @@ + + + + + + + + @@ -4290,6 +4687,14 @@ + + + + + + + + @@ -4338,6 +4743,11 @@ + + + + + @@ -4396,6 +4806,14 @@ + + + + + + + + @@ -4441,6 +4859,11 @@ + + + + + @@ -4474,6 +4897,14 @@ + + + + + + + + @@ -4522,6 +4953,11 @@ + + + + + @@ -4570,6 +5006,14 @@ + + + + + + + + @@ -4618,6 +5062,14 @@ + + + + + + + + @@ -4669,6 +5121,14 @@ + + + + + + + + @@ -4747,6 +5207,14 @@ + + + + + + + + @@ -4795,6 +5263,14 @@ + + + + + + + + @@ -4843,6 +5319,11 @@ + + + + + @@ -4896,6 +5377,14 @@ + + + + + + + + @@ -4992,6 +5481,14 @@ + + + + + + + + @@ -5072,6 +5569,11 @@ + + + + + @@ -5104,6 +5606,14 @@ + + + + + + + + @@ -5144,6 +5654,14 @@ + + + + + + + + @@ -5184,6 +5702,14 @@ + + + + + + + + @@ -5216,6 +5742,14 @@ + + + + + + + + @@ -5273,6 +5807,11 @@ + + + + + @@ -5305,6 +5844,14 @@ + + + + + + + + @@ -5345,6 +5892,11 @@ + + + + + @@ -5377,6 +5929,14 @@ + + + + + + + + @@ -5851,6 +6411,19 @@ + + + + + + + + + + + + + @@ -5859,6 +6432,14 @@ + + + + + + + + @@ -6048,6 +6629,14 @@ + + + + + + + + @@ -6208,6 +6797,14 @@ + + + + + + + + @@ -6352,6 +6949,14 @@ + + + + + + + + @@ -6496,6 +7101,14 @@ + + + + + + + + @@ -6640,6 +7253,14 @@ + + + + + + + + @@ -6784,6 +7405,14 @@ + + + + + + + + @@ -7008,6 +7637,14 @@ + + + + + + + + @@ -7088,6 +7725,14 @@ + + + + + + + + @@ -7232,6 +7877,14 @@ + + + + + + + + @@ -7376,6 +8029,14 @@ + + + + + + + + @@ -7520,6 +8181,14 @@ + + + + + + + + @@ -7664,6 +8333,14 @@ + + + + + + + + @@ -7808,6 +8485,14 @@ + + + + + + + + @@ -7952,6 +8637,14 @@ + + + + + + + + @@ -8096,6 +8789,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8360,6 +9093,14 @@ + + + + + + + + @@ -8504,6 +9245,14 @@ + + + + + + + + @@ -8648,6 +9397,14 @@ + + + + + + + + @@ -8792,6 +9549,14 @@ + + + + + + + + @@ -8936,6 +9701,14 @@ + + + + + + + + @@ -9080,6 +9853,14 @@ + + + + + + + + @@ -9224,6 +10005,14 @@ + + + + + + + + @@ -9432,6 +10221,14 @@ + + + + + + + + @@ -9576,6 +10373,14 @@ + + + + + + + + @@ -9616,6 +10421,14 @@ + + + + + + + + @@ -9760,6 +10573,14 @@ + + + + + + + + @@ -9904,6 +10725,14 @@ + + + + + + + + @@ -10072,6 +10901,14 @@ + + + + + + + + @@ -10264,6 +11101,14 @@ + + + + + + + + @@ -10344,6 +11189,14 @@ + + + + + + + + @@ -10424,6 +11277,14 @@ + + + + + + + + @@ -10568,6 +11429,14 @@ + + + + + + + + @@ -10648,6 +11517,14 @@ + + + + + + + + @@ -10728,6 +11605,14 @@ + + + + + + + + @@ -10808,6 +11693,14 @@ + + + + + + + + @@ -10888,6 +11781,14 @@ + + + + + + + + @@ -11032,6 +11933,14 @@ + + + + + + + + @@ -11176,6 +12085,14 @@ + + + + + + + + @@ -13656,6 +14573,14 @@ + + + + + + + + @@ -13672,6 +14597,14 @@ + + + + + + + + @@ -13823,6 +14756,14 @@ + + + + + + + + @@ -13863,6 +14804,14 @@ + + + + + + + + @@ -13895,6 +14844,14 @@ + + + + + + + + @@ -13911,6 +14868,14 @@ + + + + + + + + @@ -13927,6 +14892,22 @@ + + + + + + + + + + + + + + + + @@ -15589,11 +16570,24 @@ + + + + + + + + + + + + + @@ -15615,6 +16609,14 @@ + + + + + + + + @@ -15636,6 +16638,11 @@ + + + + + @@ -15699,6 +16706,14 @@ + + + + + + + + @@ -15740,6 +16755,11 @@ + + + + + @@ -15838,6 +16858,19 @@ + + + + + + + + + + + + + @@ -15854,6 +16887,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -16005,6 +17070,14 @@ + + + + + + + + @@ -16095,6 +17168,11 @@ + + + + + @@ -16172,6 +17250,14 @@ + + + + + + + + @@ -16208,6 +17294,9 @@ + + + @@ -16617,6 +17706,14 @@ + + + + + + + + @@ -16665,6 +17762,14 @@ + + + + + + + + @@ -16723,6 +17828,14 @@ + + + + + + + + @@ -16768,6 +17881,11 @@ + + + + + @@ -16784,6 +17902,19 @@ + + + + + + + + + + + + + @@ -16818,6 +17949,11 @@ + + + + + @@ -16855,6 +17991,14 @@ + + + + + + + + @@ -16989,6 +18133,11 @@ + + + + + @@ -17005,6 +18154,14 @@ + + + + + + + + @@ -17021,6 +18178,11 @@ + + + + + @@ -17037,6 +18199,14 @@ + + + + + + + + @@ -17148,6 +18318,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -18276,6 +19537,19 @@ + + + + + + + + + + + + + @@ -18354,7 +19628,12 @@ - + + + + + + @@ -18394,6 +19673,14 @@ + + + + + + + + @@ -18410,6 +19697,14 @@ + + + + + + + + @@ -18450,6 +19745,14 @@ + + + + + + + + @@ -18466,6 +19769,14 @@ + + + + + + + + @@ -18498,6 +19809,14 @@ + + + + + + + + @@ -18514,6 +19833,14 @@ + + + + + + + + @@ -18625,6 +19952,14 @@ + + + + + + + + @@ -18694,6 +20029,14 @@ + + + + + + + + @@ -18721,6 +20064,14 @@ + + + + + + + + @@ -18770,6 +20121,14 @@ + + + + + + + + @@ -18794,6 +20153,14 @@ + + + + + + + + @@ -18850,6 +20217,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -18986,6 +20534,14 @@ + + + + + + + + @@ -19116,6 +20672,14 @@ + + + + + + + + @@ -19180,6 +20744,22 @@ + + + + + + + + + + + + + + + + @@ -19196,6 +20776,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -19268,6 +20872,22 @@ + + + + + + + + + + + + + + + + @@ -19332,6 +20952,22 @@ + + + + + + + + + + + + + + + + @@ -19396,6 +21032,22 @@ + + + + + + + + + + + + + + + + @@ -19460,6 +21112,22 @@ + + + + + + + + + + + + + + + + @@ -19524,6 +21192,22 @@ + + + + + + + + + + + + + + + + @@ -19588,6 +21272,14 @@ + + + + + + + + @@ -19652,6 +21344,14 @@ + + + + + + + + @@ -19728,6 +21428,14 @@ + + + + + + + + @@ -19792,6 +21500,14 @@ + + + + + + + + @@ -19856,6 +21572,14 @@ + + + + + + + + @@ -19973,6 +21697,14 @@ + + + + + + + + @@ -20053,6 +21785,14 @@ + + + + + + + + @@ -20173,6 +21913,14 @@ + + + + + + + + @@ -20237,6 +21985,14 @@ + + + + + + + + @@ -20298,6 +22054,14 @@ + + + + + + + + @@ -20306,6 +22070,14 @@ + + + + + + + + @@ -20442,6 +22214,22 @@ + + + + + + + + + + + + + + + + @@ -20793,6 +22581,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -20890,6 +22702,19 @@ + + + + + + + + + + + + + @@ -21002,6 +22827,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -21119,6 +22965,14 @@ + + + + + + + + @@ -21183,6 +23037,14 @@ + + + + + + + + @@ -21247,6 +23109,14 @@ + + + + + + + + @@ -21311,6 +23181,14 @@ + + + + + + + + @@ -21375,6 +23253,14 @@ + + + + + + + + @@ -21441,6 +23327,19 @@ + + + + + + + + + + + + + @@ -21465,6 +23364,11 @@ + + + + + @@ -21490,6 +23394,11 @@ + + + + + @@ -21533,6 +23442,14 @@ + + + + + + + + @@ -21581,6 +23498,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -21596,6 +23552,11 @@ + + + + + @@ -21614,6 +23575,14 @@ + + + + + + + + @@ -21638,6 +23607,11 @@ + + + + + @@ -21653,6 +23627,14 @@ + + + + + + + + @@ -21709,6 +23691,14 @@ + + + + + + + + @@ -21724,6 +23714,11 @@ + + + + + @@ -21737,6 +23732,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -21793,6 +23817,14 @@ + + + + + + + + @@ -21806,6 +23838,14 @@ + + + + + + + + @@ -21817,6 +23857,14 @@ + + + + + + + + @@ -21850,6 +23898,9 @@ + + + @@ -21886,6 +23937,14 @@ + + + + + + + + @@ -21906,6 +23965,14 @@ + + + + + + + + @@ -21939,10 +24006,21 @@ + + + + + + + + + + + @@ -21976,6 +24054,9 @@ + + + @@ -22004,6 +24085,14 @@ + + + + + + + + @@ -22052,6 +24141,14 @@ + + + + + + + + @@ -22084,6 +24181,14 @@ + + + + + + + + @@ -22116,6 +24221,14 @@ + + + + + + + + @@ -22140,6 +24253,14 @@ + + + + + + + + @@ -22172,6 +24293,14 @@ + + + + + + + + @@ -22204,6 +24333,14 @@ + + + + + + + + @@ -22236,6 +24373,14 @@ + + + + + + + + @@ -22268,6 +24413,14 @@ + + + + + + + + @@ -22300,6 +24453,14 @@ + + + + + + + + @@ -22332,6 +24493,14 @@ + + + + + + + + @@ -22364,6 +24533,14 @@ + + + + + + + + @@ -22396,6 +24573,14 @@ + + + + + + + + @@ -22428,6 +24613,14 @@ + + + + + + + + @@ -22547,6 +24740,14 @@ + + + + + + + +