From ed0e5ccac1c42fdb462e4c003a182e12c0df8985 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Wed, 5 Mar 2025 09:14:07 +0100 Subject: [PATCH 01/13] Show avatars in activity list Signed-off-by: tobiasKaminsky --- .../java/com/nextcloud/utils/text/Spans.java | 82 ++++++++++++ .../third_parties/fresco/BetterImageSpan.kt | 117 ++++++++++++++++++ app/src/main/res/drawable/accent_circle.xml | 10 ++ .../main/res/layout/activity_list_item.xml | 6 +- app/src/main/res/values/dims.xml | 1 + app/src/main/res/xml/chip_others.xml | 13 ++ 6 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/utils/text/Spans.java create mode 100644 app/src/main/java/third_parties/fresco/BetterImageSpan.kt create mode 100644 app/src/main/res/drawable/accent_circle.xml create mode 100644 app/src/main/res/xml/chip_others.xml diff --git a/app/src/main/java/com/nextcloud/utils/text/Spans.java b/app/src/main/java/com/nextcloud/utils/text/Spans.java new file mode 100644 index 000000000000..098366292ccb --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/text/Spans.java @@ -0,0 +1,82 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2021 Andy Scherzinger + * SPDX-FileCopyrightText: 2017-2018 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.utils.text; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import thirdparties.fresco.BetterImageSpan; + +public class Spans { + + public static class MentionChipSpan extends BetterImageSpan { + public String id; + public CharSequence label; + + public MentionChipSpan(@NonNull Drawable drawable, int verticalAlignment, String id, CharSequence label) { + super(drawable, verticalAlignment); + this.id = id; + this.label = label; + } + + public String getId() { + return this.id; + } + + public CharSequence getLabel() { + return this.label; + } + + public void setId(String id) { + this.id = id; + } + + public void setLabel(CharSequence label) { + this.label = label; + } + + public boolean equals(final Object o) { + if (o == this) { + return true; + } + if (!(o instanceof MentionChipSpan)) { + return false; + } + final MentionChipSpan other = (MentionChipSpan) o; + if (!other.canEqual((Object) this)) { + return false; + } + final Object this$id = this.getId(); + final Object other$id = other.getId(); + if (this$id == null ? other$id != null : !this$id.equals(other$id)) { + return false; + } + final Object this$label = this.getLabel(); + final Object other$label = other.getLabel(); + + return this$label == null ? other$label == null : this$label.equals(other$label); + } + + protected boolean canEqual(final Object other) { + return other instanceof MentionChipSpan; + } + + public int hashCode() { + final int PRIME = 59; + int result = 1; + final Object $id = this.getId(); + result = result * PRIME + ($id == null ? 43 : $id.hashCode()); + final Object $label = this.getLabel(); + return result * PRIME + ($label == null ? 43 : $label.hashCode()); + } + + public String toString() { + return "Spans.MentionChipSpan(id=" + this.getId() + ", label=" + this.getLabel() + ")"; + } + } +} diff --git a/app/src/main/java/third_parties/fresco/BetterImageSpan.kt b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt new file mode 100644 index 000000000000..3996cca94945 --- /dev/null +++ b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2015-present, Facebook, Inc. and its affiliates. + * SPDX-License-Identifier: MIT + */ + +package thirdparties.fresco + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.text.style.ReplacementSpan +import androidx.annotation.IntDef + +/** + * A better implementation of image spans that also supports centering images against the text. + * + * In order to migrate from ImageSpan, replace `new ImageSpan(drawable, alignment)` with + * `new BetterImageSpan(drawable, BetterImageSpan.normalizeAlignment(alignment))`. + * + * There are 2 main differences between BetterImageSpan and ImageSpan: + * 1. Pass in ALIGN_CENTER to center images against the text. + * 2. ALIGN_BOTTOM no longer unnecessarily increases the size of the text: + * DynamicDrawableSpan (ImageSpan's parent) adjusts sizes as if alignment was ALIGN_BASELINE + * which can lead to unnecessary whitespace. + */ +open class BetterImageSpan @JvmOverloads constructor( + val drawable: Drawable, + @param:BetterImageSpanAlignment private val mAlignment: Int = ALIGN_BASELINE +) : ReplacementSpan() { + @Suppress("Detekt.SpreadOperator") + @IntDef(*[ALIGN_BASELINE, ALIGN_BOTTOM, ALIGN_CENTER]) + @Retention(AnnotationRetention.SOURCE) + annotation class BetterImageSpanAlignment + + private var mWidth = 0 + private var mHeight = 0 + private var mBounds: Rect? = null + private val mFontMetricsInt = Paint.FontMetricsInt() + + init { + updateBounds() + } + + /** + * Returns the width of the image span and increases the height if font metrics are available. + */ + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fontMetrics: Paint.FontMetricsInt? + ): Int { + updateBounds() + if (fontMetrics == null) { + return mWidth + } + val offsetAbove = getOffsetAboveBaseline(fontMetrics) + val offsetBelow = mHeight + offsetAbove + if (offsetAbove < fontMetrics.ascent) { + fontMetrics.ascent = offsetAbove + } + if (offsetAbove < fontMetrics.top) { + fontMetrics.top = offsetAbove + } + if (offsetBelow > fontMetrics.descent) { + fontMetrics.descent = offsetBelow + } + if (offsetBelow > fontMetrics.bottom) { + fontMetrics.bottom = offsetBelow + } + return mWidth + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + paint.getFontMetricsInt(mFontMetricsInt) + val iconTop = y + getOffsetAboveBaseline(mFontMetricsInt) + canvas.translate(x, iconTop.toFloat()) + drawable.draw(canvas) + canvas.translate(-x, -iconTop.toFloat()) + } + + private fun updateBounds() { + mBounds = drawable.bounds + mWidth = mBounds!!.width() + mHeight = mBounds!!.height() + } + + private fun getOffsetAboveBaseline(fm: Paint.FontMetricsInt): Int = when (mAlignment) { + ALIGN_BOTTOM -> fm.descent - mHeight + ALIGN_CENTER -> { + val textHeight = fm.descent - fm.ascent + val offset = (textHeight - mHeight) / 2 + fm.ascent + offset + } + + ALIGN_BASELINE -> -mHeight + else -> -mHeight + } + + companion object { + const val ALIGN_BOTTOM = 0 + const val ALIGN_BASELINE = 1 + const val ALIGN_CENTER = 2 + } +} diff --git a/app/src/main/res/drawable/accent_circle.xml b/app/src/main/res/drawable/accent_circle.xml new file mode 100644 index 000000000000..3acd7c33c465 --- /dev/null +++ b/app/src/main/res/drawable/accent_circle.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_list_item.xml b/app/src/main/res/layout/activity_list_item.xml index ad17d9e40485..4e0bb51d201c 100644 --- a/app/src/main/res/layout/activity_list_item.xml +++ b/app/src/main/res/layout/activity_list_item.xml @@ -34,11 +34,13 @@ 3dp 16dp 10dp + 12dp 72dp 0dp 56dp diff --git a/app/src/main/res/xml/chip_others.xml b/app/src/main/res/xml/chip_others.xml new file mode 100644 index 000000000000..9f878b3edb22 --- /dev/null +++ b/app/src/main/res/xml/chip_others.xml @@ -0,0 +1,13 @@ + + From 81ce0077486b25262c3bfa476cf716535f4f1c9d Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Tue, 17 Feb 2026 12:09:59 +0100 Subject: [PATCH 02/13] Rename .java to .kt Signed-off-by: tobiasKaminsky --- .../main/java/com/nextcloud/utils/text/{Spans.java => Spans.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/nextcloud/utils/text/{Spans.java => Spans.kt} (100%) diff --git a/app/src/main/java/com/nextcloud/utils/text/Spans.java b/app/src/main/java/com/nextcloud/utils/text/Spans.kt similarity index 100% rename from app/src/main/java/com/nextcloud/utils/text/Spans.java rename to app/src/main/java/com/nextcloud/utils/text/Spans.kt From ed366057b387188db7107cefbb47fefbd7e6d2f5 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Tue, 17 Feb 2026 12:09:59 +0100 Subject: [PATCH 03/13] fix spotless Signed-off-by: tobiasKaminsky --- .../java/com/nextcloud/utils/text/Spans.kt | 91 +++++++------------ .../third_parties/fresco/BetterImageSpan.kt | 2 + 2 files changed, 34 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/text/Spans.kt b/app/src/main/java/com/nextcloud/utils/text/Spans.kt index 098366292ccb..c2c76e7abed1 100644 --- a/app/src/main/java/com/nextcloud/utils/text/Spans.kt +++ b/app/src/main/java/com/nextcloud/utils/text/Spans.kt @@ -5,78 +5,51 @@ * SPDX-FileCopyrightText: 2017-2018 Mario Danic * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.utils.text; +package com.nextcloud.utils.text -import android.graphics.drawable.Drawable; +import android.graphics.drawable.Drawable +import thirdparties.fresco.BetterImageSpan -import androidx.annotation.NonNull; -import thirdparties.fresco.BetterImageSpan; - -public class Spans { - - public static class MentionChipSpan extends BetterImageSpan { - public String id; - public CharSequence label; - - public MentionChipSpan(@NonNull Drawable drawable, int verticalAlignment, String id, CharSequence label) { - super(drawable, verticalAlignment); - this.id = id; - this.label = label; - } - - public String getId() { - return this.id; - } - - public CharSequence getLabel() { - return this.label; - } - - public void setId(String id) { - this.id = id; - } - - public void setLabel(CharSequence label) { - this.label = label; - } - - public boolean equals(final Object o) { - if (o == this) { - return true; +class Spans { + class MentionChipSpan(drawable: Drawable, verticalAlignment: Int, var id: String, var label: CharSequence?) : + BetterImageSpan(drawable, verticalAlignment) { + override fun equals(o: Any?): Boolean { + if (o === this) { + return true } - if (!(o instanceof MentionChipSpan)) { - return false; + if (o !is MentionChipSpan) { + return false } - final MentionChipSpan other = (MentionChipSpan) o; - if (!other.canEqual((Object) this)) { - return false; + val other = o + if (!other.canEqual(this as Any)) { + return false } - final Object this$id = this.getId(); - final Object other$id = other.getId(); - if (this$id == null ? other$id != null : !this$id.equals(other$id)) { - return false; + val `this$id`: Any? = this.id + val `other$id`: Any? = other.id + if (if (`this$id` == null) `other$id` != null else (`this$id` != `other$id`)) { + return false } - final Object this$label = this.getLabel(); - final Object other$label = other.getLabel(); + val `this$label`: Any? = this.label + val `other$label`: Any? = other.label - return this$label == null ? other$label == null : this$label.equals(other$label); + return if (`this$label` == null) `other$label` == null else (`this$label` == `other$label`) } - protected boolean canEqual(final Object other) { - return other instanceof MentionChipSpan; + protected fun canEqual(other: Any?): Boolean { + return other is MentionChipSpan } - public int hashCode() { - final int PRIME = 59; - int result = 1; - final Object $id = this.getId(); - result = result * PRIME + ($id == null ? 43 : $id.hashCode()); - final Object $label = this.getLabel(); - return result * PRIME + ($label == null ? 43 : $label.hashCode()); + override fun hashCode(): Int { + val PRIME = 59 + var result = 1 + val `$id`: Any? = this.id + result = result * PRIME + (if (`$id` == null) 43 else `$id`.hashCode()) + val `$label`: Any? = this.label + return result * PRIME + (if (`$label` == null) 43 else `$label`.hashCode()) } - public String toString() { - return "Spans.MentionChipSpan(id=" + this.getId() + ", label=" + this.getLabel() + ")"; + override fun toString(): String { + return "Spans.MentionChipSpan(id=" + this.id + ", label=" + this.label + ")" } } } diff --git a/app/src/main/java/third_parties/fresco/BetterImageSpan.kt b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt index 3996cca94945..da8a0bfff4a0 100644 --- a/app/src/main/java/third_parties/fresco/BetterImageSpan.kt +++ b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt @@ -99,6 +99,7 @@ open class BetterImageSpan @JvmOverloads constructor( private fun getOffsetAboveBaseline(fm: Paint.FontMetricsInt): Int = when (mAlignment) { ALIGN_BOTTOM -> fm.descent - mHeight + ALIGN_CENTER -> { val textHeight = fm.descent - fm.ascent val offset = (textHeight - mHeight) / 2 @@ -106,6 +107,7 @@ open class BetterImageSpan @JvmOverloads constructor( } ALIGN_BASELINE -> -mHeight + else -> -mHeight } From 6cc1613cfea1a745367c13a8578eff66a2f0a431 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Tue, 17 Feb 2026 12:25:36 +0100 Subject: [PATCH 04/13] fix detekt Signed-off-by: tobiasKaminsky --- .../java/com/nextcloud/utils/text/Spans.kt | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/text/Spans.kt b/app/src/main/java/com/nextcloud/utils/text/Spans.kt index c2c76e7abed1..9ca42fe8a74e 100644 --- a/app/src/main/java/com/nextcloud/utils/text/Spans.kt +++ b/app/src/main/java/com/nextcloud/utils/text/Spans.kt @@ -24,32 +24,28 @@ class Spans { if (!other.canEqual(this as Any)) { return false } - val `this$id`: Any? = this.id - val `other$id`: Any? = other.id - if (if (`this$id` == null) `other$id` != null else (`this$id` != `other$id`)) { + val thisId: Any? = this.id + val otherId: Any? = other.id + if (if (thisId == null) otherId != null else (thisId != otherId)) { return false } - val `this$label`: Any? = this.label - val `other$label`: Any? = other.label + val thisLabel: Any? = this.label + val otherLabel: Any? = other.label - return if (`this$label` == null) `other$label` == null else (`this$label` == `other$label`) + return if (thisLabel == null) otherLabel == null else (thisLabel == otherLabel) } - protected fun canEqual(other: Any?): Boolean { - return other is MentionChipSpan - } + protected fun canEqual(other: Any?): Boolean = other is MentionChipSpan override fun hashCode(): Int { - val PRIME = 59 + val prime = 59 var result = 1 - val `$id`: Any? = this.id - result = result * PRIME + (if (`$id` == null) 43 else `$id`.hashCode()) - val `$label`: Any? = this.label - return result * PRIME + (if (`$label` == null) 43 else `$label`.hashCode()) + val thisId: Any? = this.id + result = result * prime + (if (thisId == null) 43 else thisId.hashCode()) + val label: Any? = this.label + return result * prime + (if (label == null) 43 else label.hashCode()) } - override fun toString(): String { - return "Spans.MentionChipSpan(id=" + this.id + ", label=" + this.label + ")" - } + override fun toString(): String = "Spans.MentionChipSpan(id=" + this.id + ", label=" + this.label + ")" } } From 6d4894d5556694a3f4a349af18442ef9cae0a11d Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Fri, 17 Jul 2026 10:39:50 +0200 Subject: [PATCH 05/13] wip Signed-off-by: tobiasKaminsky --- .../activities/adapter/ActivityListAdapter.kt | 110 +++++++++++++----- app/src/main/res/values/styles.xml | 4 + 2 files changed, 86 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt index 2ae062e338f4..d3b1fa145597 100644 --- a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt @@ -8,12 +8,13 @@ package com.owncloud.android.ui.activities.adapter import android.content.Context import android.graphics.Typeface +import android.graphics.drawable.Drawable import android.text.Spannable import android.text.SpannableStringBuilder import android.text.TextPaint +import android.text.TextUtils import android.text.format.DateFormat import android.text.format.DateUtils -import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan @@ -27,10 +28,12 @@ import androidx.annotation.DrawableRes import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.ChipDrawable import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.CurrentAccountProvider import com.nextcloud.common.NextcloudClient import com.nextcloud.utils.GlideHelper +import com.nextcloud.utils.text.Spans.MentionChipSpan import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.ActivityListItemBinding @@ -49,6 +52,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import thirdparties.fresco.BetterImageSpan import java.util.Locale import kotlin.math.floor import kotlin.math.log @@ -62,7 +66,8 @@ open class ActivityListAdapter( private val isDetailView: Boolean, private val viewThemeUtils: ViewThemeUtils ) : RecyclerView.Adapter(), - StickyHeaderAdapter { + StickyHeaderAdapter, + DisplayUtils.AvatarGenerationListener { protected var client: NextcloudClient? = null val values: MutableList = mutableListOf() @@ -113,9 +118,10 @@ open class ActivityListAdapter( when { activity.richSubjectElement.richSubject.isNotEmpty() -> holder.binding.subject.apply { - visibility = View.VISIBLE - movementMethod = LinkMovementMethod.getInstance() - setText(addClickablePart(activity.richSubjectElement), TextView.BufferType.SPANNABLE) + text = addClickablePart(activity.richSubjectElement) + // visibility = View.VISIBLE + // movementMethod = LinkMovementMethod.getInstance() + // setText(addClickablePart(activity.richSubjectElement), TextView.BufferType.SPANNABLE) } activity.subject.isNotEmpty() -> holder.binding.subject.apply { @@ -176,6 +182,19 @@ open class ActivityListAdapter( } } + fun getDrawableForMentionChipSpan(chipResource: Int, text: String): ChipDrawable { + val chip = ChipDrawable.createFromResource(context, chipResource).apply { + setEllipsize(TextUtils.TruncateAt.MIDDLE) + setLayoutDirection(context.getResources().getConfiguration().getLayoutDirection()) + setText(text) + setChipIconResource(R.drawable.accent_circle) + } + + chip.setBounds(0, 0, chip.getIntrinsicWidth(), chip.getIntrinsicHeight()) + + return chip + } + private suspend fun nextcloudClient(): NextcloudClient = withContext(Dispatchers.IO) { OwnCloudClientManagerFactory.getDefaultSingleton() .getNextcloudClientFor(currentAccountProvider.user.toOwnCloudAccount(), context) @@ -224,6 +243,7 @@ open class ActivityListAdapter( return imageView } + @Suppress("NestedBlockDepth") private fun addClickablePart(richElement: RichElement): SpannableStringBuilder { var text = richElement.richSubject val ssb = SpannableStringBuilder(text) @@ -236,29 +256,57 @@ open class ActivityListAdapter( } if (richObject != null) { - val name = richObject.name.orEmpty() - ssb.replace(idx1, idx2, name) - text = ssb.toString() - idx2 = idx1 + name.length - - ssb.setSpan( - object : ClickableSpan() { - override fun onClick(widget: View) = activityListInterface.onActivityClicked(richObject) - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - } - }, - idx1, - idx2, - 0 - ) - ssb.setSpan(StyleSpan(Typeface.BOLD), idx1, idx2, 0) - ssb.setSpan( - ForegroundColorSpan(context.resources.getColor(R.color.text_color)), - idx1, - idx2, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) + if ("user".equals(richObject.type)) { + val name = richObject.name + + val drawableForChip = getDrawableForMentionChipSpan(R.xml.chip_others, name ?: "") + + val mentionChipSpan = MentionChipSpan( + drawableForChip, + BetterImageSpan.ALIGN_CENTER, + richObject.id ?: "", + name + ) + + if (richObject.id != null) { + DisplayUtils.setAvatar( + currentAccountProvider.user, + richObject.id!!, + name, + this, + context.resources.getDimension(R.dimen.avatar_icon_radius), + context.resources, + drawableForChip, + context + ) + } + + ssb.setSpan(mentionChipSpan, idx1, idx2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } else { + val name = richObject.name.orEmpty() + ssb.replace(idx1, idx2, name) + text = ssb.toString() + idx2 = idx1 + name.length + + ssb.setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) = activityListInterface.onActivityClicked(richObject) + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } + }, + idx1, + idx2, + 0 + ) + ssb.setSpan(StyleSpan(Typeface.BOLD), idx1, idx2, 0) + ssb.setSpan( + ForegroundColorSpan(context.resources.getColor(R.color.text_color)), + idx1, + idx2, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } } idx1 = text.indexOf('{', idx2) } @@ -308,6 +356,12 @@ open class ActivityListAdapter( override fun isHeader(itemPosition: Int) = itemPosition in values.indices && getItemViewType(itemPosition) == HEADER_TYPE + override fun avatarGenerated(avatarDrawable: Drawable, callContext: Any) { + (callContext as ChipDrawable).chipIcon = avatarDrawable + } + + override fun shouldCallGeneratedCallback(tag: String, callContext: Any): Boolean = true + protected class ActivityViewHolder(val binding: ActivityListItemBinding) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 3b9d1ef0ecb9..58c63b7577be 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -490,4 +490,8 @@ @android:color/black @color/primary + + From 2948ae64e99c0d6a7524c71daae26cbad3575eb5 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Wed, 5 Mar 2025 09:14:07 +0100 Subject: [PATCH 06/13] Show avatars in activity list Signed-off-by: tobiasKaminsky # Conflicts: # app/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java # app/src/main/java/third_parties/fresco/BetterImageSpan.kt # app/src/main/res/values/styles.xml --- .../java/com/nextcloud/utils/text/Spans.java | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 app/src/main/java/com/nextcloud/utils/text/Spans.java diff --git a/app/src/main/java/com/nextcloud/utils/text/Spans.java b/app/src/main/java/com/nextcloud/utils/text/Spans.java new file mode 100644 index 000000000000..098366292ccb --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/text/Spans.java @@ -0,0 +1,82 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2021 Andy Scherzinger + * SPDX-FileCopyrightText: 2017-2018 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.utils.text; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import thirdparties.fresco.BetterImageSpan; + +public class Spans { + + public static class MentionChipSpan extends BetterImageSpan { + public String id; + public CharSequence label; + + public MentionChipSpan(@NonNull Drawable drawable, int verticalAlignment, String id, CharSequence label) { + super(drawable, verticalAlignment); + this.id = id; + this.label = label; + } + + public String getId() { + return this.id; + } + + public CharSequence getLabel() { + return this.label; + } + + public void setId(String id) { + this.id = id; + } + + public void setLabel(CharSequence label) { + this.label = label; + } + + public boolean equals(final Object o) { + if (o == this) { + return true; + } + if (!(o instanceof MentionChipSpan)) { + return false; + } + final MentionChipSpan other = (MentionChipSpan) o; + if (!other.canEqual((Object) this)) { + return false; + } + final Object this$id = this.getId(); + final Object other$id = other.getId(); + if (this$id == null ? other$id != null : !this$id.equals(other$id)) { + return false; + } + final Object this$label = this.getLabel(); + final Object other$label = other.getLabel(); + + return this$label == null ? other$label == null : this$label.equals(other$label); + } + + protected boolean canEqual(final Object other) { + return other instanceof MentionChipSpan; + } + + public int hashCode() { + final int PRIME = 59; + int result = 1; + final Object $id = this.getId(); + result = result * PRIME + ($id == null ? 43 : $id.hashCode()); + final Object $label = this.getLabel(); + return result * PRIME + ($label == null ? 43 : $label.hashCode()); + } + + public String toString() { + return "Spans.MentionChipSpan(id=" + this.getId() + ", label=" + this.getLabel() + ")"; + } + } +} From 7683ca0d2719d5c1bd724b0ecf90df77b9ee53f9 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 17 Jul 2026 16:04:20 +0200 Subject: [PATCH 07/13] remove old Spans.java Signed-off-by: alperozturk96 --- .../java/com/nextcloud/utils/text/Spans.java | 82 ------------------- 1 file changed, 82 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/utils/text/Spans.java diff --git a/app/src/main/java/com/nextcloud/utils/text/Spans.java b/app/src/main/java/com/nextcloud/utils/text/Spans.java deleted file mode 100644 index 098366292ccb..000000000000 --- a/app/src/main/java/com/nextcloud/utils/text/Spans.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2017-2018 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.utils.text; - -import android.graphics.drawable.Drawable; - -import androidx.annotation.NonNull; -import thirdparties.fresco.BetterImageSpan; - -public class Spans { - - public static class MentionChipSpan extends BetterImageSpan { - public String id; - public CharSequence label; - - public MentionChipSpan(@NonNull Drawable drawable, int verticalAlignment, String id, CharSequence label) { - super(drawable, verticalAlignment); - this.id = id; - this.label = label; - } - - public String getId() { - return this.id; - } - - public CharSequence getLabel() { - return this.label; - } - - public void setId(String id) { - this.id = id; - } - - public void setLabel(CharSequence label) { - this.label = label; - } - - public boolean equals(final Object o) { - if (o == this) { - return true; - } - if (!(o instanceof MentionChipSpan)) { - return false; - } - final MentionChipSpan other = (MentionChipSpan) o; - if (!other.canEqual((Object) this)) { - return false; - } - final Object this$id = this.getId(); - final Object other$id = other.getId(); - if (this$id == null ? other$id != null : !this$id.equals(other$id)) { - return false; - } - final Object this$label = this.getLabel(); - final Object other$label = other.getLabel(); - - return this$label == null ? other$label == null : this$label.equals(other$label); - } - - protected boolean canEqual(final Object other) { - return other instanceof MentionChipSpan; - } - - public int hashCode() { - final int PRIME = 59; - int result = 1; - final Object $id = this.getId(); - result = result * PRIME + ($id == null ? 43 : $id.hashCode()); - final Object $label = this.getLabel(); - return result * PRIME + ($label == null ? 43 : $label.hashCode()); - } - - public String toString() { - return "Spans.MentionChipSpan(id=" + this.getId() + ", label=" + this.getLabel() + ")"; - } - } -} From 5e913b8dc45b8c49b3bf9c980f5e6ccb44bf0553 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Jul 2026 08:27:52 +0200 Subject: [PATCH 08/13] refactor MentionChipSpan Signed-off-by: alperozturk96 --- .../nextcloud/utils/text/MentionChipSpan.kt | 25 +++++++++ .../java/com/nextcloud/utils/text/Spans.kt | 51 ------------------- .../activities/adapter/ActivityListAdapter.kt | 5 +- 3 files changed, 26 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/utils/text/MentionChipSpan.kt delete mode 100644 app/src/main/java/com/nextcloud/utils/text/Spans.kt diff --git a/app/src/main/java/com/nextcloud/utils/text/MentionChipSpan.kt b/app/src/main/java/com/nextcloud/utils/text/MentionChipSpan.kt new file mode 100644 index 000000000000..dea38a1d15d3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/text/MentionChipSpan.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.text + +import android.graphics.drawable.Drawable +import thirdparties.fresco.BetterImageSpan +import java.util.Objects + +class MentionChipSpan(drawable: Drawable, verticalAlignment: Int, var id: String, var label: CharSequence?) : + BetterImageSpan(drawable, verticalAlignment) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MentionChipSpan) return false + return id == other.id && label == other.label + } + + override fun hashCode(): Int = Objects.hash(id, label) + + override fun toString(): String = "MentionChipSpan(id=$id, label=$label)" +} diff --git a/app/src/main/java/com/nextcloud/utils/text/Spans.kt b/app/src/main/java/com/nextcloud/utils/text/Spans.kt deleted file mode 100644 index 9ca42fe8a74e..000000000000 --- a/app/src/main/java/com/nextcloud/utils/text/Spans.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2017-2018 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.utils.text - -import android.graphics.drawable.Drawable -import thirdparties.fresco.BetterImageSpan - -class Spans { - class MentionChipSpan(drawable: Drawable, verticalAlignment: Int, var id: String, var label: CharSequence?) : - BetterImageSpan(drawable, verticalAlignment) { - override fun equals(o: Any?): Boolean { - if (o === this) { - return true - } - if (o !is MentionChipSpan) { - return false - } - val other = o - if (!other.canEqual(this as Any)) { - return false - } - val thisId: Any? = this.id - val otherId: Any? = other.id - if (if (thisId == null) otherId != null else (thisId != otherId)) { - return false - } - val thisLabel: Any? = this.label - val otherLabel: Any? = other.label - - return if (thisLabel == null) otherLabel == null else (thisLabel == otherLabel) - } - - protected fun canEqual(other: Any?): Boolean = other is MentionChipSpan - - override fun hashCode(): Int { - val prime = 59 - var result = 1 - val thisId: Any? = this.id - result = result * prime + (if (thisId == null) 43 else thisId.hashCode()) - val label: Any? = this.label - return result * prime + (if (label == null) 43 else label.hashCode()) - } - - override fun toString(): String = "Spans.MentionChipSpan(id=" + this.id + ", label=" + this.label + ")" - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt index d3b1fa145597..fd5ac18aaeb1 100644 --- a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt @@ -33,7 +33,7 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.CurrentAccountProvider import com.nextcloud.common.NextcloudClient import com.nextcloud.utils.GlideHelper -import com.nextcloud.utils.text.Spans.MentionChipSpan +import com.nextcloud.utils.text.MentionChipSpan import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.ActivityListItemBinding @@ -119,9 +119,6 @@ open class ActivityListAdapter( when { activity.richSubjectElement.richSubject.isNotEmpty() -> holder.binding.subject.apply { text = addClickablePart(activity.richSubjectElement) - // visibility = View.VISIBLE - // movementMethod = LinkMovementMethod.getInstance() - // setText(addClickablePart(activity.richSubjectElement), TextView.BufferType.SPANNABLE) } activity.subject.isNotEmpty() -> holder.binding.subject.apply { From 36a0b453aeb9c68af562dde5eaed83485fb06ca3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Jul 2026 08:57:55 +0200 Subject: [PATCH 09/13] fix infinite spinner Signed-off-by: alperozturk96 --- .../android/ui/fragment/FileDetailActivitiesFragment.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailActivitiesFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailActivitiesFragment.java index c2b49e229d3c..41052a429d30 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailActivitiesFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailActivitiesFragment.java @@ -143,11 +143,13 @@ public View onCreateView(@NonNull LayoutInflater inflater, binding.swipeContainingList.setOnRefreshListener(() -> { setLoadingMessage(); binding.swipeContainingList.setRefreshing(true); + isLoadingActivities = true; fetchAndSetData(-1); }); binding.swipeContainingEmpty.setOnRefreshListener(() -> { setLoadingMessageEmpty(); + isLoadingActivities = true; fetchAndSetData(-1); }); From ce033bfb046bde82302cefa180e736205923889f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Jul 2026 09:17:53 +0200 Subject: [PATCH 10/13] refactor activity list adapter Signed-off-by: alperozturk96 --- .../activities/adapter/ActivityListAdapter.kt | 250 ++++++++++-------- 1 file changed, 138 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt index fd5ac18aaeb1..a440ed500871 100644 --- a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt @@ -21,6 +21,7 @@ import android.text.style.StyleSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.GridLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -58,7 +59,7 @@ import kotlin.math.floor import kotlin.math.log import kotlin.math.pow -@Suppress("MagicNumber") +@Suppress("MagicNumber", "TooManyFunctions") open class ActivityListAdapter( protected val context: FragmentActivity, private val currentAccountProvider: CurrentAccountProvider, @@ -77,18 +78,21 @@ open class ActivityListAdapter( fun setActivityItems(activityItems: List, client: NextcloudClient, clear: Boolean) { this.client = client if (clear) values.clear() + appendGroupedByHeader(activityItems) { (it as Activity).datetime.time } + notifyDataSetChanged() + } - var sTime = "" - for (o in activityItems) { - val activity = o as Activity - val time = getHeaderDateString(context, activity.datetime.time).toString() - if (!sTime.equals(time, ignoreCase = true)) { - sTime = time - values.add(sTime) + private fun appendGroupedByHeader(items: List, timestampOf: (Any) -> Long?) { + var currentHeader: String? = null + for (item in items) { + val timestamp = timestampOf(item) ?: continue + val header = getHeaderDateString(context, timestamp).toString() + if (!header.equals(currentHeader, ignoreCase = true)) { + currentHeader = header + values.add(header) } - values.add(activity) + values.add(item) } - notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { @@ -107,88 +111,103 @@ open class ActivityListAdapter( } } - @Suppress("LongMethod") private fun bindActivityViewHolder(holder: ActivityViewHolder, position: Int) { val activity = values[position] as Activity + holder.bindDateTime(activity) + holder.bindSubject(activity) + holder.bindMessage(activity) + holder.bindIcon(activity) + holder.bindPreviews(activity) + } - holder.binding.datetime.apply { + private fun ActivityViewHolder.bindDateTime(activity: Activity) { + binding.datetime.apply { visibility = View.VISIBLE text = DateFormat.format("HH:mm", activity.datetime.time) } + } + private fun ActivityViewHolder.bindSubject(activity: Activity) { when { - activity.richSubjectElement.richSubject.isNotEmpty() -> holder.binding.subject.apply { - text = addClickablePart(activity.richSubjectElement) - } + activity.richSubjectElement.richSubject.isNotEmpty() -> + binding.subject.text = addClickablePart(activity.richSubjectElement) - activity.subject.isNotEmpty() -> holder.binding.subject.apply { + activity.subject.isNotEmpty() -> binding.subject.apply { visibility = View.VISIBLE text = activity.subject } - else -> holder.binding.subject.visibility = View.GONE + else -> binding.subject.visibility = View.GONE } + } - holder.binding.message.apply { + private fun ActivityViewHolder.bindMessage(activity: Activity) { + binding.message.apply { text = activity.message visibility = if (activity.message.isNotEmpty()) View.VISIBLE else View.GONE } + } + private fun ActivityViewHolder.bindIcon(activity: Activity) { if (activity.icon.isNotEmpty()) { GlideHelper.loadTintableIconIntoImageView( context, client, activity.icon, - holder.binding.icon, + binding.icon, R.drawable.ic_activity, context.resources.getDimensionPixelSize(R.dimen.activity_icon_width) ) } if (activity.icon.endsWith(COLORED_ICON_SUFFIX, ignoreCase = true)) { - holder.binding.icon.imageTintList = null + binding.icon.imageTintList = null } else { - viewThemeUtils.platform.colorImageView(holder.binding.icon, ColorRole.ON_SURFACE_VARIANT) + viewThemeUtils.platform.colorImageView(binding.icon, ColorRole.ON_SURFACE_VARIANT) } + } + private fun ActivityViewHolder.bindPreviews(activity: Activity) { val richObjectList = activity.richSubjectElement.richObjectList - if (richObjectList.isNotEmpty()) { - holder.binding.list.apply { - visibility = View.VISIBLE - removeAllViews() - post { - val totalColumnCount = measuredWidth / (px + 20) - try { - columnCount = totalColumnCount - } catch (e: IllegalArgumentException) { - Log_OC.e(TAG, "error setting column count to $totalColumnCount") - } - } - activity.previews - .filter { - !isDetailView || MimeTypeUtil.isImageOrVideo(it.mimeType) || - MimeTypeUtil.isVideo(it.mimeType) - } - .forEach { addView(createThumbnail(it, richObjectList)) } - } - } else { - holder.binding.list.apply { + + if (richObjectList.isEmpty()) { + binding.list.apply { removeAllViews() visibility = View.GONE } + return + } + + binding.list.apply { + visibility = View.VISIBLE + removeAllViews() + post { adjustColumnCount() } + activity.previews + .filter { shouldShowPreview(it) } + .forEach { addView(createThumbnail(it, richObjectList)) } } } - fun getDrawableForMentionChipSpan(chipResource: Int, text: String): ChipDrawable { + private fun shouldShowPreview(preview: PreviewObject): Boolean = + !isDetailView || MimeTypeUtil.isImageOrVideo(preview.mimeType) || MimeTypeUtil.isVideo(preview.mimeType) + + private fun GridLayout.adjustColumnCount() { + val columns = measuredWidth / (px + PREVIEW_COLUMN_SPACING) + try { + columnCount = columns + } catch (e: IllegalArgumentException) { + Log_OC.e(TAG, "error setting column count to $columns") + } + } + + private fun getDrawableForMentionChipSpan(chipResource: Int, text: String): ChipDrawable { val chip = ChipDrawable.createFromResource(context, chipResource).apply { setEllipsize(TextUtils.TruncateAt.MIDDLE) - setLayoutDirection(context.getResources().getConfiguration().getLayoutDirection()) + layoutDirection = context.resources.configuration.layoutDirection setText(text) setChipIconResource(R.drawable.accent_circle) } - - chip.setBounds(0, 0, chip.getIntrinsicWidth(), chip.getIntrinsicHeight()) - + chip.setBounds(0, 0, chip.intrinsicWidth, chip.intrinsicHeight) return chip } @@ -209,7 +228,9 @@ open class ActivityListAdapter( private fun createThumbnail(previewObject: PreviewObject, richObjectList: List): ImageView { val imageView = ImageView(context).apply { - layoutParams = LinearLayout.LayoutParams(px, px).apply { setMargins(10, 10, 10, 10) } + layoutParams = LinearLayout.LayoutParams(px, px).apply { + setMargins(PREVIEW_CELL_MARGIN, PREVIEW_CELL_MARGIN, PREVIEW_CELL_MARGIN, PREVIEW_CELL_MARGIN) + } } richObjectList.firstOrNull { it.id?.toIntOrNull() == previewObject.fileId }?.let { richObject -> @@ -228,89 +249,92 @@ open class ActivityListAdapter( else -> imageView.setImageDrawable( - MimeTypeUtil.getFileTypeIcon( - previewObject.mimeType, - "", - context, - viewThemeUtils - ) + MimeTypeUtil.getFileTypeIcon(previewObject.mimeType, "", context, viewThemeUtils) ) } return imageView } - @Suppress("NestedBlockDepth") private fun addClickablePart(richElement: RichElement): SpannableStringBuilder { var text = richElement.richSubject val ssb = SpannableStringBuilder(text) - var idx1 = text.indexOf('{') - while (idx1 != -1) { - var idx2 = text.indexOf('}', idx1) + 1 - val richObject = richElement.richObjectList.firstOrNull { - it.tag.equals(text.substring(idx1 + 1, idx2 - 1), ignoreCase = true) - } + var start = text.indexOf('{') + while (start != -1) { + val end = text.indexOf('}', start) + 1 + val tag = text.substring(start + 1, end - 1) + val richObject = richElement.richObjectList.firstOrNull { it.tag.equals(tag, ignoreCase = true) } + + val nextSearchStart = when { + richObject == null -> end - if (richObject != null) { - if ("user".equals(richObject.type)) { - val name = richObject.name - - val drawableForChip = getDrawableForMentionChipSpan(R.xml.chip_others, name ?: "") - - val mentionChipSpan = MentionChipSpan( - drawableForChip, - BetterImageSpan.ALIGN_CENTER, - richObject.id ?: "", - name - ) - - if (richObject.id != null) { - DisplayUtils.setAvatar( - currentAccountProvider.user, - richObject.id!!, - name, - this, - context.resources.getDimension(R.dimen.avatar_icon_radius), - context.resources, - drawableForChip, - context - ) - } - - ssb.setSpan(mentionChipSpan, idx1, idx2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) - } else { - val name = richObject.name.orEmpty() - ssb.replace(idx1, idx2, name) + richObject.type == "user" -> { + ssb.applyMentionSpan(richObject, start, end) + end + } + + else -> { + val nameEnd = ssb.applyClickableNameSpan(richObject, start, end) text = ssb.toString() - idx2 = idx1 + name.length - - ssb.setSpan( - object : ClickableSpan() { - override fun onClick(widget: View) = activityListInterface.onActivityClicked(richObject) - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - } - }, - idx1, - idx2, - 0 - ) - ssb.setSpan(StyleSpan(Typeface.BOLD), idx1, idx2, 0) - ssb.setSpan( - ForegroundColorSpan(context.resources.getColor(R.color.text_color)), - idx1, - idx2, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) + nameEnd } } - idx1 = text.indexOf('{', idx2) + + start = text.indexOf('{', nextSearchStart) } return ssb } + private fun SpannableStringBuilder.applyMentionSpan(richObject: RichObject, start: Int, end: Int) { + val name = richObject.name + val chip = getDrawableForMentionChipSpan(R.xml.chip_others, name ?: "") + val span = MentionChipSpan(chip, BetterImageSpan.ALIGN_CENTER, richObject.id ?: "", name) + + richObject.id?.let { id -> + DisplayUtils.setAvatar( + currentAccountProvider.user, + id, + name, + this@ActivityListAdapter, + context.resources.getDimension(R.dimen.avatar_icon_radius), + context.resources, + chip, + context + ) + } + + setSpan(span, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } + + private fun SpannableStringBuilder.applyClickableNameSpan(richObject: RichObject, start: Int, end: Int): Int { + val name = richObject.name.orEmpty() + replace(start, end, name) + val nameEnd = start + name.length + + setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) = activityListInterface.onActivityClicked(richObject) + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } + }, + start, + nameEnd, + 0 + ) + setSpan(StyleSpan(Typeface.BOLD), start, nameEnd, 0) + setSpan( + ForegroundColorSpan(context.resources.getColor(R.color.text_color)), + start, + nameEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + return nameEnd + } + override fun getItemViewType(position: Int) = if (values[position] is Activity) ACTIVITY_TYPE else HEADER_TYPE override fun getItemCount() = values.size @@ -369,6 +393,8 @@ open class ActivityListAdapter( const val HEADER_TYPE = 100 const val ACTIVITY_TYPE = 101 private const val COLORED_ICON_SUFFIX = "-color.svg" + private const val PREVIEW_COLUMN_SPACING = 20 + private const val PREVIEW_CELL_MARGIN = 10 private val TAG: String = ActivityListAdapter::class.java.simpleName } } From ded1eded3313716540bea5c497222a1a5b49472b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Jul 2026 09:29:56 +0200 Subject: [PATCH 11/13] refactor activity list adapter Signed-off-by: alperozturk96 --- .../activities/adapter/ActivityListAdapter.kt | 143 ++++++++++-------- 1 file changed, 76 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt index a440ed500871..c683fb31be2b 100644 --- a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt @@ -73,7 +73,9 @@ open class ActivityListAdapter( protected var client: NextcloudClient? = null val values: MutableList = mutableListOf() private val px = getThumbnailDimension() + private var cachedNextcloudClient: NextcloudClient? = null + // region Public Methods @Suppress("NotifyDataSetChanged") fun setActivityItems(activityItems: List, client: NextcloudClient, clear: Boolean) { this.client = client @@ -82,19 +84,26 @@ open class ActivityListAdapter( notifyDataSetChanged() } - private fun appendGroupedByHeader(items: List, timestampOf: (Any) -> Long?) { - var currentHeader: String? = null - for (item in items) { - val timestamp = timestampOf(item) ?: continue - val header = getHeaderDateString(context, timestamp).toString() - if (!header.equals(currentHeader, ignoreCase = true)) { - currentHeader = header - values.add(header) - } - values.add(item) + fun isEmpty() = values.isEmpty() + + fun getHeaderDateString(context: Context, modificationTimestamp: Long): CharSequence = + if ((System.currentTimeMillis() - modificationTimestamp) < DateUtils.WEEK_IN_MILLIS) { + DisplayUtils.getRelativeDateTimeString( + context, + modificationTimestamp, + DateUtils.DAY_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0 + ) + } else { + DateFormat.format( + DateFormat.getBestDateTimePattern(Locale.getDefault(), HEADER_DATE_SKELETON), + modificationTimestamp + ) } - } + // endregion + // region Overridden Methods override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return if (viewType == ACTIVITY_TYPE) { @@ -111,6 +120,45 @@ open class ActivityListAdapter( } } + override fun getItemViewType(position: Int) = if (values[position] is Activity) ACTIVITY_TYPE else HEADER_TYPE + + override fun getItemCount() = values.size + + override fun getHeaderPositionForItem(itemPosition: Int): Int { + var pos = itemPosition + while (pos >= 0 && !isHeader(pos)) pos-- + return pos + } + + override fun getHeaderLayout(headerPosition: Int) = R.layout.activity_list_item_header + + override fun bindHeaderData(header: View?, headerPosition: Int) { + header?.findViewById(R.id.header)?.text = values[headerPosition] as String + } + + override fun isHeader(itemPosition: Int) = + itemPosition in values.indices && getItemViewType(itemPosition) == HEADER_TYPE + + override fun avatarGenerated(avatarDrawable: Drawable, callContext: Any) { + (callContext as ChipDrawable).chipIcon = avatarDrawable + } + + override fun shouldCallGeneratedCallback(tag: String, callContext: Any): Boolean = true + // endregion + + // region Private Methods + private fun appendGroupedByHeader(items: List, timestampOf: (Any) -> Long?) { + var currentHeader: String? = null + for (item in items) { + val header = getHeaderDateString(context, timestampOf(item) ?: continue).toString() + if (!header.equals(currentHeader, ignoreCase = true)) { + currentHeader = header + values.add(header) + } + values.add(item) + } + } + private fun bindActivityViewHolder(holder: ActivityViewHolder, position: Int) { val activity = values[position] as Activity holder.bindDateTime(activity) @@ -123,7 +171,7 @@ open class ActivityListAdapter( private fun ActivityViewHolder.bindDateTime(activity: Activity) { binding.datetime.apply { visibility = View.VISIBLE - text = DateFormat.format("HH:mm", activity.datetime.time) + text = DateFormat.format(TIME_PATTERN, activity.datetime.time) } } @@ -195,26 +243,24 @@ open class ActivityListAdapter( val columns = measuredWidth / (px + PREVIEW_COLUMN_SPACING) try { columnCount = columns - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { Log_OC.e(TAG, "error setting column count to $columns") } } - private fun getDrawableForMentionChipSpan(chipResource: Int, text: String): ChipDrawable { - val chip = ChipDrawable.createFromResource(context, chipResource).apply { + private fun getDrawableForMentionChipSpan(chipResource: Int, text: String): ChipDrawable = + ChipDrawable.createFromResource(context, chipResource).apply { setEllipsize(TextUtils.TruncateAt.MIDDLE) layoutDirection = context.resources.configuration.layoutDirection setText(text) setChipIconResource(R.drawable.accent_circle) + setBounds(0, 0, intrinsicWidth, intrinsicHeight) } - chip.setBounds(0, 0, chip.intrinsicWidth, chip.intrinsicHeight) - return chip - } - private suspend fun nextcloudClient(): NextcloudClient = withContext(Dispatchers.IO) { + private suspend fun nextcloudClient(): NextcloudClient = cachedNextcloudClient ?: withContext(Dispatchers.IO) { OwnCloudClientManagerFactory.getDefaultSingleton() .getNextcloudClientFor(currentAccountProvider.user.toOwnCloudAccount(), context) - } + }.also { cachedNextcloudClient = it } private fun loadImageAsync(url: String, imageView: ImageView, @DrawableRes placeholder: Int) { context.lifecycleScope.launch { @@ -260,16 +306,16 @@ open class ActivityListAdapter( var text = richElement.richSubject val ssb = SpannableStringBuilder(text) - var start = text.indexOf('{') + var start = text.indexOf(PLACEHOLDER_START) while (start != -1) { - val end = text.indexOf('}', start) + 1 + val end = text.indexOf(PLACEHOLDER_END, start) + 1 val tag = text.substring(start + 1, end - 1) val richObject = richElement.richObjectList.firstOrNull { it.tag.equals(tag, ignoreCase = true) } val nextSearchStart = when { richObject == null -> end - richObject.type == "user" -> { + richObject.type == USER_TYPE -> { ssb.applyMentionSpan(richObject, start, end) end } @@ -281,7 +327,7 @@ open class ActivityListAdapter( } } - start = text.indexOf('{', nextSearchStart) + start = text.indexOf(PLACEHOLDER_START, nextSearchStart) } return ssb @@ -335,53 +381,11 @@ open class ActivityListAdapter( return nameEnd } - override fun getItemViewType(position: Int) = if (values[position] is Activity) ACTIVITY_TYPE else HEADER_TYPE - - override fun getItemCount() = values.size - - fun isEmpty() = values.isEmpty() - private fun getThumbnailDimension(): Int { val dimension = MainApp.getAppContext().resources.getDimension(R.dimen.file_icon_size_grid) return (2.0.pow(floor(log(dimension.toDouble(), 2.0))) / 2).toInt() } - - fun getHeaderDateString(context: Context, modificationTimestamp: Long): CharSequence = - if ((System.currentTimeMillis() - modificationTimestamp) < DateUtils.WEEK_IN_MILLIS) { - DisplayUtils.getRelativeDateTimeString( - context, - modificationTimestamp, - DateUtils.DAY_IN_MILLIS, - DateUtils.WEEK_IN_MILLIS, - 0 - ) - } else { - DateFormat.format( - DateFormat.getBestDateTimePattern(Locale.getDefault(), "EEEE, MMMM d"), - modificationTimestamp - ) - } - - override fun getHeaderPositionForItem(itemPosition: Int): Int { - var pos = itemPosition - while (pos >= 0 && !isHeader(pos)) pos-- - return pos - } - - override fun getHeaderLayout(headerPosition: Int) = R.layout.activity_list_item_header - - override fun bindHeaderData(header: View?, headerPosition: Int) { - header?.findViewById(R.id.header)?.text = values[headerPosition] as String - } - - override fun isHeader(itemPosition: Int) = - itemPosition in values.indices && getItemViewType(itemPosition) == HEADER_TYPE - - override fun avatarGenerated(avatarDrawable: Drawable, callContext: Any) { - (callContext as ChipDrawable).chipIcon = avatarDrawable - } - - override fun shouldCallGeneratedCallback(tag: String, callContext: Any): Boolean = true + // endregion protected class ActivityViewHolder(val binding: ActivityListItemBinding) : RecyclerView.ViewHolder(binding.root) @@ -393,6 +397,11 @@ open class ActivityListAdapter( const val HEADER_TYPE = 100 const val ACTIVITY_TYPE = 101 private const val COLORED_ICON_SUFFIX = "-color.svg" + private const val PLACEHOLDER_START = '{' + private const val PLACEHOLDER_END = '}' + private const val USER_TYPE = "user" + private const val TIME_PATTERN = "HH:mm" + private const val HEADER_DATE_SKELETON = "EEEE, MMMM d" private const val PREVIEW_COLUMN_SPACING = 20 private const val PREVIEW_CELL_MARGIN = 10 private val TAG: String = ActivityListAdapter::class.java.simpleName From 5f021f538b70a1d472c41e27d572a7f99c89e07b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Jul 2026 09:41:25 +0200 Subject: [PATCH 12/13] refactor BetterImageSpan Signed-off-by: alperozturk96 --- .../nextcloud/utils/text/MentionChipSpan.kt | 2 +- .../activities/adapter/ActivityListAdapter.kt | 2 +- .../third_parties/fresco/BetterImageSpan.kt | 42 ++++++++----------- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/nextcloud/utils/text/MentionChipSpan.kt b/app/src/main/java/com/nextcloud/utils/text/MentionChipSpan.kt index dea38a1d15d3..7b439b36d024 100644 --- a/app/src/main/java/com/nextcloud/utils/text/MentionChipSpan.kt +++ b/app/src/main/java/com/nextcloud/utils/text/MentionChipSpan.kt @@ -7,7 +7,7 @@ package com.nextcloud.utils.text import android.graphics.drawable.Drawable -import thirdparties.fresco.BetterImageSpan +import third_parties.fresco.BetterImageSpan import java.util.Objects class MentionChipSpan(drawable: Drawable, verticalAlignment: Int, var id: String, var label: CharSequence?) : diff --git a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt index c683fb31be2b..e6d305e114f4 100644 --- a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt @@ -53,7 +53,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import thirdparties.fresco.BetterImageSpan +import third_parties.fresco.BetterImageSpan import java.util.Locale import kotlin.math.floor import kotlin.math.log diff --git a/app/src/main/java/third_parties/fresco/BetterImageSpan.kt b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt index da8a0bfff4a0..3f57b61b0e6d 100644 --- a/app/src/main/java/third_parties/fresco/BetterImageSpan.kt +++ b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt @@ -3,11 +3,10 @@ * SPDX-License-Identifier: MIT */ -package thirdparties.fresco +package third_parties.fresco import android.graphics.Canvas import android.graphics.Paint -import android.graphics.Rect import android.graphics.drawable.Drawable import android.text.style.ReplacementSpan import androidx.annotation.IntDef @@ -26,25 +25,21 @@ import androidx.annotation.IntDef */ open class BetterImageSpan @JvmOverloads constructor( val drawable: Drawable, - @param:BetterImageSpanAlignment private val mAlignment: Int = ALIGN_BASELINE + @param:BetterImageSpanAlignment private val alignment: Int = ALIGN_BASELINE ) : ReplacementSpan() { @Suppress("Detekt.SpreadOperator") @IntDef(*[ALIGN_BASELINE, ALIGN_BOTTOM, ALIGN_CENTER]) @Retention(AnnotationRetention.SOURCE) annotation class BetterImageSpanAlignment - private var mWidth = 0 - private var mHeight = 0 - private var mBounds: Rect? = null - private val mFontMetricsInt = Paint.FontMetricsInt() + private var width = 0 + private var height = 0 + private val fontMetricsInt = Paint.FontMetricsInt() init { updateBounds() } - /** - * Returns the width of the image span and increases the height if font metrics are available. - */ override fun getSize( paint: Paint, text: CharSequence, @@ -54,10 +49,11 @@ open class BetterImageSpan @JvmOverloads constructor( ): Int { updateBounds() if (fontMetrics == null) { - return mWidth + return width } + val offsetAbove = getOffsetAboveBaseline(fontMetrics) - val offsetBelow = mHeight + offsetAbove + val offsetBelow = height + offsetAbove if (offsetAbove < fontMetrics.ascent) { fontMetrics.ascent = offsetAbove } @@ -70,7 +66,7 @@ open class BetterImageSpan @JvmOverloads constructor( if (offsetBelow > fontMetrics.bottom) { fontMetrics.bottom = offsetBelow } - return mWidth + return width } override fun draw( @@ -84,31 +80,29 @@ open class BetterImageSpan @JvmOverloads constructor( bottom: Int, paint: Paint ) { - paint.getFontMetricsInt(mFontMetricsInt) - val iconTop = y + getOffsetAboveBaseline(mFontMetricsInt) + paint.getFontMetricsInt(fontMetricsInt) + val iconTop = y + getOffsetAboveBaseline(fontMetricsInt) canvas.translate(x, iconTop.toFloat()) drawable.draw(canvas) canvas.translate(-x, -iconTop.toFloat()) } private fun updateBounds() { - mBounds = drawable.bounds - mWidth = mBounds!!.width() - mHeight = mBounds!!.height() + val bounds = drawable.bounds + width = bounds.width() + height = bounds.height() } - private fun getOffsetAboveBaseline(fm: Paint.FontMetricsInt): Int = when (mAlignment) { - ALIGN_BOTTOM -> fm.descent - mHeight + private fun getOffsetAboveBaseline(fm: Paint.FontMetricsInt): Int = when (alignment) { + ALIGN_BOTTOM -> fm.descent - height ALIGN_CENTER -> { val textHeight = fm.descent - fm.ascent - val offset = (textHeight - mHeight) / 2 + val offset = (textHeight - height) / 2 fm.ascent + offset } - ALIGN_BASELINE -> -mHeight - - else -> -mHeight + else -> -height } companion object { From 39124049578dd22afb0cdd55f89f9e8fa418ba9d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Jul 2026 09:54:02 +0200 Subject: [PATCH 13/13] fix restoring empty state Signed-off-by: alperozturk96 --- .../android/ui/fragment/FileDetailActivitiesFragment.java | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailActivitiesFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailActivitiesFragment.java index 41052a429d30..451d37aaa4ac 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailActivitiesFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailActivitiesFragment.java @@ -461,7 +461,6 @@ private void hideRefreshLayoutLoader(FragmentActivity activity) { if (binding != null && getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) { binding.swipeContainingList.setRefreshing(false); binding.swipeContainingEmpty.setRefreshing(false); - binding.emptyList.emptyListView.setVisibility(View.GONE); isLoadingActivities = false; } });