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..7b439b36d024 --- /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 third_parties.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/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityListAdapter.kt index 2ae062e338f4..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 @@ -8,18 +8,20 @@ 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 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 @@ -27,10 +29,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.MentionChipSpan import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.ActivityListItemBinding @@ -49,12 +53,13 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import third_parties.fresco.BetterImageSpan import java.util.Locale 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, @@ -62,30 +67,43 @@ 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() 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 if (clear) values.clear() - - 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) - } - values.add(activity) - } + appendGroupedByHeader(activityItems) { (it as Activity).datetime.time } notifyDataSetChanged() } + 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) { @@ -102,84 +120,147 @@ open class ActivityListAdapter( } } - @Suppress("LongMethod") + 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) + 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) + text = DateFormat.format(TIME_PATTERN, activity.datetime.time) } + } + private fun ActivityViewHolder.bindSubject(activity: Activity) { when { - activity.richSubjectElement.richSubject.isNotEmpty() -> holder.binding.subject.apply { - visibility = View.VISIBLE - movementMethod = LinkMovementMethod.getInstance() - setText(addClickablePart(activity.richSubjectElement), TextView.BufferType.SPANNABLE) - } + 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)) } + } + } + + 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 (_: IllegalArgumentException) { + Log_OC.e(TAG, "error setting column count to $columns") } } - private suspend fun nextcloudClient(): NextcloudClient = withContext(Dispatchers.IO) { + 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) + } + + 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 { @@ -193,7 +274,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 -> @@ -212,12 +295,7 @@ open class ActivityListAdapter( else -> imageView.setImageDrawable( - MimeTypeUtil.getFileTypeIcon( - previewObject.mimeType, - "", - context, - viewThemeUtils - ) + MimeTypeUtil.getFileTypeIcon(previewObject.mimeType, "", context, viewThemeUtils) ) } @@ -228,85 +306,86 @@ open class ActivityListAdapter( 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(PLACEHOLDER_START) + while (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) } - 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 - ) - } - idx1 = text.indexOf('{', idx2) - } + val nextSearchStart = when { + richObject == null -> end - return ssb - } - - override fun getItemViewType(position: Int) = if (values[position] is Activity) ACTIVITY_TYPE else HEADER_TYPE + richObject.type == USER_TYPE -> { + ssb.applyMentionSpan(richObject, start, end) + end + } - override fun getItemCount() = values.size + else -> { + val nameEnd = ssb.applyClickableNameSpan(richObject, start, end) + text = ssb.toString() + nameEnd + } + } - fun isEmpty() = values.isEmpty() + start = text.indexOf(PLACEHOLDER_START, nextSearchStart) + } - 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() + return ssb } - 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 + 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 ) } - override fun getHeaderPositionForItem(itemPosition: Int): Int { - var pos = itemPosition - while (pos >= 0 && !isHeader(pos)) pos-- - return pos + setSpan(span, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) } - override fun getHeaderLayout(headerPosition: Int) = R.layout.activity_list_item_header + 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 - override fun bindHeaderData(header: View?, headerPosition: Int) { - header?.findViewById(R.id.header)?.text = values[headerPosition] as String + 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 isHeader(itemPosition: Int) = - itemPosition in values.indices && getItemViewType(itemPosition) == HEADER_TYPE + 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() + } + // endregion protected class ActivityViewHolder(val binding: ActivityListItemBinding) : RecyclerView.ViewHolder(binding.root) @@ -318,6 +397,13 @@ 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 } } 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..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 @@ -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); }); @@ -459,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; } }); 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..3f57b61b0e6d --- /dev/null +++ b/app/src/main/java/third_parties/fresco/BetterImageSpan.kt @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2015-present, Facebook, Inc. and its affiliates. + * SPDX-License-Identifier: MIT + */ + +package third_parties.fresco + +import android.graphics.Canvas +import android.graphics.Paint +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 alignment: Int = ALIGN_BASELINE +) : ReplacementSpan() { + @Suppress("Detekt.SpreadOperator") + @IntDef(*[ALIGN_BASELINE, ALIGN_BOTTOM, ALIGN_CENTER]) + @Retention(AnnotationRetention.SOURCE) + annotation class BetterImageSpanAlignment + + private var width = 0 + private var height = 0 + private val fontMetricsInt = Paint.FontMetricsInt() + + init { + updateBounds() + } + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fontMetrics: Paint.FontMetricsInt? + ): Int { + updateBounds() + if (fontMetrics == null) { + return width + } + + val offsetAbove = getOffsetAboveBaseline(fontMetrics) + val offsetBelow = height + 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 width + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + paint.getFontMetricsInt(fontMetricsInt) + val iconTop = y + getOffsetAboveBaseline(fontMetricsInt) + canvas.translate(x, iconTop.toFloat()) + drawable.draw(canvas) + canvas.translate(-x, -iconTop.toFloat()) + } + + private fun updateBounds() { + val bounds = drawable.bounds + width = bounds.width() + height = bounds.height() + } + + 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 - height) / 2 + fm.ascent + offset + } + + else -> -height + } + + 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/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 + + 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 @@ + +