diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt index 6121a30ad..2b0a7f6c9 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt @@ -110,7 +110,7 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) : @Suppress("NestedBlockDepth", "ComplexMethod", "LongMethod") override fun onBind(message: ChatMessage) { super.onBind(message) - image.minimumHeight = DisplayUtils.convertDpToPixel(MIN_IMAGE_HEIGHT, context).toInt() + image.minimumHeight = DisplayUtils.convertDpToPixel(MIN_IMAGE_HEIGHT, context!!).toInt() time.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp) diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageViewHolder.kt index 0c1feeba8..6c9980438 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/SystemMessageViewHolder.kt @@ -90,7 +90,7 @@ class SystemMessageViewHolder(itemView: View) : MessageHolders.IncomingTextMessa } else { individualMap["name"] } - messageString = DisplayUtils.searchAndColor(messageString, searchText, mentionColor) + messageString = DisplayUtils.searchAndColor(messageString, searchText!!, mentionColor) } } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 518bddde0..898dba17b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2142,7 +2142,7 @@ class ChatActivity : true ) - if (DisplayUtils.isDarkModeOn(supportActionBar?.themedContext)) { + if (DisplayUtils.isDarkModeOn(supportActionBar?.themedContext!!)) { url = "$url/dark" } diff --git a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt index f461e7d78..61a8aecef 100644 --- a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt @@ -70,6 +70,7 @@ fun ImageView.loadConversationAvatar( ) } +@Suppress("ReturnCount") fun ImageView.loadConversationAvatar( user: User, conversation: ConversationModel, diff --git a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt index d80c7492b..973397205 100644 --- a/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/location/LocationPickerActivity.kt @@ -281,7 +281,7 @@ class LocationPickerActivity : locationOverlay.setPersonHotspot(PERSON_HOT_SPOT_X, PERSON_HOT_SPOT_Y) locationOverlay.setPersonIcon( DisplayUtils.getBitmap( - ResourcesCompat.getDrawable(resources!!, R.drawable.current_location_circle, null) + ResourcesCompat.getDrawable(resources!!, R.drawable.current_location_circle, null)!! ) ) binding.map.overlays.add(locationOverlay) diff --git a/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java b/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java deleted file mode 100644 index 3e2c392c8..000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java +++ /dev/null @@ -1,576 +0,0 @@ -/* - * Nextcloud Talk application - * - * @author Mario Danic - * @author Andy Scherzinger - * @author Tim Krüger - * Copyright (C) 2022 Tim Krüger - * Copyright (C) 2021 Andy Scherzinger - * Copyright (C) 2017-2020 Mario Danic - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.nextcloud.talk.utils; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.res.ColorStateList; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.Build; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.TextPaint; -import android.text.TextUtils; -import android.text.format.DateUtils; -import android.text.method.LinkMovementMethod; -import android.text.style.AbsoluteSizeSpan; -import android.text.style.ClickableSpan; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; -import android.util.TypedValue; -import android.view.View; -import android.view.Window; -import android.widget.EditText; -import android.widget.ImageView; -import android.widget.TextView; - -import com.google.android.material.chip.ChipDrawable; -import com.nextcloud.talk.R; -import com.nextcloud.talk.application.NextcloudTalkApplication; -import com.nextcloud.talk.data.user.model.User; -import com.nextcloud.talk.events.UserMentionClickEvent; -import com.nextcloud.talk.extensions.ImageViewExtensionsKt; -import com.nextcloud.talk.ui.theme.ViewThemeUtils; -import com.nextcloud.talk.utils.text.Spans; - -import org.greenrobot.eventbus.EventBus; - -import java.text.DateFormat; -import java.util.Date; -import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import androidx.annotation.ColorInt; -import androidx.annotation.ColorRes; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.annotation.XmlRes; -import androidx.core.content.ContextCompat; -import androidx.core.content.res.ResourcesCompat; -import androidx.core.graphics.ColorUtils; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.emoji2.text.EmojiCompat; -import coil.Coil; -import coil.request.ImageRequest; -import coil.target.Target; -import coil.transform.CircleCropTransformation; -import third.parties.fresco.BetterImageSpan; - -import static com.nextcloud.talk.utils.FileSortOrder.SORT_A_TO_Z_ID; -import static com.nextcloud.talk.utils.FileSortOrder.SORT_BIG_TO_SMALL_ID; -import static com.nextcloud.talk.utils.FileSortOrder.SORT_NEW_TO_OLD_ID; -import static com.nextcloud.talk.utils.FileSortOrder.SORT_OLD_TO_NEW_ID; -import static com.nextcloud.talk.utils.FileSortOrder.SORT_SMALL_TO_BIG_ID; -import static com.nextcloud.talk.utils.FileSortOrder.SORT_Z_TO_A_ID; - -public class DisplayUtils { - private static final String TAG = DisplayUtils.class.getSimpleName(); - - private static final int INDEX_LUMINATION = 2; - private static final double MAX_LIGHTNESS = 0.92; - - private static final String TWITTER_HANDLE_PREFIX = "@"; - private static final String HTTP_PROTOCOL = "http://"; - private static final String HTTPS_PROTOCOL = "https://"; - - private static final int DATE_TIME_PARTS_SIZE = 2; - - public static Boolean isDarkModeOn(Context context) { - int currentNightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - return currentNightMode == Configuration.UI_MODE_NIGHT_YES; - } - - public static void setClickableString(String string, String url, TextView textView) { - SpannableString spannableString = new SpannableString(string); - spannableString.setSpan(new ClickableSpan() { - @Override - public void onClick(@NonNull View widget) { - Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - NextcloudTalkApplication - .Companion - .getSharedApplication() - .getApplicationContext() - .startActivity(browserIntent); - } - - @Override - public void updateDrawState(@NonNull TextPaint ds) { - super.updateDrawState(ds); - ds.setUnderlineText(false); - } - }, 0, string.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - textView.setText(spannableString); - textView.setMovementMethod(LinkMovementMethod.getInstance()); - } - - public static Bitmap getBitmap(Drawable drawable) { - Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), - drawable.getIntrinsicHeight(), - Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - return bitmap; - } - - public static float convertDpToPixel(float dp, Context context) { - return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, - context.getResources().getDisplayMetrics()) + 0.5f); - } - - public static float convertPixelToDp(float px, Context context) { - return px / context.getResources().getDisplayMetrics().density; - } - - public static Drawable getTintedDrawable(Resources res, @DrawableRes int drawableResId, @ColorRes int colorResId) { - Drawable drawable = ResourcesCompat.getDrawable(res, drawableResId, null); - - int color = res.getColor(colorResId); - if (drawable != null) { - drawable.setTint(color); - } - return drawable; - } - - public static Drawable getDrawableForMentionChipSpan(Context context, - String id, - String roomToken, - CharSequence label, - User conversationUser, - String type, - @XmlRes int chipResource, - @Nullable EditText emojiEditText, - ViewThemeUtils viewThemeUtils, - Boolean isFederated) { - ChipDrawable chip = ChipDrawable.createFromResource(context, chipResource); - chip.setText(EmojiCompat.get().process(label)); - chip.setEllipsize(TextUtils.TruncateAt.MIDDLE); - - if (chipResource == R.xml.chip_you) { - viewThemeUtils.material.colorChipDrawable(context, chip); - } - - Configuration config = context.getResources().getConfiguration(); - chip.setLayoutDirection(config.getLayoutDirection()); - - int drawable; - - boolean isCallOrGroup = - "call".equals(type) || "calls".equals(type) || "groups".equals(type) || "user-group".equals(type); - - if (!isCallOrGroup) { - if (chipResource == R.xml.chip_you) { - drawable = R.drawable.mention_chip; - } else { - drawable = R.drawable.accent_circle; - } - - chip.setChipIconResource(drawable); - } else { - chip.setChipIconResource(R.drawable.ic_circular_group); - } - - chip.setBounds(0, 0, chip.getIntrinsicWidth(), chip.getIntrinsicHeight()); - - if (!isCallOrGroup) { - String url = ApiUtils.getUrlForAvatar(conversationUser.getBaseUrl(), id, false); - if ("guests".equals(type) || "guest".equals(type)) { - url = ApiUtils.getUrlForGuestAvatar( - conversationUser.getBaseUrl(), - String.valueOf(label), true); - } - - if (isFederated) { - int darkTheme = (DisplayUtils.isDarkModeOn(context))? 1 : 0; - url = ApiUtils.getUrlForFederatedAvatar(Objects.requireNonNull(conversationUser.getBaseUrl()), - roomToken, id, - darkTheme, false); - } - - - ImageRequest imageRequest = new ImageRequest.Builder(context) - .data(url) - .crossfade(true) - .transformations(new CircleCropTransformation()) - .target(new Target() { - @Override - public void onStart(@Nullable Drawable drawable) { - - } - - @Override - public void onError(@Nullable Drawable drawable) { - chip.setChipIcon(drawable); - } - - @Override - public void onSuccess(@NonNull Drawable drawable) { - chip.setChipIcon(drawable); - // A hack to refresh the chip icon - if (emojiEditText != null) { - emojiEditText.post(() -> emojiEditText.setTextKeepState( - emojiEditText.getText(), - TextView.BufferType.SPANNABLE)); - } - } - }) - .build(); - - Coil.imageLoader(context).enqueue(imageRequest); - } - - return chip; - } - - public static Spannable searchAndReplaceWithMentionSpan(String key, Context context, Spanned text, - String id, String roomToken, - String label, String type, - User conversationUser, - @XmlRes int chipXmlRes, - ViewThemeUtils viewThemeUtils, - Boolean isFederated) { - - Spannable spannableString = new SpannableString(text); - String stringText = text.toString(); - - String keyWithBrackets = "{" + key + "}"; - Matcher m = Pattern.compile(keyWithBrackets, Pattern.CASE_INSENSITIVE | Pattern.LITERAL | Pattern.MULTILINE) - .matcher(spannableString); - - ClickableSpan clickableSpan = new ClickableSpan() { - @Override - public void onClick(@NonNull View widget) { - EventBus.getDefault().post(new UserMentionClickEvent(id)); - } - }; - - int lastStartIndex = 0; - Spans.MentionChipSpan mentionChipSpan; - while (m.find()) { - int start = stringText.indexOf(m.group(), lastStartIndex); - int end = start + m.group().length(); - lastStartIndex = end; - - Drawable drawableForChip = DisplayUtils.getDrawableForMentionChipSpan(context, - id, - roomToken, - label, - conversationUser, - type, - chipXmlRes, - null, - viewThemeUtils, - isFederated); - mentionChipSpan = new Spans.MentionChipSpan(drawableForChip, - BetterImageSpan.ALIGN_CENTER, - id, - label); - - spannableString.setSpan(mentionChipSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - - if (chipXmlRes == R.xml.chip_you) { - spannableString.setSpan( - viewThemeUtils.talk.themeForegroundColorSpan(context), - start, - end, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - } - if ("user".equals(type) && !conversationUser.getUserId().equals(id)) { - spannableString.setSpan(clickableSpan, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - } - } - - return spannableString; - } - - public static Spannable searchAndColor(Spannable text, String searchText, @ColorInt int color) { - - Spannable spannableString = new SpannableString(text); - String stringText = text.toString(); - if (TextUtils.isEmpty(text) || TextUtils.isEmpty(searchText)) { - return spannableString; - } - - Matcher m = Pattern.compile(searchText, - Pattern.CASE_INSENSITIVE | Pattern.LITERAL | Pattern.MULTILINE) - .matcher(spannableString); - - - int textSize = NextcloudTalkApplication.Companion.getSharedApplication().getResources().getDimensionPixelSize(R.dimen - .chat_text_size); - - int lastStartIndex = -1; - while (m.find()) { - int start = stringText.indexOf(m.group(), lastStartIndex); - int end = start + m.group().length(); - lastStartIndex = end; - spannableString.setSpan(new ForegroundColorSpan(color), start, end, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - spannableString.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - spannableString.setSpan(new AbsoluteSizeSpan(textSize), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - } - - return spannableString; - } - - public static Drawable getMessageSelector(@ColorInt int normalColor, - @ColorInt int selectedColor, - @ColorInt int pressedColor, - @DrawableRes int shape) { - - Drawable vectorDrawable = ContextCompat.getDrawable(NextcloudTalkApplication.Companion.getSharedApplication() - .getApplicationContext(), - shape); - Drawable drawable = DrawableCompat.wrap(vectorDrawable).mutate(); - DrawableCompat.setTintList( - drawable, - new ColorStateList( - new int[][]{ - new int[]{android.R.attr.state_selected}, - new int[]{android.R.attr.state_pressed}, - new int[]{-android.R.attr.state_pressed, -android.R.attr.state_selected} - }, - new int[]{selectedColor, pressedColor, normalColor} - )); - return drawable; - } - - /** - * Sets the color of the status bar to {@code color}. - * - * @param activity activity - * @param color the color - */ - public static void applyColorToStatusBar(Activity activity, @ColorInt int color) { - Window window = activity.getWindow(); - boolean isLightTheme = lightTheme(color); - if (window != null) { - - View decor = window.getDecorView(); - if (isLightTheme) { - int systemUiFlagLightStatusBar; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - systemUiFlagLightStatusBar = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | - View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; - } else { - systemUiFlagLightStatusBar = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; - } - decor.setSystemUiVisibility(systemUiFlagLightStatusBar); - } else { - decor.setSystemUiVisibility(0); - } - window.setStatusBarColor(color); - } - } - - /** - * Tests if light color is set - * - * @param color the color - * @return true if primaryColor is lighter than MAX_LIGHTNESS - */ - @SuppressWarnings("CLI_CONSTANT_LIST_INDEX") - public static boolean lightTheme(int color) { - float[] hsl = colorToHSL(color); - - // spotbugs dislikes fixed index access - // which is enforced by having such an - // array from Android-API itself - return hsl[INDEX_LUMINATION] >= MAX_LIGHTNESS; - } - - private static float[] colorToHSL(int color) { - float[] hsl = new float[3]; - ColorUtils.RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), hsl); - - return hsl; - } - - public static void applyColorToNavigationBar(Window window, @ColorInt int color) { - window.setNavigationBarColor(color); - } - - /** - * beautifies a given URL by removing any http/https protocol prefix. - * - * @param url to be beautified url - * @return beautified url - */ - public static String beautifyURL(@Nullable String url) { - if (TextUtils.isEmpty(url)) { - return ""; - } - - if (url.length() >= 7 && HTTP_PROTOCOL.equalsIgnoreCase(url.substring(0, 7))) { - return url.substring(HTTP_PROTOCOL.length()).trim(); - } - - if (url.length() >= 8 && HTTPS_PROTOCOL.equalsIgnoreCase(url.substring(0, 8))) { - return url.substring(HTTPS_PROTOCOL.length()).trim(); - } - - return url.trim(); - } - - /** - * beautifies a given twitter handle by prefixing it with an @ in case it is missing. - * - * @param handle to be beautified twitter handle - * @return beautified twitter handle - */ - public static String beautifyTwitterHandle(@Nullable String handle) { - if (handle != null) { - String trimmedHandle = handle.trim(); - - if (TextUtils.isEmpty(trimmedHandle)) { - return ""; - } - - if (trimmedHandle.startsWith(TWITTER_HANDLE_PREFIX)) { - return trimmedHandle; - } else { - return TWITTER_HANDLE_PREFIX + trimmedHandle; - } - } else { - return ""; - } - } - - public static void loadAvatarImage(User user, ImageView avatarImageView, boolean deleteCache) { - if (user != null && avatarImageView != null) { - String avatarId; - if (!TextUtils.isEmpty(user.getUserId())) { - avatarId = user.getUserId(); - } else { - avatarId = user.getUsername(); - } - - if (avatarId != null) { - ImageViewExtensionsKt.loadUserAvatar(avatarImageView, user, avatarId, true, deleteCache); - } - } - } - - public static @StringRes - int getSortOrderStringId(FileSortOrder sortOrder) { - switch (sortOrder.getName()) { - case SORT_Z_TO_A_ID: - return R.string.menu_item_sort_by_name_z_a; - case SORT_NEW_TO_OLD_ID: - return R.string.menu_item_sort_by_date_newest_first; - case SORT_OLD_TO_NEW_ID: - return R.string.menu_item_sort_by_date_oldest_first; - case SORT_BIG_TO_SMALL_ID: - return R.string.menu_item_sort_by_size_biggest_first; - case SORT_SMALL_TO_BIG_ID: - return R.string.menu_item_sort_by_size_smallest_first; - case SORT_A_TO_Z_ID: - default: - return R.string.menu_item_sort_by_name_a_z; - } - } - - /** - * calculates the relative time string based on the given modification timestamp. - * - * @param context the app's context - * @param modificationTimestamp the UNIX timestamp of the file modification time in milliseconds. - * @return a relative time string - */ - - public static CharSequence getRelativeTimestamp(Context context, long modificationTimestamp, boolean showFuture) { - return getRelativeDateTimeString(context, - modificationTimestamp, - DateUtils.SECOND_IN_MILLIS, - DateUtils.WEEK_IN_MILLIS, - 0, - showFuture); - } - - public static CharSequence getRelativeDateTimeString(Context c, - long time, - long minResolution, - long transitionResolution, - int flags, - boolean showFuture) { - - CharSequence dateString = ""; - - // in Future - if (!showFuture && time > System.currentTimeMillis()) { - return DisplayUtils.unixTimeToHumanReadable(time); - } - // < 60 seconds -> seconds ago - long diff = System.currentTimeMillis() - time; - if (diff > 0 && diff < 60 * 1000 && minResolution == DateUtils.SECOND_IN_MILLIS) { - return c.getString(R.string.secondsAgo); - } else { - dateString = DateUtils.getRelativeDateTimeString(c, time, minResolution, transitionResolution, flags); - } - - String[] parts = dateString.toString().split(","); - if (parts.length == DATE_TIME_PARTS_SIZE) { - if (parts[1].contains(":") && !parts[0].contains(":")) { - return parts[0]; - } else if (parts[0].contains(":") && !parts[1].contains(":")) { - return parts[1]; - } - } - // dateString contains unexpected format. fallback: use relative date time string from android api as is. - return dateString.toString(); - } - - /** - * Converts Unix time to human readable format - * - * @param milliseconds that have passed since 01/01/1970 - * @return The human readable time for the users locale - */ - public static String unixTimeToHumanReadable(long milliseconds) { - Date date = new Date(milliseconds); - DateFormat df = DateFormat.getDateTimeInstance(); - return df.format(date); - } - - public static String ellipsize(String text, int maxLength) { - if (text.length() > maxLength) { - return text.substring(0, maxLength - 1) + "…"; - } - return text; - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt new file mode 100644 index 000000000..14f8680a8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt @@ -0,0 +1,540 @@ +/* + * Nextcloud Talk application + * + * @author Mario Danic + * @author Andy Scherzinger + * @author Tim Krüger + * Copyright (C) 2022 Tim Krüger + * Copyright (C) 2021 Andy Scherzinger + * Copyright (C) 2017-2020 Mario Danic + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.nextcloud.talk.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.text.Spannable +import android.text.SpannableString +import android.text.Spanned +import android.text.TextPaint +import android.text.TextUtils +import android.text.format.DateUtils +import android.text.method.LinkMovementMethod +import android.text.style.AbsoluteSizeSpan +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.util.TypedValue +import android.view.View +import android.view.Window +import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.annotation.XmlRes +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.ColorUtils +import androidx.core.graphics.drawable.DrawableCompat +import androidx.emoji2.text.EmojiCompat +import coil.Coil.imageLoader +import coil.request.ImageRequest +import coil.target.Target +import coil.transform.CircleCropTransformation +import com.google.android.material.chip.ChipDrawable +import com.nextcloud.talk.R +import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.events.UserMentionClickEvent +import com.nextcloud.talk.extensions.loadUserAvatar +import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.utils.ApiUtils.getUrlForAvatar +import com.nextcloud.talk.utils.ApiUtils.getUrlForFederatedAvatar +import com.nextcloud.talk.utils.ApiUtils.getUrlForGuestAvatar +import com.nextcloud.talk.utils.text.Spans.MentionChipSpan +import org.greenrobot.eventbus.EventBus +import third.parties.fresco.BetterImageSpan +import java.text.DateFormat +import java.util.Date +import java.util.regex.Pattern + +object DisplayUtils { + private val TAG = DisplayUtils::class.java.getSimpleName() + private const val INDEX_LUMINATION = 2 + private const val MAX_LIGHTNESS = 0.92 + private const val TWITTER_HANDLE_PREFIX = "@" + private const val HTTP_PROTOCOL = "http://" + private const val HTTPS_PROTOCOL = "https://" + private const val DATE_TIME_PARTS_SIZE = 2 + fun isDarkModeOn(context: Context): Boolean { + val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return currentNightMode == Configuration.UI_MODE_NIGHT_YES + } + + fun setClickableString(string: String, url: String?, textView: TextView) { + val spannableString = SpannableString(string) + spannableString.setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + sharedApplication!!.applicationContext.startActivity(browserIntent) + } + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.isUnderlineText = false + } + }, + 0, + string.length, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE + ) + textView.text = spannableString + textView.movementMethod = LinkMovementMethod.getInstance() + } + + fun getBitmap(drawable: Drawable): Bitmap { + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } + + @JvmStatic + fun convertDpToPixel(dp: Float, context: Context): Float { + return Math.round( + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, dp, + context.resources.displayMetrics + ) + 0.5f + ).toFloat() + } + + fun convertPixelToDp(px: Float, context: Context): Float { + return px / context.resources.displayMetrics.density + } + + fun getTintedDrawable(res: Resources, @DrawableRes drawableResId: Int, @ColorRes colorResId: Int): Drawable? { + val drawable = ResourcesCompat.getDrawable(res, drawableResId, null) + val color = res.getColor(colorResId) + drawable?.setTint(color) + return drawable + } + + @JvmStatic + fun getDrawableForMentionChipSpan( + context: Context, + id: String?, + roomToken: String?, + label: CharSequence, + conversationUser: User, + type: String, + @XmlRes chipResource: Int, + emojiEditText: EditText?, + viewThemeUtils: ViewThemeUtils, + isFederated: Boolean + ): Drawable { + val chip = ChipDrawable.createFromResource(context, chipResource) + chip.text = EmojiCompat.get().process(label) + chip.ellipsize = TextUtils.TruncateAt.MIDDLE + if (chipResource == R.xml.chip_you) { + viewThemeUtils.material.colorChipDrawable(context, chip) + } + val config = context.resources.configuration + chip.setLayoutDirection(config.layoutDirection) + val drawable: Int + val isCallOrGroup = "call" == type || "calls" == type || "groups" == type || "user-group" == type + if (!isCallOrGroup) { + drawable = if (chipResource == R.xml.chip_you) { + R.drawable.mention_chip + } else { + R.drawable.accent_circle + } + chip.setChipIconResource(drawable) + } else { + chip.setChipIconResource(R.drawable.ic_circular_group) + } + chip.setBounds(0, 0, chip.intrinsicWidth, chip.intrinsicHeight) + if (!isCallOrGroup) { + var url = getUrlForAvatar(conversationUser.baseUrl, id, false) + if ("guests" == type || "guest" == type) { + url = getUrlForGuestAvatar( + conversationUser.baseUrl, label.toString(), true + ) + } + if (isFederated) { + val darkTheme = if (isDarkModeOn(context)) 1 else 0 + url = getUrlForFederatedAvatar( + conversationUser.baseUrl!!, + roomToken!!, id!!, + darkTheme, false + ) + } + val imageRequest: ImageRequest = ImageRequest.Builder(context) + .data(url) + .crossfade(true) + .transformations(CircleCropTransformation()) + .target(object : Target { + override fun onStart(placeholder: Drawable?) {} + override fun onError(error: Drawable?) { + chip.chipIcon = error + } + + override fun onSuccess(result: Drawable) { + chip.chipIcon = result + // A hack to refresh the chip icon + emojiEditText?.post { + emojiEditText.setTextKeepState( + emojiEditText.getText(), + TextView.BufferType.SPANNABLE + ) + } + } + }) + .build() + imageLoader(context).enqueue(imageRequest) + } + return chip + } + + fun searchAndReplaceWithMentionSpan( + key: String, + context: Context, + text: Spanned, + id: String, + roomToken: String?, + label: String, + type: String, + conversationUser: User, + @XmlRes chipXmlRes: Int, + viewThemeUtils: ViewThemeUtils, + isFederated: Boolean + ): Spannable { + val spannableString: Spannable = SpannableString(text) + val stringText = text.toString() + val keyWithBrackets = "{$key}" + val m = Pattern.compile(keyWithBrackets, Pattern.CASE_INSENSITIVE or Pattern.LITERAL or Pattern.MULTILINE) + .matcher(spannableString) + val clickableSpan: ClickableSpan = object : ClickableSpan() { + override fun onClick(widget: View) { + EventBus.getDefault().post(UserMentionClickEvent(id)) + } + } + var lastStartIndex = 0 + var mentionChipSpan: MentionChipSpan + while (m.find()) { + val start = stringText.indexOf(m.group(), lastStartIndex) + val end = start + m.group().length + lastStartIndex = end + val drawableForChip = getDrawableForMentionChipSpan( + context, + id, + roomToken, + label, + conversationUser, + type, + chipXmlRes, + null, + viewThemeUtils, + isFederated + ) + mentionChipSpan = MentionChipSpan( + drawableForChip, + BetterImageSpan.ALIGN_CENTER, + id, + label + ) + spannableString.setSpan(mentionChipSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + if (chipXmlRes == R.xml.chip_you) { + spannableString.setSpan( + viewThemeUtils.talk.themeForegroundColorSpan(context), + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + if ("user" == type && conversationUser.userId != id) { + spannableString.setSpan(clickableSpan, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } + } + return spannableString + } + + fun searchAndColor(text: Spannable, searchText: String, @ColorInt color: Int): Spannable { + val spannableString: Spannable = SpannableString(text) + val stringText = text.toString() + if (TextUtils.isEmpty(text) || TextUtils.isEmpty(searchText)) { + return spannableString + } + val m = Pattern.compile( + searchText, + Pattern.CASE_INSENSITIVE or Pattern.LITERAL or Pattern.MULTILINE + ) + .matcher(spannableString) + val textSize = sharedApplication!!.resources.getDimensionPixelSize(R.dimen.chat_text_size) + var lastStartIndex = -1 + while (m.find()) { + val start = stringText.indexOf(m.group(), lastStartIndex) + val end = start + m.group().length + lastStartIndex = end + spannableString.setSpan( + ForegroundColorSpan(color), + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + spannableString.setSpan(StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + spannableString.setSpan(AbsoluteSizeSpan(textSize), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + return spannableString + } + + fun getMessageSelector( + @ColorInt normalColor: Int, + @ColorInt selectedColor: Int, + @ColorInt pressedColor: Int, + @DrawableRes shape: Int + ): Drawable { + val vectorDrawable = ContextCompat.getDrawable( + sharedApplication!!.applicationContext, + shape + ) + val drawable = DrawableCompat.wrap(vectorDrawable!!).mutate() + DrawableCompat.setTintList( + drawable, + ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_selected), + intArrayOf(android.R.attr.state_pressed), + intArrayOf(-android.R.attr.state_pressed, -android.R.attr.state_selected) + ), + intArrayOf(selectedColor, pressedColor, normalColor) + ) + ) + return drawable + } + + /** + * Sets the color of the status bar to `color`. + * + * @param activity activity + * @param color the color + */ + fun applyColorToStatusBar(activity: Activity, @ColorInt color: Int) { + val window = activity.window + val isLightTheme = lightTheme(color) + if (window != null) { + val decor = window.decorView + if (isLightTheme) { + val systemUiFlagLightStatusBar: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or + View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + } else { + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } + decor.systemUiVisibility = systemUiFlagLightStatusBar + } else { + decor.systemUiVisibility = 0 + } + window.statusBarColor = color + } + } + + /** + * Tests if light color is set + * + * @param color the color + * @return true if primaryColor is lighter than MAX_LIGHTNESS + */ + fun lightTheme(color: Int): Boolean { + val hsl = colorToHSL(color) + + // spotbugs dislikes fixed index access + // which is enforced by having such an + // array from Android-API itself + return hsl[INDEX_LUMINATION] >= MAX_LIGHTNESS + } + + private fun colorToHSL(color: Int): FloatArray { + val hsl = FloatArray(3) + ColorUtils.RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), hsl) + return hsl + } + + fun applyColorToNavigationBar(window: Window, @ColorInt color: Int) { + window.navigationBarColor = color + } + + /** + * beautifies a given URL by removing any http/https protocol prefix. + * + * @param url to be beautified url + * @return beautified url + */ + @Suppress("ReturnCount") + fun beautifyURL(url: String?): String { + if (TextUtils.isEmpty(url)) { + return "" + } + if (url!!.length >= 7 && HTTP_PROTOCOL.equals(url.substring(0, 7), ignoreCase = true)) { + return url.substring(HTTP_PROTOCOL.length).trim { it <= ' ' } + } + return if (url.length >= 8 && HTTPS_PROTOCOL.equals(url.substring(0, 8), ignoreCase = true)) { + url.substring(HTTPS_PROTOCOL.length).trim { it <= ' ' } + } else { + url.trim { it <= ' ' } + } + } + + /** + * beautifies a given twitter handle by prefixing it with an @ in case it is missing. + * + * @param handle to be beautified twitter handle + * @return beautified twitter handle + */ + fun beautifyTwitterHandle(handle: String?): String { + return if (handle != null) { + val trimmedHandle = handle.trim { it <= ' ' } + if (TextUtils.isEmpty(trimmedHandle)) { + return "" + } + if (trimmedHandle.startsWith(TWITTER_HANDLE_PREFIX)) { + trimmedHandle + } else { + TWITTER_HANDLE_PREFIX + trimmedHandle + } + } else { + "" + } + } + + fun loadAvatarImage(user: User?, avatarImageView: ImageView?, deleteCache: Boolean) { + if (user != null && avatarImageView != null) { + val avatarId: String? = if (!TextUtils.isEmpty(user.userId)) { + user.userId + } else { + user.username + } + if (avatarId != null) { + avatarImageView.loadUserAvatar(user, avatarId, true, deleteCache) + } + } + } + + @StringRes + fun getSortOrderStringId(sortOrder: FileSortOrder): Int { + return when (sortOrder.name) { + FileSortOrder.SORT_Z_TO_A_ID -> R.string.menu_item_sort_by_name_z_a + FileSortOrder.SORT_NEW_TO_OLD_ID -> R.string.menu_item_sort_by_date_newest_first + FileSortOrder.SORT_OLD_TO_NEW_ID -> R.string.menu_item_sort_by_date_oldest_first + FileSortOrder.SORT_BIG_TO_SMALL_ID -> R.string.menu_item_sort_by_size_biggest_first + FileSortOrder.SORT_SMALL_TO_BIG_ID -> R.string.menu_item_sort_by_size_smallest_first + FileSortOrder.SORT_A_TO_Z_ID -> R.string.menu_item_sort_by_name_a_z + else -> R.string.menu_item_sort_by_name_a_z + } + } + + /** + * calculates the relative time string based on the given modification timestamp. + * + * @param context the app's context + * @param modificationTimestamp the UNIX timestamp of the file modification time in milliseconds. + * @return a relative time string + */ + fun getRelativeTimestamp(context: Context, modificationTimestamp: Long, showFuture: Boolean): CharSequence { + return getRelativeDateTimeString( + context, + modificationTimestamp, + DateUtils.SECOND_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0, + showFuture + ) + } + + @Suppress("ReturnCount") + private fun getRelativeDateTimeString( + c: Context, + time: Long, + minResolution: Long, + transitionResolution: Long, + flags: Int, + showFuture: Boolean + ): CharSequence { + val dateString: CharSequence + + // in Future + if (!showFuture && time > System.currentTimeMillis()) { + return unixTimeToHumanReadable(time) + } + // < 60 seconds -> seconds ago + val diff = System.currentTimeMillis() - time + dateString = if (diff > 0 && diff < 60 * 1000 && minResolution == DateUtils.SECOND_IN_MILLIS) { + return c.getString(R.string.secondsAgo) + } else { + DateUtils.getRelativeDateTimeString(c, time, minResolution, transitionResolution, flags) + } + val parts = dateString.toString().split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (parts.size == DATE_TIME_PARTS_SIZE) { + if (parts[1].contains(":") && !parts[0].contains(":")) { + return parts[0] + } else if (parts[0].contains(":") && !parts[1].contains(":")) { + return parts[1] + } + } + // dateString contains unexpected format. fallback: use relative date time string from android api as is. + return dateString.toString() + } + + /** + * Converts Unix time to human readable format + * + * @param milliseconds that have passed since 01/01/1970 + * @return The human readable time for the users locale + */ + fun unixTimeToHumanReadable(milliseconds: Long): String { + val date = Date(milliseconds) + val df = DateFormat.getDateTimeInstance() + return df.format(date) + } + + fun ellipsize(text: String, maxLength: Int): String { + return if (text.length > maxLength) { + text.substring(0, maxLength - 1) + "…" + } else { + text + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt index cb8787746..73856063c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt @@ -134,10 +134,10 @@ class MessageUtils(val context: Context) { } messageStringInternal = DisplayUtils.searchAndReplaceWithMentionSpan( - key, + key!!, themingContext, messageStringInternal, - id, + id!!, message.roomToken, individualHashMap["name"]!!, individualHashMap["type"]!!,