Skip to content

Commit 6f40bdd

Browse files
Merge pull request #3377 from nextcloud/feat/versions-activities
Implement activities (files versions)
2 parents 8856f4b + 25a1d14 commit 6f40bdd

74 files changed

Lines changed: 3354 additions & 1095 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎LICENSES/BSD-2-Clause.txt‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Copyright (c) <year> <owner>
2+
3+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4+
5+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6+
7+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8+
9+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

‎app/build.gradle.kts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
* SPDX-FileCopyrightText: 2020 Hui-Ouyang16 <11710106@mail.sustech.edu.cn>
77
* SPDX-License-Identifier: GPL-3.0-or-later
88
*/
9+
@file:Suppress("MagicNumber")
910

1011
plugins {
1112
id("com.android.application")
13+
id("com.google.devtools.ksp")
1214
}
1315

1416
val kotlinVersion = rootProject.extra["kotlinVersion"] as String
@@ -130,6 +132,7 @@ dependencies {
130132
implementation("commons-httpclient:commons-httpclient:3.1") {
131133
exclude(group = "commons-logging", module = "commons-logging")
132134
}
135+
implementation("org.apache.jackrabbit:jackrabbit-webdav:2.13.5")
133136

134137
implementation("com.github.nextcloud:android-library:2.26.1") {
135138
exclude(group = "org.ogce", module = "xpp3")
@@ -156,7 +159,8 @@ dependencies {
156159
// Glide
157160
val glideVersion = "5.0.9"
158161
implementation("com.github.bumptech.glide:glide:$glideVersion")
159-
annotationProcessor("com.github.bumptech.glide:compiler:$glideVersion")
162+
ksp("com.github.bumptech.glide:ksp:$glideVersion")
163+
implementation("com.caverock:androidsvg-aar:1.4")
160164

161165
// Android X
162166
implementation("androidx.appcompat:appcompat:1.8.0")

‎app/src/main/AndroidManifest.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
</activity>
5959

6060
<activity
61-
android:name=".share.NoteShareActivity" />
61+
android:name=".itemdetails.ItemDetailsActivity" />
6262

6363
<activity
6464
android:name=".share.NoteShareDetailActivity" />
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Nextcloud Notes - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Daniele Verducci <daniele.verducci@nextcloud.com>
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
package it.niedermann.owncloud.notes.activities
9+
10+
import android.content.Context
11+
import android.util.Log
12+
import androidx.lifecycle.LifecycleCoroutineScope
13+
import com.nextcloud.android.sso.helper.SingleAccountHelper
14+
import it.niedermann.owncloud.notes.activities.ActivitiesFragment.Companion.TAG
15+
import it.niedermann.owncloud.notes.activities.adapter.ActivitiesData
16+
import it.niedermann.owncloud.notes.activities.repository.ActivitiesRepository
17+
import it.niedermann.owncloud.notes.activities.repository.model.ActivitiesResult
18+
import it.niedermann.owncloud.notes.activities.repository.VersionsRepository
19+
import it.niedermann.owncloud.notes.activities.repository.model.VersionsResult
20+
import kotlinx.coroutines.Dispatchers
21+
import kotlinx.coroutines.Job
22+
import kotlinx.coroutines.async
23+
import kotlinx.coroutines.launch
24+
import kotlinx.coroutines.withContext
25+
26+
class ActivitiesDataProvider (
27+
val context: Context,
28+
val lifecycleScope: LifecycleCoroutineScope,
29+
val noteId: Long,
30+
val listener: OnPageFetchedListener
31+
) {
32+
val data = ActivitiesData()
33+
private var job: Job? = null
34+
35+
private val ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(context)
36+
private val activitiesRepo = ActivitiesRepository(context, ssoAccount)
37+
private val versionsRepo = VersionsRepository(context, ssoAccount)
38+
39+
fun fetchFirstPage() {
40+
job?.cancel()
41+
job = lifecycleScope.launch {
42+
val (activitiesResult, versionsResult) = withContext(Dispatchers.IO) {
43+
val activities = async {
44+
activitiesRepo.fetchActivities(noteId)
45+
}
46+
val versions = async {
47+
versionsRepo.fetchVersions(noteId)
48+
}
49+
activities.await() to versions.await()
50+
}
51+
52+
if (!parseActivitiesResult(activitiesResult)) return@launch
53+
54+
if (!parseVersionsResult(versionsResult)) return@launch
55+
56+
listener.onPageFetched()
57+
}
58+
}
59+
60+
fun fetchNextPage() {
61+
if (job?.isActive == true) return
62+
63+
if (!data.activitiesMore) return
64+
65+
job = lifecycleScope.launch {
66+
val activitiesResult = withContext(Dispatchers.IO) {
67+
activitiesRepo.fetchActivities(noteId, data.activitiesLastGiven)
68+
}
69+
70+
if (!parseActivitiesResult(activitiesResult)) return@launch
71+
72+
listener.onPageFetched()
73+
}
74+
}
75+
76+
private fun parseActivitiesResult(activitiesResult: ActivitiesResult): Boolean {
77+
return when (activitiesResult) {
78+
is ActivitiesResult.Success -> {
79+
data.activitiesLastGiven = activitiesResult.lastGiven
80+
data.activitiesMore = activitiesResult.lastGiven != null
81+
data.activities.addAll(activitiesResult.activities)
82+
true
83+
}
84+
85+
is ActivitiesResult.FailedNoCapability -> {
86+
Log.d(TAG, "Server has no Activity endpoint")
87+
data.activitiesMore = false
88+
true
89+
}
90+
91+
is ActivitiesResult.Failed -> {
92+
Log.e(TAG, "Fetching activities failed")
93+
listener.onPageFetchError()
94+
false
95+
}
96+
}
97+
}
98+
99+
private fun parseVersionsResult(versionsResult: VersionsResult): Boolean {
100+
return when (versionsResult) {
101+
is VersionsResult.Success -> {
102+
data.versions.addAll(versionsResult.versions)
103+
true
104+
}
105+
is VersionsResult.Failed -> {
106+
Log.e(TAG, "Fetching versions failed")
107+
false
108+
}
109+
}
110+
}
111+
112+
fun reset() {
113+
data.activities.clear()
114+
data.versions.clear()
115+
data.activitiesLastGiven = null
116+
data.activitiesMore = true
117+
}
118+
119+
interface OnPageFetchedListener {
120+
fun onPageFetched()
121+
fun onPageFetchError()
122+
}
123+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Nextcloud Notes - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Daniele Verducci <daniele.verducci@nextcloud.com>
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
package it.niedermann.owncloud.notes.activities
9+
10+
import android.annotation.SuppressLint
11+
import android.os.Bundle
12+
import android.util.Log
13+
import android.view.LayoutInflater
14+
import android.view.View
15+
import android.view.ViewGroup
16+
import androidx.annotation.StringRes
17+
import androidx.lifecycle.lifecycleScope
18+
import com.google.android.material.snackbar.Snackbar
19+
import com.nextcloud.android.sso.helper.SingleAccountHelper
20+
import com.owncloud.android.lib.resources.files.model.FileVersion
21+
import it.niedermann.owncloud.notes.R
22+
import it.niedermann.owncloud.notes.activities.adapter.ActivityListAdapter
23+
import it.niedermann.owncloud.notes.activities.adapter.OnActivityClickListener
24+
import it.niedermann.owncloud.notes.activities.adapter.model.Activity
25+
import it.niedermann.owncloud.notes.activities.repository.ActivitiesRepository
26+
import it.niedermann.owncloud.notes.activities.repository.model.RestoreVersionsResult
27+
import it.niedermann.owncloud.notes.activities.repository.VersionsRepository
28+
import it.niedermann.owncloud.notes.branding.BrandedFragment
29+
import it.niedermann.owncloud.notes.branding.BrandedSnackbar
30+
import it.niedermann.owncloud.notes.branding.BrandingUtil
31+
import it.niedermann.owncloud.notes.databinding.FragmentActivitiesBinding
32+
import it.niedermann.owncloud.notes.persistence.NotesRepository
33+
import it.niedermann.owncloud.notes.persistence.entity.Account
34+
import it.niedermann.owncloud.notes.persistence.entity.Note
35+
import it.niedermann.owncloud.notes.shared.util.extensions.getSerializableArgument
36+
import kotlinx.coroutines.Dispatchers
37+
import kotlinx.coroutines.launch
38+
import kotlinx.coroutines.withContext
39+
40+
class ActivitiesFragment : BrandedFragment(), OnActivityClickListener, ActivitiesDataProvider.OnPageFetchedListener {
41+
42+
lateinit var binding : FragmentActivitiesBinding
43+
private lateinit var note: Note
44+
private lateinit var account: Account
45+
private lateinit var adapter: ActivityListAdapter
46+
47+
private lateinit var activityDataProvider: ActivitiesDataProvider
48+
49+
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
50+
adapter = ActivityListAdapter(requireContext(), this)
51+
binding = FragmentActivitiesBinding.inflate(inflater, container, false)
52+
53+
initializeArguments()
54+
initializeList()
55+
56+
return binding.root
57+
}
58+
59+
private fun initializeArguments() {
60+
val bundler = arguments
61+
note = bundler.getSerializableArgument(ARG_NOTE, Note::class.java)
62+
?: throw IllegalArgumentException("Argument $ARG_NOTE is mandatory")
63+
account = bundler.getSerializableArgument(ARG_ACCOUNT, Account::class.java)
64+
?: throw IllegalArgumentException("Argument $ARG_ACCOUNT is mandatory")
65+
}
66+
67+
private fun initializeList() {
68+
val id = note.remoteId
69+
if (id == null) {
70+
Log.e(TAG, "Unable to load list: note has no remoteId")
71+
return
72+
}
73+
74+
activityDataProvider = ActivitiesDataProvider(
75+
requireContext(),
76+
lifecycleScope,
77+
id,
78+
this
79+
)
80+
binding.list.adapter = adapter
81+
binding.swipeContainingList.setOnRefreshListener {
82+
// Reload list
83+
activityDataProvider.reset()
84+
activityDataProvider.fetchFirstPage()
85+
}
86+
87+
activityDataProvider.fetchFirstPage()
88+
}
89+
90+
override fun applyBrand(color: Int) {
91+
adapter.applyBrand(color)
92+
val util = BrandingUtil.of(color, requireContext())
93+
util.platform.colorViewBackground(binding.getRoot())
94+
util.androidx.themeSwipeRefreshLayout(binding.swipeContainingList)
95+
}
96+
97+
override fun onActivityItemClick(activity: Activity) = Unit
98+
99+
override fun onVersionItemClick(version: FileVersion) {
100+
showProgress(true)
101+
lifecycleScope.launch(Dispatchers.IO) {
102+
// Restore the version
103+
val ssoAccount =
104+
SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext())
105+
val repository = VersionsRepository(requireContext(), ssoAccount)
106+
val result = repository.restoreVersion(version)
107+
withContext(Dispatchers.Main) {
108+
when (result) {
109+
RestoreVersionsResult.Success -> syncNote()
110+
RestoreVersionsResult.FailedUnsupported ->
111+
showMessageAndHideProgress(R.string.version_restore_failed_unsupported)
112+
else -> showMessageAndHideProgress(R.string.version_restore_failed)
113+
}
114+
}
115+
}
116+
}
117+
118+
private fun syncNote() {
119+
// Sync the restored note
120+
val noteRepo = NotesRepository.getInstance(binding.root.context)
121+
noteRepo.addCallbackPull(account, {
122+
// Used in node editor: RESULT_OK means reload the note
123+
requireActivity().setResult(android.app.Activity.RESULT_OK)
124+
125+
// Reload list
126+
activityDataProvider.reset()
127+
activityDataProvider.fetchFirstPage()
128+
129+
// Notify user
130+
showMessageAndHideProgress(R.string.version_restored)
131+
})
132+
noteRepo.scheduleSync(account, false)
133+
}
134+
135+
private fun showProgress(show: Boolean) { binding.swipeContainingList.isRefreshing = show }
136+
137+
private fun showMessageAndHideProgress(@StringRes message: Int) {
138+
showProgress(false)
139+
BrandedSnackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
140+
}
141+
142+
override fun lastActivityReached() {
143+
if (adapter.itemCount < ActivitiesRepository.PAGE_SIZE)
144+
return
145+
activityDataProvider.fetchNextPage()
146+
}
147+
148+
@SuppressLint("NotifyDataSetChanged")
149+
override fun onPageFetched() {
150+
showProgress(false)
151+
binding.emptyList.visibility = View.GONE
152+
adapter.items.clear()
153+
adapter.items.addAll(
154+
activityDataProvider.data.getAggregatedList(requireContext())
155+
)
156+
adapter.lastActivity = activityDataProvider.data.activities.lastOrNull()
157+
adapter.notifyDataSetChanged()
158+
}
159+
160+
override fun onPageFetchError() {
161+
showProgress(false)
162+
if (adapter.items.isEmpty()) binding.emptyList.visibility = View.VISIBLE
163+
BrandedSnackbar.make(binding.root, R.string.activities_error, Snackbar.LENGTH_SHORT).show()
164+
}
165+
166+
companion object {
167+
val TAG = ActivitiesFragment::class.simpleName
168+
const val ARG_NOTE = "NOTE"
169+
const val ARG_ACCOUNT = "ACCOUNT"
170+
171+
fun newInstance(note: Note, account: Account) : ActivitiesFragment = ActivitiesFragment().apply {
172+
arguments = Bundle().apply {
173+
putSerializable(ARG_NOTE, note)
174+
putSerializable(ARG_ACCOUNT, account)
175+
}
176+
}
177+
}
178+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Nextcloud Notes - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Daniele Verducci <daniele.verducci@nextcloud.com>
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
package it.niedermann.owncloud.notes.activities.adapter
9+
10+
import android.content.Context
11+
import com.owncloud.android.lib.resources.files.model.FileVersion
12+
import it.niedermann.owncloud.notes.activities.adapter.model.Activity
13+
import it.niedermann.owncloud.notes.activities.adapter.model.ActivityListItem
14+
import it.niedermann.owncloud.notes.activities.adapter.model.ActivityListItem.FileVersionItem
15+
16+
data class ActivitiesData(
17+
val activities: ArrayList<Activity> = arrayListOf(),
18+
val versions: ArrayList<FileVersion> = arrayListOf(),
19+
var activitiesLastGiven: Long? = null,
20+
var activitiesMore: Boolean = true
21+
) {
22+
fun getAggregatedList(context: Context) : List<ActivityListItem> {
23+
val items = arrayListOf<ActivityListItem>()
24+
items.addAll(
25+
activities.map { ActivityListItem.ActivityItem(it) }
26+
)
27+
items.addAll(
28+
versions.map { FileVersionItem(context, it) }
29+
)
30+
items.sortDescending()
31+
return items
32+
}
33+
34+
fun isEmpty() : Boolean = activities.isEmpty() && versions.isEmpty()
35+
}

0 commit comments

Comments
 (0)