1package eu.siacs.conversations.utils;
  2
  3import android.content.Context;
  4import android.content.res.ColorStateList;
  5import android.text.SpannableStringBuilder;
  6import android.text.format.DateFormat;
  7import android.text.format.DateUtils;
  8import android.util.Pair;
  9import android.widget.TextView;
 10import androidx.annotation.ColorInt;
 11import androidx.annotation.ColorRes;
 12import androidx.annotation.StringRes;
 13import androidx.core.content.ContextCompat;
 14import com.google.android.material.color.MaterialColors;
 15import com.google.common.base.Joiner;
 16import com.google.common.base.Splitter;
 17import com.google.common.base.Strings;
 18import com.google.common.collect.Collections2;
 19import eu.siacs.conversations.Config;
 20import eu.siacs.conversations.R;
 21import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 22import eu.siacs.conversations.entities.Account;
 23import eu.siacs.conversations.entities.Contact;
 24import eu.siacs.conversations.entities.Conversation;
 25import eu.siacs.conversations.entities.Conversational;
 26import eu.siacs.conversations.entities.Message;
 27import eu.siacs.conversations.entities.MucOptions;
 28import eu.siacs.conversations.entities.RtpSessionStatus;
 29import eu.siacs.conversations.entities.Transferable;
 30import eu.siacs.conversations.ui.util.QuoteHelper;
 31import eu.siacs.conversations.worker.ExportBackupWorker;
 32import eu.siacs.conversations.xmpp.Jid;
 33import im.conversations.android.xmpp.model.stanza.Presence;
 34import java.util.Arrays;
 35import java.util.Calendar;
 36import java.util.Date;
 37import java.util.List;
 38import java.util.Locale;
 39
 40public class UIHelper {
 41
 42    private static final List<String> LOCATION_QUESTIONS =
 43            Arrays.asList(
 44                    "where are you", // en
 45                    "where are you now", // en
 46                    "where are you right now", // en
 47                    "whats your 20", // en
 48                    "what is your 20", // en
 49                    "what's your 20", // en
 50                    "whats your twenty", // en
 51                    "what is your twenty", // en
 52                    "what's your twenty", // en
 53                    "wo bist du", // de
 54                    "wo bist du jetzt", // de
 55                    "wo bist du gerade", // de
 56                    "wo seid ihr", // de
 57                    "wo seid ihr jetzt", // de
 58                    "wo seid ihr gerade", // de
 59                    "dónde estás", // es
 60                    "donde estas" // es
 61                    );
 62
 63    private static final List<Character> PUNCTIONATION =
 64            Arrays.asList('.', ',', '?', '!', ';', ':');
 65
 66    private static final int SHORT_DATE_FLAGS =
 67            DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_YEAR | DateUtils.FORMAT_ABBREV_ALL;
 68    private static final int FULL_DATE_FLAGS =
 69            DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_DATE;
 70
 71    public static String readableTimeDifference(Context context, long time) {
 72        return readableTimeDifference(context, time, false);
 73    }
 74
 75    public static String readableTimeDifferenceFull(Context context, long time) {
 76        return readableTimeDifference(context, time, true);
 77    }
 78
 79    private static String readableTimeDifference(Context context, long time, boolean fullDate) {
 80        if (time == 0) {
 81            return context.getString(R.string.just_now);
 82        }
 83        Date date = new Date(time);
 84        long difference = (System.currentTimeMillis() - time) / 1000;
 85        if (difference < 60) {
 86            return context.getString(R.string.just_now);
 87        } else if (difference < 60 * 2) {
 88            return context.getString(R.string.minute_ago);
 89        } else if (difference < 60 * 15) {
 90            return context.getString(R.string.minutes_ago, Math.round(difference / 60.0));
 91        } else if (today(date)) {
 92            java.text.DateFormat df = DateFormat.getTimeFormat(context);
 93            return df.format(date);
 94        } else {
 95            if (fullDate) {
 96                return DateUtils.formatDateTime(context, date.getTime(), FULL_DATE_FLAGS);
 97            } else {
 98                return DateUtils.formatDateTime(context, date.getTime(), SHORT_DATE_FLAGS);
 99            }
100        }
101    }
102
103    private static boolean today(Date date) {
104        return sameDay(date, new Date(System.currentTimeMillis()));
105    }
106
107    public static boolean today(long date) {
108        return sameDay(date, System.currentTimeMillis());
109    }
110
111    public static boolean yesterday(long date) {
112        Calendar cal1 = Calendar.getInstance();
113        Calendar cal2 = Calendar.getInstance();
114        cal1.add(Calendar.DAY_OF_YEAR, -1);
115        cal2.setTime(new Date(date));
116        return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR)
117                && cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR);
118    }
119
120    public static boolean sameDay(long a, long b) {
121        return sameDay(new Date(a), new Date(b));
122    }
123
124    private static boolean sameDay(Date a, Date b) {
125        Calendar cal1 = Calendar.getInstance();
126        Calendar cal2 = Calendar.getInstance();
127        cal1.setTime(a);
128        cal2.setTime(b);
129        return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR)
130                && cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR);
131    }
132
133    public static String lastseen(Context context, boolean active, long time) {
134        long difference = (System.currentTimeMillis() - time) / 1000;
135        if (active) {
136            return context.getString(R.string.online_right_now);
137        } else if (difference < 60) {
138            return context.getString(R.string.last_seen_now);
139        } else if (difference < 60 * 2) {
140            return context.getString(R.string.last_seen_min);
141        } else if (difference < 60 * 60) {
142            return context.getString(R.string.last_seen_mins, Math.round(difference / 60.0));
143        } else if (difference < 60 * 60 * 2) {
144            return context.getString(R.string.last_seen_hour);
145        } else if (difference < 60 * 60 * 24) {
146            return context.getString(
147                    R.string.last_seen_hours, Math.round(difference / (60.0 * 60.0)));
148        } else if (difference < 60 * 60 * 48) {
149            return context.getString(R.string.last_seen_day);
150        } else {
151            return context.getString(
152                    R.string.last_seen_days, Math.round(difference / (60.0 * 60.0 * 24.0)));
153        }
154    }
155
156    public static int getColorForName(final String name) {
157        return XEP0392Helper.rgbFromNick(name);
158    }
159
160    public static Pair<CharSequence, Boolean> getMessagePreview(
161            final Context context, final Message message) {
162        return getMessagePreview(context, message, 0);
163    }
164
165    public static Pair<CharSequence, Boolean> getMessagePreview(
166            final Context context, final Message message, @ColorInt int textColor) {
167        final Transferable d = message.getTransferable();
168        if (d != null) {
169            switch (d.getStatus()) {
170                case Transferable.STATUS_CHECKING:
171                    return new Pair<>(
172                            context.getString(
173                                    R.string.checking_x,
174                                    getFileDescriptionString(context, message)),
175                            true);
176                case Transferable.STATUS_DOWNLOADING:
177                    return new Pair<>(
178                            context.getString(
179                                    R.string.receiving_x_file,
180                                    getFileDescriptionString(context, message),
181                                    d.getProgress()),
182                            true);
183                case Transferable.STATUS_OFFER:
184                case Transferable.STATUS_OFFER_CHECK_FILESIZE:
185                    return new Pair<>(
186                            context.getString(
187                                    R.string.x_file_offered_for_download,
188                                    getFileDescriptionString(context, message)),
189                            true);
190                case Transferable.STATUS_FAILED:
191                    return new Pair<>(context.getString(R.string.file_transmission_failed), true);
192                case Transferable.STATUS_CANCELLED:
193                    return new Pair<>(
194                            context.getString(R.string.file_transmission_cancelled), true);
195                case Transferable.STATUS_UPLOADING:
196                    if (message.getStatus() == Message.STATUS_OFFERED) {
197                        return new Pair<>(
198                                context.getString(
199                                        R.string.offering_x_file,
200                                        getFileDescriptionString(context, message)),
201                                true);
202                    } else {
203                        return new Pair<>(
204                                context.getString(
205                                        R.string.sending_x_file,
206                                        getFileDescriptionString(context, message)),
207                                true);
208                    }
209                default:
210                    return new Pair<>("", false);
211            }
212        } else if (message.isFileOrImage() && message.isDeleted()) {
213            return new Pair<>(context.getString(R.string.file_deleted), true);
214        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
215            return new Pair<>(context.getString(R.string.pgp_message), true);
216        } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
217            return new Pair<>(context.getString(R.string.decryption_failed), true);
218        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
219            return new Pair<>(context.getString(R.string.not_encrypted_for_this_device), true);
220        } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
221            return new Pair<>(context.getString(R.string.omemo_decryption_failed), true);
222        } else if (message.isFileOrImage()) {
223            return new Pair<>(getFileDescriptionString(context, message), true);
224        } else if (message.getType() == Message.TYPE_RTP_SESSION) {
225            RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
226            final boolean received = message.getStatus() == Message.STATUS_RECEIVED;
227            if (!rtpSessionStatus.successful && received) {
228                return new Pair<>(context.getString(R.string.missed_call), true);
229            } else {
230                return new Pair<>(
231                        context.getString(
232                                received ? R.string.incoming_call : R.string.outgoing_call),
233                        true);
234            }
235        } else {
236            final String body = MessageUtils.filterLtrRtl(message.getBody());
237            if (body.startsWith(Message.ME_COMMAND)) {
238                return new Pair<>(
239                        body.replaceAll(
240                                "^" + Message.ME_COMMAND,
241                                UIHelper.getMessageDisplayName(message) + " "),
242                        false);
243            } else if (message.isGeoUri()) {
244                return new Pair<>(context.getString(R.string.location), true);
245            } else if (message.treatAsDownloadable()
246                    || MessageUtils.unInitiatedButKnownSize(message)) {
247                return new Pair<>(
248                        context.getString(
249                                R.string.x_file_offered_for_download,
250                                getFileDescriptionString(context, message)),
251                        true);
252            } else {
253                if (textColor != 0) {
254                    return new Pair<>(getStyledBodyOneLine(body, textColor), false);
255                } else {
256                    return new Pair<>(getBodyOmitQuotesAndBlocks(body), false);
257                }
258            }
259        }
260    }
261
262    private static CharSequence getBodyOmitQuotesAndBlocks(final String body) {
263        final var parts = Splitter.on('\n').trimResults().omitEmptyStrings().splitToList(body);
264        final var filtered =
265                Collections2.filter(
266                        parts,
267                        line ->
268                                !QuoteHelper.isPositionQuoteCharacter(line, 0)
269                                        && !line.equals("```"));
270        if (filtered.isEmpty()) {
271            return body;
272        }
273        return Joiner.on(' ').join(filtered);
274    }
275
276    private static CharSequence getStyledBodyOneLine(final String body, final int textColor) {
277        final var styledBody = new SpannableStringBuilder(body);
278        StylingHelper.format(styledBody, 0, styledBody.length() - 1, textColor);
279        final var builder = new SpannableStringBuilder();
280        for (final var l : CharSequenceUtils.split(styledBody, '\n')) {
281            if (l.length() == 0) {
282                continue;
283            }
284            if (l.toString().equals("```")) {
285                continue;
286            }
287            if (QuoteHelper.isPositionQuoteCharacter(l, 0)) {
288                continue;
289            }
290            final var trimmed = CharSequenceUtils.trim(l);
291            if (trimmed.length() == 0) {
292                continue;
293            }
294            char last = trimmed.charAt(trimmed.length() - 1);
295            if (builder.length() != 0) {
296                builder.append(' ');
297            }
298            builder.append(trimmed);
299            if (!PUNCTIONATION.contains(last)) {
300                break;
301            }
302        }
303        if (builder.length() == 0) {
304            return body.trim();
305        } else {
306            return builder;
307        }
308    }
309
310    public static boolean isLastLineQuote(String body) {
311        if (body.endsWith("\n")) {
312            return false;
313        }
314        String[] lines = body.split("\n");
315        if (lines.length == 0) {
316            return false;
317        }
318        String line = lines[lines.length - 1];
319        if (line.isEmpty()) {
320            return false;
321        }
322        char first = line.charAt(0);
323        return first == '>' && isPositionFollowedByQuoteableCharacter(line, 0) || first == '\u00bb';
324    }
325
326    public static CharSequence shorten(CharSequence input) {
327        return input.length() > 256 ? StylingHelper.subSequence(input, 0, 256) : input;
328    }
329
330    public static boolean isPositionPrecededByBodyStart(CharSequence body, int pos) {
331        // true if not a single linebreak before current position
332        for (int i = pos - 1; i >= 0; i--) {
333            if (body.charAt(i) != ' ') {
334                return false;
335            }
336        }
337        return true;
338    }
339
340    public static boolean isPositionPrecededByLineStart(CharSequence body, int pos) {
341        if (isPositionPrecededByBodyStart(body, pos)) {
342            return true;
343        }
344        return body.charAt(pos - 1) == '\n';
345    }
346
347    public static boolean isPositionFollowedByQuoteableCharacter(CharSequence body, int pos) {
348        return !isPositionFollowedByNumber(body, pos)
349                && !isPositionFollowedByEmoticon(body, pos)
350                && !isPositionFollowedByEquals(body, pos);
351    }
352
353    private static boolean isPositionFollowedByNumber(CharSequence body, int pos) {
354        boolean previousWasNumber = false;
355        for (int i = pos + 1; i < body.length(); i++) {
356            char c = body.charAt(i);
357            if (Character.isDigit(body.charAt(i))) {
358                previousWasNumber = true;
359            } else if (previousWasNumber && (c == '.' || c == ',')) {
360                previousWasNumber = false;
361            } else {
362                return (Character.isWhitespace(c) || c == '%' || c == '+') && previousWasNumber;
363            }
364        }
365        return previousWasNumber;
366    }
367
368    private static boolean isPositionFollowedByEquals(CharSequence body, int pos) {
369        return body.length() > pos + 1 && body.charAt(pos + 1) == '=';
370    }
371
372    private static boolean isPositionFollowedByEmoticon(CharSequence body, int pos) {
373        if (body.length() <= pos + 1) {
374            return false;
375        } else {
376            final char first = body.charAt(pos + 1);
377            return first == ';'
378                    || first == ':'
379                    || first == '.' // do not quote >.< (but >>.<)
380                    || closingBeforeWhitespace(body, pos + 1);
381        }
382    }
383
384    private static boolean closingBeforeWhitespace(CharSequence body, int pos) {
385        for (int i = pos; i < body.length(); ++i) {
386            final char c = body.charAt(i);
387            if (Character.isWhitespace(c)) {
388                return false;
389            } else if (QuoteHelper.isPositionQuoteCharacter(body, pos)
390                    || QuoteHelper.isPositionQuoteEndCharacter(body, pos)) {
391                return body.length() == i + 1 || Character.isWhitespace(body.charAt(i + 1));
392            }
393        }
394        return false;
395    }
396
397    public static String getDisplayName(MucOptions.User user) {
398        Contact contact = user.getContact();
399        if (contact != null) {
400            return contact.getDisplayName();
401        } else {
402            final String name = user.getName();
403            if (name != null) {
404                return name;
405            }
406            final Jid realJid = user.getRealJid();
407            if (realJid != null) {
408                return JidHelper.localPartOrFallback(realJid);
409            }
410            return null;
411        }
412    }
413
414    public static String concatNames(List<MucOptions.User> users) {
415        return concatNames(users, users.size());
416    }
417
418    public static String concatNames(List<MucOptions.User> users, int max) {
419        StringBuilder builder = new StringBuilder();
420        final boolean shortNames = users.size() >= 3;
421        for (int i = 0; i < Math.min(users.size(), max); ++i) {
422            if (builder.length() != 0) {
423                builder.append(", ");
424            }
425            final String name = UIHelper.getDisplayName(users.get(i));
426            if (name != null) {
427                builder.append(shortNames ? name.split("\\s+")[0] : name);
428            }
429        }
430        return builder.toString();
431    }
432
433    public static String getFileDescriptionString(final Context context, final Message message) {
434        final String mime = message.getMimeType();
435        if (Strings.isNullOrEmpty(mime)) {
436            return context.getString(R.string.file);
437        } else if (MimeUtils.AMBIGUOUS_CONTAINER_FORMATS.contains(mime)) {
438            return context.getString(R.string.multimedia_file);
439        } else if (mime.equals("audio/x-m4b")) {
440            return context.getString(R.string.audiobook);
441        } else if (mime.startsWith("audio/")) {
442            return context.getString(R.string.audio);
443        } else if (mime.startsWith("video/")) {
444            return context.getString(R.string.video);
445        } else if (mime.equals("image/gif")) {
446            return context.getString(R.string.gif);
447        } else if (mime.equals("image/svg+xml")) {
448            return context.getString(R.string.vector_graphic);
449        } else if (mime.startsWith("image/") || message.getType() == Message.TYPE_IMAGE) {
450            return context.getString(R.string.image);
451        } else if (mime.contains("pdf")) {
452            return context.getString(R.string.pdf_document);
453        } else if (MimeUtils.WORD_DOCUMENT_MIMES.contains(mime)) {
454            return context.getString(R.string.word_document);
455        } else if (mime.equals("application/vnd.android.package-archive")) {
456            return context.getString(R.string.apk);
457        } else if (mime.equals(ExportBackupWorker.MIME_TYPE)) {
458            return context.getString(R.string.conversations_backup);
459        } else if (mime.contains("vcard")) {
460            return context.getString(R.string.vcard);
461        } else if (mime.equals("text/x-vcalendar") || mime.equals("text/calendar")) {
462            return context.getString(R.string.event);
463        } else if (mime.equals("application/epub+zip")
464                || mime.equals("application/vnd.amazon.mobi8-ebook")) {
465            return context.getString(R.string.ebook);
466        } else if (mime.equals("application/gpx+xml")) {
467            return context.getString(R.string.gpx_track);
468        } else if (mime.equals("text/plain")) {
469            return context.getString(R.string.plain_text_document);
470        } else {
471            return mime;
472        }
473    }
474
475    public static String getMessageDisplayName(final Message message) {
476        final Conversational conversation = message.getConversation();
477        if (message.getStatus() == Message.STATUS_RECEIVED) {
478            final Contact contact = message.getContact();
479            if (conversation.getMode() == Conversation.MODE_MULTI) {
480                if (contact != null) {
481                    return contact.getDisplayName();
482                } else {
483                    return getDisplayedMucCounterpart(message.getCounterpart());
484                }
485            } else {
486                return contact != null ? contact.getDisplayName() : "";
487            }
488        } else {
489            if (conversation instanceof Conversation
490                    && conversation.getMode() == Conversation.MODE_MULTI) {
491                return ((Conversation) conversation).getMucOptions().getSelf().getName();
492            } else {
493                final Account account = conversation.getAccount();
494                final Jid jid = account.getJid();
495                final String displayName = account.getDisplayName();
496                if (Strings.isNullOrEmpty(displayName)) {
497                    return jid.getLocal() != null ? jid.getLocal() : jid.getDomain().toString();
498                } else {
499                    return displayName;
500                }
501            }
502        }
503    }
504
505    public static String getMessageHint(final Context context, final Conversation conversation) {
506        return switch (conversation.getNextEncryption()) {
507            case Message.ENCRYPTION_NONE -> {
508                if (Config.multipleEncryptionChoices()) {
509                    yield context.getString(R.string.send_unencrypted_message);
510                } else {
511                    yield context.getString(R.string.send_message_to_x, conversation.getName());
512                }
513            }
514            case Message.ENCRYPTION_AXOLOTL -> {
515                final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
516                if (axolotlService != null && axolotlService.trustedSessionVerified(conversation)) {
517                    yield context.getString(R.string.send_omemo_x509_message);
518                } else {
519                    yield context.getString(R.string.send_encrypted_message);
520                }
521            }
522            default -> context.getString(R.string.send_encrypted_message);
523        };
524    }
525
526    public static String getDisplayedMucCounterpart(final Jid counterpart) {
527        if (counterpart == null) {
528            return "";
529        } else if (!counterpart.isBareJid()) {
530            return counterpart.getResource().trim();
531        } else {
532            return counterpart.toString().trim();
533        }
534    }
535
536    public static boolean receivedLocationQuestion(final Message message) {
537        if (message == null
538                || message.getStatus() != Message.STATUS_RECEIVED
539                || message.getType() != Message.TYPE_TEXT) {
540            return false;
541        }
542        final String body =
543                Strings.nullToEmpty(message.getBody())
544                        .trim()
545                        .toLowerCase(Locale.getDefault())
546                        .replace("?", "")
547                        .replace("¿", "");
548        return LOCATION_QUESTIONS.contains(body);
549    }
550
551    public static void setStatus(final TextView textView, Presence.Availability status) {
552        final @StringRes int text;
553        final @ColorRes int color =
554                switch (status) {
555                    case CHAT -> {
556                        text = R.string.presence_chat;
557                        yield R.color.green_800;
558                    }
559                    case ONLINE -> {
560                        text = R.string.presence_online;
561                        yield R.color.green_800;
562                    }
563                    case AWAY -> {
564                        text = R.string.presence_away;
565                        yield R.color.amber_800;
566                    }
567                    case XA -> {
568                        text = R.string.presence_xa;
569                        yield R.color.orange_800;
570                    }
571                    case DND -> {
572                        text = R.string.presence_dnd;
573                        yield R.color.red_800;
574                    }
575                    default -> throw new IllegalStateException();
576                };
577        textView.setText(text);
578        textView.setBackgroundTintList(
579                ColorStateList.valueOf(
580                        MaterialColors.harmonizeWithPrimary(
581                                textView.getContext(),
582                                ContextCompat.getColor(textView.getContext(), color))));
583    }
584
585    public static String filesizeToString(long size) {
586        if (size > (1.5 * 1024 * 1024)) {
587            return Math.round(size * 1f / (1024 * 1024)) + " MiB";
588        } else if (size >= 1024) {
589            return Math.round(size * 1f / 1024) + " KiB";
590        } else {
591            return size + " B";
592        }
593    }
594}