diff --git a/app/build.gradle b/app/build.gradle index f116ba1dd..fde0045fc 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/ActivitiesFragment.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/ActivitiesFragment.kt new file mode 100644 index 000000000..373adf76b --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/ActivitiesFragment.kt @@ -0,0 +1,185 @@ +package it.niedermann.owncloud.notes.activities + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import com.nextcloud.android.sso.helper.SingleAccountHelper +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.ActivitiesResult +import it.niedermann.owncloud.notes.activities.repository.VersionsRepository +import it.niedermann.owncloud.notes.activities.repository.VersionsResult +import it.niedermann.owncloud.notes.branding.BrandedFragment +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.FragmentActivitiesBinding +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 +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService + +/* + * 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 { + + lateinit var binding : FragmentActivitiesBinding + private lateinit var note: Note + private lateinit var account: Account + private lateinit var adapter: ActivityListAdapter + private var lastGiven: Long? = null + + private var executorService: ScheduledExecutorService? = null + + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + executorService = Executors.newSingleThreadScheduledExecutor() + binding = FragmentActivitiesBinding.inflate(inflater, container, false) + + initializeArguments() + initializeList() + fetchActivities() + + //DEBUG + fetchVersions() + //DEBUG + + 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() { + adapter = ActivityListAdapter( + requireContext(), + this + ) + binding.list.adapter = adapter + binding.swipeContainingList.setOnRefreshListener { fetchActivities() } + } + + override fun applyBrand(color: Int) { + val util = BrandingUtil.of(color, requireContext()) + util.platform.colorViewBackground(binding.getRoot()) + util.androidx.themeSwipeRefreshLayout(binding.swipeContainingList) + } + + private fun fetchActivities(reqLastGiven: Long? = null) { + val noteId = note.remoteId ?: return + + binding.swipeContainingList.isRefreshing = true + + executorService!!.submit(Runnable submit@{ + val ssoAccount = + SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()) + val repository = ActivitiesRepository(requireContext(), ssoAccount) + lifecycleScope.launch(Dispatchers.IO) { + val result = repository.fetchActivities(noteId, reqLastGiven) + withContext(Dispatchers.Main) { + when (result) { + is ActivitiesResult.Success -> { + val isFirstPage = reqLastGiven == null || lastGiven == null + lastGiven = result.lastGiven + showActivities(result.activities, isFirstPage) + } + + is ActivitiesResult.FailedNotModified -> { + Log.i(TAG, "Reached end of list (last page starting from item $lastGiven") + } + + is ActivitiesResult.Failed -> { + adapter.clear() + showEmptyContent(getString(R.string.activities_error)) + } + } + + binding.swipeContainingList.isRefreshing = false + } + } + + }) + } + + private fun fetchVersions() { + val noteId = note.remoteId ?: return + executorService!!.submit(Runnable submit@{ + val ssoAccount = + SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()) + val repository = VersionsRepository(requireContext(), ssoAccount) + lifecycleScope.launch(Dispatchers.IO) { + val result = repository.fetchVersions(noteId) + withContext(Dispatchers.Main) { + when (result) { + is VersionsResult.Success -> { + result.versions.forEach { + Log.d("DEBUG", "Version: ${it.remoteId} ${it.fileName} ${it.modifiedTimestamp}") + } + } + + is VersionsResult.Failed -> { + Log.e("DEBUG", "Versions failed") + } + } + } + } + }) + } + + private fun showActivities(activities: List, isFirstPage: Boolean) { + binding.emptyList.visibility = View.GONE + if (isFirstPage) adapter.clear() + adapter.addItems(activities) + } + + private fun showEmptyContent(message: String) = + with (binding.emptyList) { + visibility = View.VISIBLE + text = message + } + + override fun onItemClick(activity: Activity) { + TODO("Not yet implemented") + } + + override fun onEndOfListReached() { + if (adapter.itemCount < ActivitiesRepository.PAGE_SIZE) + return + fetchActivities(lastGiven) + } + + 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/ActivityListAdapter.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivityListAdapter.kt new file mode 100644 index 000000000..5d2978dd4 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/ActivityListAdapter.kt @@ -0,0 +1,121 @@ +package it.niedermann.owncloud.notes.activities.adapter + +import android.annotation.SuppressLint +import android.content.Context +import android.text.format.DateFormat +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 it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.activities.model.Activity +import it.niedermann.owncloud.notes.databinding.ActivityListItemBinding + +class ActivityListAdapter ( + private val context: Context, + private val listener: OnActivityClickListener +) : RecyclerView.Adapter() { + + private val items = arrayListOf() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): RecyclerView.ViewHolder = + ActivityViewHolder( + ActivityListItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (position == itemCount - 1) { + listener.onEndOfListReached() + } + (holder as ActivityViewHolder).bind(items[position], listener) + } + + override fun getItemCount(): Int = items.size + + fun addItems(it: List) { + val prevItems = itemCount + this.items.addAll(it) + notifyItemRangeInserted(prevItems, it.size) + } + + @SuppressLint("NotifyDataSetChanged") + fun clear() { + this.items.clear() + notifyDataSetChanged() + } + + private class ActivityViewHolder( + val binding: ActivityListItemBinding, + //val brandingUtil: BrandingUtil + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(activity: Activity, listener: OnActivityClickListener) { + bindDateTime(activity) + bindSubject(activity) + bindMessage(activity) + bindIcon(activity) + binding.root.setOnClickListener { listener.onItemClick(activity) } + } + + private fun bindDateTime(activity: Activity) { + binding.datetime.apply { + visibility = View.VISIBLE + text = DateFormat.format(TIME_PATTERN, activity.datetime.time) + } + } + + private fun bindSubject(activity: Activity) { + binding.subject.text = activity.subject + } + + private fun bindMessage(activity: Activity) { + binding.message.apply { + when { + activity.message.isNullOrEmpty() -> visibility = View.GONE + else -> { + visibility = View.VISIBLE + text = activity.message + } + } + } + } + + private fun bindIcon(activity: Activity) { + if (activity.icon.isNullOrEmpty()) { + return + } + + Glide + .with(binding.icon.context) + .load(activity.icon.toUri()) + .placeholder( R.drawable.ic_activity) + .error(R.drawable.ic_activity) + .into(binding.icon) + + /* + if (activity.icon.endsWith(COLORED_ICON_SUFFIX, ignoreCase = true)) { + binding.icon.imageTintList = null + } else { + brandingUtil.platform.colorImageView(binding.icon, ColorRole.ON_SURFACE_VARIANT) + } + */ + + } + + companion object { + private const val TIME_PATTERN = "HH:mm" + private const val COLORED_ICON_SUFFIX = "-color.svg" + } + + } + +} \ 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..dc0c92d41 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/adapter/OnActivityClickListener.kt @@ -0,0 +1,8 @@ +package it.niedermann.owncloud.notes.activities.adapter + +import it.niedermann.owncloud.notes.activities.model.Activity + +interface OnActivityClickListener { + fun onItemClick(activity: Activity) + fun onEndOfListReached() +} \ 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..8bd719420 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/model/Activity.kt @@ -0,0 +1,24 @@ +/* + * 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? +) 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..72ecdbe8e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/ActivitiesRepository.kt @@ -0,0 +1,37 @@ +package it.niedermann.owncloud.notes.activities.repository + +import android.content.Context +import com.nextcloud.android.sso.model.SingleSignOnAccount +import it.niedermann.owncloud.notes.persistence.ApiProvider +import java.net.HttpURLConnection + +class ActivitiesRepository( + private val context: Context, + private val ssoAccount: SingleSignOnAccount +) { + + private val api by lazy { ApiProvider.getInstance().getActivitiesAPI(context, ssoAccount) } + + fun fetchActivities(noteId: Long, since: Long? = null): ActivitiesResult { + val response = api.fetchActivities(noteId, since, PAGE_SIZE).execute() + + if (response.isSuccessful) { + return ActivitiesResult.Success( + response.body()?.ocs?.data ?: listOf(), + response.headers()[HEADER_LAST_GIVEN]?.toLongOrNull() + ) + } + + if (response.code() == HttpURLConnection.HTTP_NOT_MODIFIED) { + return ActivitiesResult.FailedNotModified() + } + + return ActivitiesResult.Failed() + } + + companion object { + 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..d2cd6b282 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/ActivitiesResult.kt @@ -0,0 +1,18 @@ +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 class FailedNotModified( + override val descriptionId: Int = R.string.activities_fetch_failed_not_modified + ) : ActivitiesResult(descriptionId) + + 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/VersionsRepository.kt b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/VersionsRepository.kt new file mode 100644 index 000000000..07f89a092 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/VersionsRepository.kt @@ -0,0 +1,18 @@ +package it.niedermann.owncloud.notes.activities.repository + +import android.content.Context +import com.nextcloud.android.sso.model.SingleSignOnAccount +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) + } + +} \ 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..2ecf814e4 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/activities/repository/VersionsResult.kt @@ -0,0 +1,10 @@ +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..201ded2ec 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) { + Intent detailsIntent = ItemDetailsActivity.getActivityStartIntent(requireContext(), note, localAccount); + startActivity(detailsIntent); + return true; } else if (itemId == MENU_ID_PIN) { pinNoteToHome(); return true; 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/itemdetails/ItemDetailsActivity.kt b/app/src/main/java/it/niedermann/owncloud/notes/itemdetails/ItemDetailsActivity.kt new file mode 100644 index 000000000..e37fe4a04 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/itemdetails/ItemDetailsActivity.kt @@ -0,0 +1,129 @@ +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 + + 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") + } + + 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( + getString(R.string.activities_title), + AppCompatResources.getDrawable(this, R.drawable.nav_activity), + ActivitiesFragment.newInstance(note, account) + ), + Page( + getString(R.string.share), + AppCompatResources.getDrawable(this, R.drawable.ic_share_white_24dp), + NoteShareFragment.newInstance(note, account) + ) + ) + + binding.pager.adapter = PagerAdapter(pages) + 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 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 + } + + companion object { + const val ARG_NOTE: String = "note" + const val ARG_ACCOUNT: String = "account" + + @JvmStatic + fun getActivityStartIntent(context: Context, note: Note, account: Account): Intent { + val bundle = Bundle() + bundle.putSerializable(ARG_NOTE, note) + bundle.putSerializable(ARG_ACCOUNT, account) + + 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 ed7b84ca6..f5c07fe61 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 @@ -100,6 +100,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.itemdetails.ItemDetailsActivity; 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; @@ -114,7 +115,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; @@ -915,10 +915,10 @@ public void onNoteFavoriteClick(int position, View view) { } @Override - public void openShare(int position) { + public void openDetails(int position) { if (adapter.getItem(position) instanceof Note note) { mainViewModel.getCurrentAccount().observe(this, (account) -> { - final Intent intent = NoteShareActivity.getActivityStartIntent(note, account, this); + final Intent intent = ItemDetailsActivity.getActivityStartIntent(this, note, account); startActivity(intent); }); } 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..0e49e6970 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,6 +101,8 @@ public boolean onCreateActionMode(ActionMode mode, Menu menu) { @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + // Info menu makes sense only for single selection + menu.findItem(R.id.menu_details).setEnabled(tracker.getSelection().size() == 1); return false; } @@ -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..d68ac162a 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.openDetails(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..883ab60c3 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/VersionsAPI.kt @@ -0,0 +1,84 @@ +/* + * 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 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.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.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) + VersionsResult.Success(readData(response, fileId)) + } catch (e: Exception) { + VersionsResult.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 + } +} 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..38a41f395 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/share/NoteShareFragment.kt @@ -0,0 +1,894 @@ +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 var resultLauncher: ActivityResultLauncher? = null + 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 ssoAcc = + SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()) + repository = ShareRepository(requireContext(), 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(requireContext())) + binding!!.pickContactEmailBtn.setOnClickListener { checkContactPermission() } + binding!!.btnShareButton.setOnClickListener { + ShareUtil.openShareDialog( + requireContext(), + 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 + ) + AvatarLoader.load(requireContext(), 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 { + copyToClipboard(requireContext(), share.shareLink) + } + } + + private fun copyAndShareFileLink(link: String?) { + copyToClipboard(requireContext(), 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) { } + val util = BrandingUtil.of(BrandingUtil.readBrandMainColor(requireContext()), requireContext()) + 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() { + if (ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED + ) { + pickContactEmail() + } else { + requestContactPermissionLauncher.launch(Manifest.permission.READ_CONTACTS) + } + } + + private fun pickContactEmail() { + val intent = Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI) + + // FIXME: + if (intent.resolveActivity(requireContext().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 util = BrandingUtil.of(color, requireContext()) + 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..5e24d0303 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 openDetails(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/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/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/activity_item_details.xml b/app/src/main/res/layout/activity_item_details.xml new file mode 100644 index 000000000..e8e2304fd --- /dev/null +++ b/app/src/main/res/layout/activity_item_details.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..0fa624b92 --- /dev/null +++ b/app/src/main/res/layout/activity_list_item.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + 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_notes_list_note_item_grid.xml b/app/src/main/res/layout/item_notes_list_note_item_grid.xml index 2191dba0c..21425b0a6 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 @@ -126,20 +126,18 @@ tools:text="27.11." /> + app:iconTint="@color/defaultBrand" /> 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..23239131e 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 @@ -115,20 +115,18 @@ tools:text="27.11." /> + app:iconTint="@color/defaultBrand" /> 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..995a859eb 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 @@ -169,19 +169,17 @@ tools:text="@tools:sample/lorem/random" /> + app:iconTint="@color/defaultBrand" /> 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..73a3ff8af 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -82,4 +82,13 @@ 56dp 60dp 24dp + + @dimen/spacer_2x + + 32dp + 32dp + 16dp + 24dp + -3dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80d4284fc..6633d809b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -537,4 +537,21 @@ Share expires on %1$s Dismiss Account not found! + + Details + + Activities + Unable to fetch activities + No activity present for this note + Activity + No events like additions, changes and shares yet. + An error occurred loading activities + + Note.md + Favorite +  • + 12:23:45 + This is a placeholder + + 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 830e9a0bb..97e40397a 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -15,6 +15,7 @@ + @@ -111,6 +112,7 @@ + @@ -180,6 +182,7 @@ + @@ -418,6 +421,11 @@ + + + + + @@ -544,6 +552,14 @@ + + + + + + + + @@ -597,6 +613,14 @@ + + + + + + + + @@ -621,6 +645,14 @@ + + + + + + + + @@ -705,6 +737,11 @@ + + + + + @@ -734,6 +771,14 @@ + + + + + + + + @@ -781,6 +826,14 @@ + + + + + + + + @@ -881,6 +934,11 @@ + + + + + @@ -896,6 +954,11 @@ + + + + + @@ -960,6 +1023,14 @@ + + + + + + + + @@ -1039,6 +1110,11 @@ + + + + + @@ -1103,6 +1179,14 @@ + + + + + + + + @@ -1182,6 +1266,11 @@ + + + + + @@ -1246,6 +1335,14 @@ + + + + + + + + @@ -1325,6 +1422,11 @@ + + + + + @@ -1389,6 +1491,14 @@ + + + + + + + + @@ -1479,6 +1589,14 @@ + + + + + + + + @@ -1494,6 +1612,11 @@ + + + + + @@ -1558,6 +1681,14 @@ + + + + + + + + @@ -1676,6 +1807,11 @@ + + + + + @@ -1740,6 +1876,14 @@ + + + + + + + + @@ -1819,6 +1963,11 @@ + + + + + @@ -1863,6 +2012,14 @@ + + + + + + + + @@ -1910,6 +2067,11 @@ + + + + + @@ -1934,6 +2096,14 @@ + + + + + + + + @@ -1949,6 +2119,11 @@ + + + + + @@ -2013,6 +2188,14 @@ + + + + + + + + @@ -2092,6 +2275,11 @@ + + + + + @@ -2156,6 +2344,14 @@ + + + + + + + + @@ -2235,6 +2431,11 @@ + + + + + @@ -2299,6 +2500,14 @@ + + + + + + + + @@ -2378,6 +2587,11 @@ + + + + + @@ -2442,6 +2656,14 @@ + + + + + + + + @@ -2521,6 +2743,11 @@ + + + + + @@ -2585,6 +2812,14 @@ + + + + + + + + @@ -2649,6 +2884,19 @@ + + + + + + + + + + + + + @@ -2664,6 +2912,11 @@ + + + + + @@ -2728,6 +2981,14 @@ + + + + + + + + @@ -2807,6 +3068,11 @@ + + + + + @@ -2871,6 +3137,14 @@ + + + + + + + + @@ -2988,6 +3262,14 @@ + + + + + + + + @@ -2996,6 +3278,14 @@ + + + + + + + + @@ -3075,6 +3365,14 @@ + + + + + + + + @@ -3112,6 +3410,14 @@ + + + + + + + + @@ -3161,6 +3467,9 @@ + + + @@ -3314,6 +3623,14 @@ + + + + + + + + @@ -3458,6 +3775,14 @@ + + + + + + + + @@ -3602,6 +3927,14 @@ + + + + + + + + @@ -3695,6 +4028,14 @@ + + + + + + + + @@ -3772,6 +4113,14 @@ + + + + + + + + @@ -3809,6 +4158,14 @@ + + + + + + + + @@ -3865,6 +4222,11 @@ + + + + + @@ -3931,6 +4293,14 @@ + + + + + + + + @@ -3979,6 +4349,14 @@ + + + + + + + + @@ -4031,6 +4409,9 @@ + + + @@ -4131,6 +4512,14 @@ + + + + + + + + @@ -4184,6 +4573,14 @@ + + + + + + + + @@ -4232,6 +4629,14 @@ + + + + + + + + @@ -4277,6 +4682,14 @@ + + + + + + + + @@ -4325,6 +4738,11 @@ + + + + + @@ -4383,6 +4801,14 @@ + + + + + + + + @@ -4428,6 +4854,11 @@ + + + + + @@ -4461,6 +4892,14 @@ + + + + + + + + @@ -4509,6 +4948,11 @@ + + + + + @@ -4557,6 +5001,14 @@ + + + + + + + + @@ -4605,6 +5057,14 @@ + + + + + + + + @@ -4656,6 +5116,14 @@ + + + + + + + + @@ -4734,6 +5202,14 @@ + + + + + + + + @@ -4782,6 +5258,14 @@ + + + + + + + + @@ -4830,6 +5314,11 @@ + + + + + @@ -4883,6 +5372,14 @@ + + + + + + + + @@ -4979,6 +5476,14 @@ + + + + + + + + @@ -5059,6 +5564,11 @@ + + + + + @@ -5091,6 +5601,14 @@ + + + + + + + + @@ -5131,6 +5649,14 @@ + + + + + + + + @@ -5171,6 +5697,14 @@ + + + + + + + + @@ -5203,6 +5737,14 @@ + + + + + + + + @@ -5260,6 +5802,11 @@ + + + + + @@ -5292,6 +5839,14 @@ + + + + + + + + @@ -5332,6 +5887,11 @@ + + + + + @@ -5364,6 +5924,14 @@ + + + + + + + + @@ -5838,6 +6406,19 @@ + + + + + + + + + + + + + @@ -5846,6 +6427,14 @@ + + + + + + + + @@ -6035,6 +6624,14 @@ + + + + + + + + @@ -6195,8 +6792,16 @@ - - + + + + + + + + + + @@ -6339,6 +6944,14 @@ + + + + + + + + @@ -6483,6 +7096,14 @@ + + + + + + + + @@ -6627,6 +7248,14 @@ + + + + + + + + @@ -6771,6 +7400,14 @@ + + + + + + + + @@ -6995,6 +7632,14 @@ + + + + + + + + @@ -7075,6 +7720,14 @@ + + + + + + + + @@ -7219,6 +7872,14 @@ + + + + + + + + @@ -7363,6 +8024,14 @@ + + + + + + + + @@ -7507,6 +8176,14 @@ + + + + + + + + @@ -7651,6 +8328,14 @@ + + + + + + + + @@ -7795,6 +8480,14 @@ + + + + + + + + @@ -7939,6 +8632,14 @@ + + + + + + + + @@ -8083,6 +8784,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8347,6 +9088,14 @@ + + + + + + + + @@ -8491,6 +9240,14 @@ + + + + + + + + @@ -8635,6 +9392,14 @@ + + + + + + + + @@ -8779,6 +9544,14 @@ + + + + + + + + @@ -8923,6 +9696,14 @@ + + + + + + + + @@ -9067,6 +9848,14 @@ + + + + + + + + @@ -9211,6 +10000,14 @@ + + + + + + + + @@ -9419,6 +10216,14 @@ + + + + + + + + @@ -9563,6 +10368,14 @@ + + + + + + + + @@ -9603,6 +10416,14 @@ + + + + + + + + @@ -9747,6 +10568,14 @@ + + + + + + + + @@ -9891,6 +10720,14 @@ + + + + + + + + @@ -10059,6 +10896,14 @@ + + + + + + + + @@ -10251,6 +11096,14 @@ + + + + + + + + @@ -10331,6 +11184,14 @@ + + + + + + + + @@ -10411,6 +11272,14 @@ + + + + + + + + @@ -10555,6 +11424,14 @@ + + + + + + + + @@ -10635,6 +11512,14 @@ + + + + + + + + @@ -10715,6 +11600,14 @@ + + + + + + + + @@ -10795,6 +11688,14 @@ + + + + + + + + @@ -10875,6 +11776,14 @@ + + + + + + + + @@ -11019,6 +11928,14 @@ + + + + + + + + @@ -11163,6 +12080,14 @@ + + + + + + + + @@ -13643,6 +14568,14 @@ + + + + + + + + @@ -13659,6 +14592,14 @@ + + + + + + + + @@ -13810,6 +14751,14 @@ + + + + + + + + @@ -13850,6 +14799,14 @@ + + + + + + + + @@ -13882,6 +14839,14 @@ + + + + + + + + @@ -13898,6 +14863,14 @@ + + + + + + + + @@ -13914,6 +14887,22 @@ + + + + + + + + + + + + + + + + @@ -15576,11 +16565,24 @@ + + + + + + + + + + + + + @@ -15602,6 +16604,14 @@ + + + + + + + + @@ -15623,6 +16633,11 @@ + + + + + @@ -15686,6 +16701,14 @@ + + + + + + + + @@ -15727,6 +16750,11 @@ + + + + + @@ -15825,6 +16853,19 @@ + + + + + + + + + + + + + @@ -15841,6 +16882,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -15992,6 +17065,14 @@ + + + + + + + + @@ -16082,6 +17163,11 @@ + + + + + @@ -16159,6 +17245,14 @@ + + + + + + + + @@ -16195,6 +17289,9 @@ + + + @@ -16599,6 +17696,14 @@ + + + + + + + + @@ -16647,6 +17752,14 @@ + + + + + + + + @@ -16705,6 +17818,14 @@ + + + + + + + + @@ -16750,6 +17871,11 @@ + + + + + @@ -16766,6 +17892,19 @@ + + + + + + + + + + + + + @@ -16800,6 +17939,11 @@ + + + + + @@ -16837,6 +17981,14 @@ + + + + + + + + @@ -16971,6 +18123,11 @@ + + + + + @@ -16987,6 +18144,14 @@ + + + + + + + + @@ -17003,6 +18168,11 @@ + + + + + @@ -17019,6 +18189,14 @@ + + + + + + + + @@ -17130,53 +18308,144 @@ - - - + + + - - + + + + + + + - - - + + + - - - + + + - - + + - - - - - - + + + - - - + + + - - + + - - - + + + - - - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -18258,6 +19527,19 @@ + + + + + + + + + + + + + @@ -18336,6 +19618,11 @@ + + + + + @@ -18376,6 +19663,14 @@ + + + + + + + + @@ -18392,6 +19687,14 @@ + + + + + + + + @@ -18432,6 +19735,14 @@ + + + + + + + + @@ -18448,6 +19759,14 @@ + + + + + + + + @@ -18480,6 +19799,14 @@ + + + + + + + + @@ -18496,6 +19823,14 @@ + + + + + + + + @@ -18607,6 +19942,14 @@ + + + + + + + + @@ -18676,6 +20019,14 @@ + + + + + + + + @@ -18703,6 +20054,14 @@ + + + + + + + + @@ -18752,6 +20111,14 @@ + + + + + + + + @@ -18776,6 +20143,14 @@ + + + + + + + + @@ -18832,6 +20207,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -18968,6 +20524,14 @@ + + + + + + + + @@ -19098,6 +20662,14 @@ + + + + + + + + @@ -19162,6 +20734,22 @@ + + + + + + + + + + + + + + + + @@ -19178,6 +20766,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -19250,6 +20862,22 @@ + + + + + + + + + + + + + + + + @@ -19314,6 +20942,22 @@ + + + + + + + + + + + + + + + + @@ -19378,6 +21022,22 @@ + + + + + + + + + + + + + + + + @@ -19442,6 +21102,22 @@ + + + + + + + + + + + + + + + + @@ -19506,6 +21182,22 @@ + + + + + + + + + + + + + + + + @@ -19570,6 +21262,14 @@ + + + + + + + + @@ -19634,6 +21334,14 @@ + + + + + + + + @@ -19710,6 +21418,14 @@ + + + + + + + + @@ -19774,6 +21490,14 @@ + + + + + + + + @@ -19838,6 +21562,14 @@ + + + + + + + + @@ -19955,6 +21687,14 @@ + + + + + + + + @@ -20035,6 +21775,14 @@ + + + + + + + + @@ -20155,6 +21903,14 @@ + + + + + + + + @@ -20219,6 +21975,14 @@ + + + + + + + + @@ -20280,6 +22044,14 @@ + + + + + + + + @@ -20288,6 +22060,14 @@ + + + + + + + + @@ -20424,6 +22204,22 @@ + + + + + + + + + + + + + + + + @@ -20775,6 +22571,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -20872,6 +22692,19 @@ + + + + + + + + + + + + + @@ -20984,6 +22817,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -21101,6 +22955,14 @@ + + + + + + + + @@ -21165,6 +23027,14 @@ + + + + + + + + @@ -21229,6 +23099,14 @@ + + + + + + + + @@ -21293,6 +23171,14 @@ + + + + + + + + @@ -21357,6 +23243,14 @@ + + + + + + + + @@ -21423,6 +23317,19 @@ + + + + + + + + + + + + + @@ -21447,6 +23354,11 @@ + + + + + @@ -21472,6 +23384,11 @@ + + + + + @@ -21515,6 +23432,14 @@ + + + + + + + + @@ -21563,6 +23488,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -21578,6 +23542,11 @@ + + + + + @@ -21596,6 +23565,14 @@ + + + + + + + + @@ -21620,6 +23597,11 @@ + + + + + @@ -21635,6 +23617,14 @@ + + + + + + + + @@ -21691,6 +23681,14 @@ + + + + + + + + @@ -21706,6 +23704,11 @@ + + + + + @@ -21719,6 +23722,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -21775,6 +23807,14 @@ + + + + + + + + @@ -21788,6 +23828,14 @@ + + + + + + + + @@ -21799,6 +23847,14 @@ + + + + + + + + @@ -21832,6 +23888,9 @@ + + + @@ -21868,6 +23927,14 @@ + + + + + + + + @@ -21888,6 +23955,14 @@ + + + + + + + + @@ -21921,10 +23996,21 @@ + + + + + + + + + + + @@ -21958,6 +24044,9 @@ + + + @@ -21986,6 +24075,14 @@ + + + + + + + + @@ -22034,6 +24131,14 @@ + + + + + + + + @@ -22066,6 +24171,14 @@ + + + + + + + + @@ -22098,6 +24211,14 @@ + + + + + + + + @@ -22122,6 +24243,14 @@ + + + + + + + + @@ -22154,6 +24283,14 @@ + + + + + + + + @@ -22186,6 +24323,14 @@ + + + + + + + + @@ -22218,6 +24363,14 @@ + + + + + + + + @@ -22250,6 +24403,14 @@ + + + + + + + + @@ -22282,6 +24443,14 @@ + + + + + + + + @@ -22314,6 +24483,14 @@ + + + + + + + + @@ -22346,6 +24523,14 @@ + + + + + + + + @@ -22378,6 +24563,14 @@ + + + + + + + + @@ -22410,6 +24603,14 @@ + + + + + + + + @@ -22529,6 +24730,14 @@ + + + + + + + +