From 91ef23081b1421ad8524ce7f0c5e17b454370d8e Mon Sep 17 00:00:00 2001 From: Ryan ZHAO Date: Fri, 8 Jan 2021 11:10:51 +1100 Subject: [PATCH] move Recipient to libsession --- .../messaging/contacts/ContactAccessor.java | 172 ++ .../contacts/ContactsCursorLoader.java | 257 +++ .../contacts/avatars/AvatarHelper.java | 62 + .../contacts/avatars/ContactColors.java | 32 + .../contacts/avatars/ContactColorsLegacy.java | 40 + .../contacts/avatars/ContactPhoto.java | 23 + .../avatars/FallbackContactPhoto.java | 11 + .../avatars/GeneratedContactPhoto.java | 91 + .../avatars/GroupRecordContactPhoto.java | 74 + .../contacts/avatars/ProfileContactPhoto.java | 61 + .../avatars/ResourceContactPhoto.java | 77 + .../contacts/avatars/SystemContactPhoto.java | 67 + .../avatars/TransparentContactPhoto.java | 26 + .../threads/recipients/Recipient.java | 919 ++++++++ .../threads/recipients/RecipientExporter.java | 46 + .../RecipientFormattingException.java | 35 + .../recipients/RecipientModifiedListener.java | 6 + .../threads/recipients/RecipientProvider.java | 243 +++ .../recipients/RecipientsFormatter.java | 76 + .../libsession/utilities/Conversions.java | 180 ++ .../libsession/utilities/DynamicLanguage.java | 55 + .../utilities/FutureTaskListener.java | 24 + .../utilities/LinkedBlockingLifoQueue.java | 23 + .../utilities/ListenableFutureTask.java | 126 ++ .../utilities/ProfilePictureModifiedEvent.kt | 5 + .../libsession/utilities/SSKEnvironment.kt | 46 + .../libsession/utilities/SoftHashMap.java | 328 +++ .../utilities/TextSecurePreferences.kt | 4 + .../libsession/utilities/ThemeUtil.java | 70 + .../org/session/libsession/utilities/Util.kt | 61 + .../libsession/utilities/ViewUtil.java | 256 +++ .../utilities/color/MaterialColor.java | 135 ++ .../utilities/color/MaterialColors.java | 70 + .../concurrent/AssertedSuccessListener.java | 12 + .../concurrent/ListenableFuture.java | 13 + .../utilities/concurrent/SettableFuture.java | 136 ++ .../utilities/concurrent/SignalExecutors.java | 40 + .../utilities/concurrent/SimpleTask.java | 59 + .../DynamicLanguageActivityHelper.java | 40 + .../DynamicLanguageContextWrapper.java | 34 + .../dynamiclanguage/LanguageString.java | 48 + .../utilities/dynamiclanguage/LocaleParser.kt | 28 + .../LocaleParserHelperProtocol.kt | 8 + .../libsession/utilities/views/Stub.java | 30 + .../res/drawable/avatar_gradient_dark.xml | 11 + .../res/drawable/avatar_gradient_light.xml | 11 + .../src/main/res/drawable/ic_person_large.xml | 4 + libsession/src/main/res/values/arrays.xml | 311 +++ libsession/src/main/res/values/attrs.xml | 301 +++ libsession/src/main/res/values/colors.xml | 120 ++ .../main/res/values/conversation_colors.xml | 54 + .../src/main/res/values/core_colors.xml | 21 + .../main/res/values/crop_area_renderer.xml | 10 + libsession/src/main/res/values/dimens.xml | 174 ++ libsession/src/main/res/values/emoji.xml | 3 + .../res/values/google-playstore-strings.xml | 36 + .../res/values/ic_launcher_background.xml | 4 + libsession/src/main/res/values/ids.xml | 6 + libsession/src/main/res/values/integers.xml | 4 + .../src/main/res/values/material_colors.xml | 278 +++ libsession/src/main/res/values/strings.xml | 1878 +++++++++++++++++ libsession/src/main/res/values/styles.xml | 441 ++++ .../src/main/res/values/text_styles.xml | 51 + libsession/src/main/res/values/themes.xml | 294 +++ libsession/src/main/res/values/values.xml | 5 + .../src/main/res/values/vector_paths.xml | 20 + 66 files changed, 8186 insertions(+) create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/ContactAccessor.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/ContactsCursorLoader.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/AvatarHelper.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactColors.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactColorsLegacy.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactPhoto.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/FallbackContactPhoto.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/GeneratedContactPhoto.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/GroupRecordContactPhoto.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ProfileContactPhoto.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ResourceContactPhoto.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/SystemContactPhoto.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/TransparentContactPhoto.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/threads/recipients/Recipient.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientExporter.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientFormattingException.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientModifiedListener.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientProvider.java create mode 100644 libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientsFormatter.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/Conversions.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/DynamicLanguage.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/FutureTaskListener.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/LinkedBlockingLifoQueue.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/ListenableFutureTask.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/ProfilePictureModifiedEvent.kt create mode 100644 libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt create mode 100644 libsession/src/main/java/org/session/libsession/utilities/SoftHashMap.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/ViewUtil.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/color/MaterialColor.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/color/MaterialColors.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/concurrent/ListenableFuture.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/concurrent/SettableFuture.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/concurrent/SignalExecutors.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/concurrent/SimpleTask.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/DynamicLanguageActivityHelper.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/DynamicLanguageContextWrapper.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LanguageString.java create mode 100644 libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LocaleParser.kt create mode 100644 libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LocaleParserHelperProtocol.kt create mode 100644 libsession/src/main/java/org/session/libsession/utilities/views/Stub.java create mode 100644 libsession/src/main/res/drawable/avatar_gradient_dark.xml create mode 100644 libsession/src/main/res/drawable/avatar_gradient_light.xml create mode 100644 libsession/src/main/res/drawable/ic_person_large.xml create mode 100644 libsession/src/main/res/values/arrays.xml create mode 100644 libsession/src/main/res/values/attrs.xml create mode 100644 libsession/src/main/res/values/colors.xml create mode 100644 libsession/src/main/res/values/conversation_colors.xml create mode 100644 libsession/src/main/res/values/core_colors.xml create mode 100644 libsession/src/main/res/values/crop_area_renderer.xml create mode 100644 libsession/src/main/res/values/dimens.xml create mode 100644 libsession/src/main/res/values/emoji.xml create mode 100644 libsession/src/main/res/values/google-playstore-strings.xml create mode 100644 libsession/src/main/res/values/ic_launcher_background.xml create mode 100644 libsession/src/main/res/values/ids.xml create mode 100644 libsession/src/main/res/values/integers.xml create mode 100644 libsession/src/main/res/values/material_colors.xml create mode 100644 libsession/src/main/res/values/strings.xml create mode 100644 libsession/src/main/res/values/styles.xml create mode 100644 libsession/src/main/res/values/text_styles.xml create mode 100644 libsession/src/main/res/values/themes.xml create mode 100644 libsession/src/main/res/values/values.xml create mode 100644 libsession/src/main/res/values/vector_paths.xml diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/ContactAccessor.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/ContactAccessor.java new file mode 100644 index 0000000000..e0fd4aa2c7 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/ContactAccessor.java @@ -0,0 +1,172 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * 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 org.session.libsession.messaging.contacts; + +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.LinkedList; +import java.util.List; + +import network.loki.messenger.R; + +import static org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; + +/** + * This class was originally a layer of indirection between + * ContactAccessorNewApi and ContactAccesorOldApi, which corresponded + * to the API changes between 1.x and 2.x. + * + * Now that we no longer support 1.x, this class mostly serves as a place + * to encapsulate Contact-related logic. It's still a singleton, mostly + * just because that's how it's currently called from everywhere. + * + * @author Moxie Marlinspike + */ + +public class ContactAccessor { + + private static final ContactAccessor instance = new ContactAccessor(); + + public static synchronized ContactAccessor getInstance() { + return instance; + } + + public String getNameFromContact(Context context, Uri uri) { + return "Anonymous"; + } + + public ContactData getContactData(Context context, Uri uri) { + return getContactData(context, getNameFromContact(context, uri), Long.parseLong(uri.getLastPathSegment())); + } + + private ContactData getContactData(Context context, String displayName, long id) { + return new ContactData(id, displayName); + } + + public List getNumbersForThreadSearchFilter(Context context, String constraint) { + LinkedList numberList = new LinkedList<>(); + + GroupDatabase.Reader reader = null; + GroupRecord record; + + try { + reader = DatabaseFactory.getGroupDatabase(context).getGroupsFilteredByTitle(constraint); + + while ((record = reader.getNext()) != null) { + numberList.add(record.getEncodedId()); + } + } finally { + if (reader != null) + reader.close(); + } + + if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) && + !numberList.contains(TextSecurePreferences.getLocalNumber(context))) + { + numberList.add(TextSecurePreferences.getLocalNumber(context)); + } + + return numberList; + } + + public CharSequence phoneTypeToString(Context mContext, int type, CharSequence label) { + return label; + } + + public static class NumberData implements Parcelable { + + public static final Creator CREATOR = new Creator() { + public NumberData createFromParcel(Parcel in) { + return new NumberData(in); + } + + public NumberData[] newArray(int size) { + return new NumberData[size]; + } + }; + + public final String number; + public final String type; + + public NumberData(String type, String number) { + this.type = type; + this.number = number; + } + + public NumberData(Parcel in) { + number = in.readString(); + type = in.readString(); + } + + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(number); + dest.writeString(type); + } + } + + public static class ContactData implements Parcelable { + + public static final Creator CREATOR = new Creator() { + public ContactData createFromParcel(Parcel in) { + return new ContactData(in); + } + + public ContactData[] newArray(int size) { + return new ContactData[size]; + } + }; + + public final long id; + public final String name; + public final List numbers; + + public ContactData(long id, String name) { + this.id = id; + this.name = name; + this.numbers = new LinkedList(); + } + + public ContactData(Parcel in) { + id = in.readLong(); + name = in.readString(); + numbers = new LinkedList(); + in.readTypedList(numbers, NumberData.CREATOR); + } + + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeString(name); + dest.writeTypedList(numbers); + } + } + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/ContactsCursorLoader.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/ContactsCursorLoader.java new file mode 100644 index 0000000000..9d114ce04a --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/ContactsCursorLoader.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2013-2017 Open Whisper Systems + * + * 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 org.session.libsession.messaging.contacts; + +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.loader.content.CursorLoader; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.util.NumberUtil; + +import java.util.ArrayList; +import java.util.List; + +import network.loki.messenger.R; + +/** + * CursorLoader that initializes a ContactsDatabase instance + * + * @author Jake McGinty + */ +public class ContactsCursorLoader extends CursorLoader { + private static final String TAG = ContactsCursorLoader.class.getSimpleName(); + + static final int NORMAL_TYPE = 0; + static final int PUSH_TYPE = 1; + static final int NEW_TYPE = 2; + static final int RECENT_TYPE = 3; + static final int DIVIDER_TYPE = 4; + + static final String CONTACT_TYPE_COLUMN = "contact_type"; + static final String LABEL_COLUMN = "label"; + static final String NUMBER_TYPE_COLUMN = "number_type"; + static final String NUMBER_COLUMN = "number"; + static final String NAME_COLUMN = "name"; + + public static final class DisplayMode { + public static final int FLAG_PUSH = 1; + public static final int FLAG_SMS = 1 << 1; + public static final int FLAG_GROUPS = 1 << 2; + public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_GROUPS; + } + + private static final String[] CONTACT_PROJECTION = new String[]{NAME_COLUMN, + NUMBER_COLUMN, + NUMBER_TYPE_COLUMN, + LABEL_COLUMN, + CONTACT_TYPE_COLUMN}; + + private static final int RECENT_CONVERSATION_MAX = 25; + + private final String filter; + private final int mode; + private final boolean recents; + + public ContactsCursorLoader(@NonNull Context context, int mode, String filter, boolean recents) + { + super(context); + + this.filter = filter; + this.mode = mode; + this.recents = recents; + } + + @Override + public Cursor loadInBackground() { + List cursorList = TextUtils.isEmpty(filter) ? getUnfilteredResults() + : getFilteredResults(); + if (cursorList.size() > 0) { + return new MergeCursor(cursorList.toArray(new Cursor[0])); + } + return null; + } + + private List getUnfilteredResults() { + ArrayList cursorList = new ArrayList<>(); + + if (recents) { + Cursor recentConversations = getRecentConversationsCursor(); + if (recentConversations.getCount() > 0) { + cursorList.add(getRecentsHeaderCursor()); + cursorList.add(recentConversations); + cursorList.add(getContactsHeaderCursor()); + } + } + cursorList.addAll(getContactsCursors()); + return cursorList; + } + + private List getFilteredResults() { + ArrayList cursorList = new ArrayList<>(); + + if (groupsEnabled(mode)) { + Cursor groups = getGroupsCursor(); + if (groups.getCount() > 0) { + List contacts = getContactsCursors(); + if (!isCursorListEmpty(contacts)) { + cursorList.add(getContactsHeaderCursor()); + cursorList.addAll(contacts); + cursorList.add(getGroupsHeaderCursor()); + } + cursorList.add(groups); + } else { + cursorList.addAll(getContactsCursors()); + } + } else { + cursorList.addAll(getContactsCursors()); + } + + if (NumberUtil.isValidSmsOrEmail(filter)) { + cursorList.add(getNewNumberCursor()); + } + + return cursorList; + } + + private Cursor getRecentsHeaderCursor() { + MatrixCursor recentsHeader = new MatrixCursor(CONTACT_PROJECTION); + /* + recentsHeader.addRow(new Object[]{ getContext().getString(R.string.ContactsCursorLoader_recent_chats), + "", + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + ContactsDatabase.DIVIDER_TYPE }); + */ + return recentsHeader; + } + + private Cursor getContactsHeaderCursor() { + MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1); + /* + contactsHeader.addRow(new Object[] { getContext().getString(R.string.ContactsCursorLoader_contacts), + "", + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + ContactsDatabase.DIVIDER_TYPE }); + */ + return contactsHeader; + } + + private Cursor getGroupsHeaderCursor() { + MatrixCursor groupHeader = new MatrixCursor(CONTACT_PROJECTION, 1); + groupHeader.addRow(new Object[]{ getContext().getString(R.string.ContactsCursorLoader_groups), + "", + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + DIVIDER_TYPE }); + return groupHeader; + } + + + private Cursor getRecentConversationsCursor() { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(getContext()); + + MatrixCursor recentConversations = new MatrixCursor(CONTACT_PROJECTION, RECENT_CONVERSATION_MAX); + try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX)) { + ThreadDatabase.Reader reader = threadDatabase.readerFor(rawConversations); + ThreadRecord threadRecord; + while ((threadRecord = reader.getNext()) != null) { + recentConversations.addRow(new Object[] { threadRecord.getRecipient().toShortString(), + threadRecord.getRecipient().getAddress().serialize(), + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + "", + RECENT_TYPE }); + } + } + return recentConversations; + } + + private List getContactsCursors() { + return new ArrayList<>(2); + /* + if (!Permissions.hasAny(getContext(), Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { + return cursorList; + } + + if (pushEnabled(mode)) { + cursorList.add(contactsDatabase.queryTextSecureContacts(filter)); + } + + if (pushEnabled(mode) && smsEnabled(mode)) { + cursorList.add(contactsDatabase.querySystemContacts(filter)); + } else if (smsEnabled(mode)) { + cursorList.add(filterNonPushContacts(contactsDatabase.querySystemContacts(filter))); + } + return cursorList; + */ + } + + private Cursor getGroupsCursor() { + MatrixCursor groupContacts = new MatrixCursor(CONTACT_PROJECTION); + try (GroupDatabase.Reader reader = DatabaseFactory.getGroupDatabase(getContext()).getGroupsFilteredByTitle(filter)) { + GroupDatabase.GroupRecord groupRecord; + while ((groupRecord = reader.getNext()) != null) { + groupContacts.addRow(new Object[] { groupRecord.getTitle(), + groupRecord.getEncodedId(), + ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, + "", + NORMAL_TYPE }); + } + } + return groupContacts; + } + + private Cursor getNewNumberCursor() { + MatrixCursor newNumberCursor = new MatrixCursor(CONTACT_PROJECTION, 1); + newNumberCursor.addRow(new Object[] { getContext().getString(R.string.contact_selection_list__unknown_contact), + filter, + ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, + "\u21e2", + NEW_TYPE }); + return newNumberCursor; + } + + private static boolean isCursorListEmpty(List list) { + int sum = 0; + for (Cursor cursor : list) { + sum += cursor.getCount(); + } + return sum == 0; + } + + private static boolean pushEnabled(int mode) { + return (mode & DisplayMode.FLAG_PUSH) > 0; + } + + private static boolean smsEnabled(int mode) { + return (mode & DisplayMode.FLAG_SMS) > 0; + } + + private static boolean groupsEnabled(int mode) { + return (mode & DisplayMode.FLAG_GROUPS) > 0; + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/AvatarHelper.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/AvatarHelper.java new file mode 100644 index 0000000000..5c5051ed9a --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/AvatarHelper.java @@ -0,0 +1,62 @@ +package org.session.libsession.messaging.contacts.avatars; + + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.session.libsession.messaging.threads.Address; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedList; +import java.util.List; + +public class AvatarHelper { + + private static final String AVATAR_DIRECTORY = "avatars"; + + public static InputStream getInputStreamFor(@NonNull Context context, @NonNull Address address) + throws IOException + { + return new FileInputStream(getAvatarFile(context, address)); + } + + public static List getAvatarFiles(@NonNull Context context) { + File avatarDirectory = new File(context.getFilesDir(), AVATAR_DIRECTORY); + File[] results = avatarDirectory.listFiles(); + + if (results == null) return new LinkedList<>(); + else return Stream.of(results).toList(); + } + + public static void delete(@NonNull Context context, @NonNull Address address) { + getAvatarFile(context, address).delete(); + } + + public static @NonNull File getAvatarFile(@NonNull Context context, @NonNull Address address) { + File avatarDirectory = new File(context.getFilesDir(), AVATAR_DIRECTORY); + avatarDirectory.mkdirs(); + + return new File(avatarDirectory, new File(address.serialize()).getName()); + } + + public static void setAvatar(@NonNull Context context, @NonNull Address address, @Nullable byte[] data) + throws IOException + { + if (data == null) { + delete(context, address); + } else { + try (FileOutputStream out = new FileOutputStream(getAvatarFile(context, address))) { + out.write(data); + } + } + } + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactColors.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactColors.java new file mode 100644 index 0000000000..e157034f34 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactColors.java @@ -0,0 +1,32 @@ +package org.session.libsession.messaging.contacts.avatars; + +import androidx.annotation.NonNull; + +import org.session.libsession.utilities.color.MaterialColor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class ContactColors { + + public static final MaterialColor UNKNOWN_COLOR = MaterialColor.STEEL; + + private static final List CONVERSATION_PALETTE = new ArrayList<>(Arrays.asList( + MaterialColor.PLUM, + MaterialColor.CRIMSON, + MaterialColor.VERMILLION, + MaterialColor.VIOLET, + MaterialColor.BLUE, + MaterialColor.INDIGO, + MaterialColor.FOREST, + MaterialColor.WINTERGREEN, + MaterialColor.TEAL, + MaterialColor.BURLAP, + MaterialColor.TAUPE + )); + + public static MaterialColor generateFor(@NonNull String name) { + return CONVERSATION_PALETTE.get(Math.abs(name.hashCode()) % CONVERSATION_PALETTE.size()); + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactColorsLegacy.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactColorsLegacy.java new file mode 100644 index 0000000000..af7201f715 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactColorsLegacy.java @@ -0,0 +1,40 @@ +package org.session.libsession.messaging.contacts.avatars; + +import androidx.annotation.NonNull; + +import org.session.libsession.utilities.color.MaterialColor; + + +/** + * Used for migrating legacy colors to modern colors. For normal color generation, use + * {@link ContactColors}. + */ +public class ContactColorsLegacy { + + private static final String[] LEGACY_PALETTE = new String[] { + "red", + "pink", + "purple", + "deep_purple", + "indigo", + "blue", + "light_blue", + "cyan", + "teal", + "green", + "light_green", + "orange", + "deep_orange", + "amber", + "blue_grey" + }; + + public static MaterialColor generateFor(@NonNull String name) { + String serialized = LEGACY_PALETTE[Math.abs(name.hashCode()) % LEGACY_PALETTE.length]; + try { + return MaterialColor.fromSerialized(serialized); + } catch (MaterialColor.UnknownColorException e) { + return ContactColors.generateFor(name); + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactPhoto.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactPhoto.java new file mode 100644 index 0000000000..62b7e7ab95 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ContactPhoto.java @@ -0,0 +1,23 @@ +package org.session.libsession.messaging.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Key; + +import java.io.IOException; +import java.io.InputStream; + +public interface ContactPhoto extends Key { + + InputStream openInputStream(Context context) throws IOException; + + @Nullable Uri getUri(@NonNull Context context); + + boolean isProfilePhoto(); + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/FallbackContactPhoto.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/FallbackContactPhoto.java new file mode 100644 index 0000000000..1ce982f986 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/FallbackContactPhoto.java @@ -0,0 +1,11 @@ +package org.session.libsession.messaging.contacts.avatars; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +public interface FallbackContactPhoto { + + public Drawable asDrawable(Context context, int color); + public Drawable asDrawable(Context context, int color, boolean inverted); + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/GeneratedContactPhoto.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/GeneratedContactPhoto.java new file mode 100644 index 0000000000..9961e84f26 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/GeneratedContactPhoto.java @@ -0,0 +1,91 @@ +package org.session.libsession.messaging.contacts.avatars; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.text.TextUtils; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; + +import com.amulyakhare.textdrawable.TextDrawable; + +import org.session.libsession.R; +import org.session.libsession.utilities.ThemeUtil; +import org.session.libsession.utilities.ViewUtil; + +import java.util.regex.Pattern; + + +public class GeneratedContactPhoto implements FallbackContactPhoto { + + private static final Pattern PATTERN = Pattern.compile("[^\\p{L}\\p{Nd}\\p{S}]+"); + private static final Typeface TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL); + + private final String name; + private final int fallbackResId; + + public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId) { + this.name = name; + this.fallbackResId = fallbackResId; + } + + @Override + public Drawable asDrawable(Context context, int color) { + return asDrawable(context, color,false); + } + + @Override + public Drawable asDrawable(Context context, int color, boolean inverted) { + int targetSize = context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); + String character = getAbbreviation(name); + + if (!TextUtils.isEmpty(character)) { + Drawable base = TextDrawable.builder() + .beginConfig() + .width(targetSize) + .height(targetSize) + .useFont(TYPEFACE) + .fontSize(ViewUtil.dpToPx(context, 24)) + .textColor(inverted ? color : Color.WHITE) + .endConfig() + .buildRound(character, inverted ? Color.WHITE : color); + + Drawable gradient = context.getResources().getDrawable(ThemeUtil.isDarkTheme(context) ? R.drawable.avatar_gradient_dark + : R.drawable.avatar_gradient_light); + return new LayerDrawable(new Drawable[] { base, gradient }); + } + + return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted); + } + + private @Nullable String getAbbreviation(String name) { + String[] parts = name.split(" "); + StringBuilder builder = new StringBuilder(); + int count = 0; + + for (int i = 0; i < parts.length && count < 2; i++) { + String cleaned = PATTERN.matcher(parts[i]).replaceFirst(""); + if (!TextUtils.isEmpty(cleaned)) { + builder.appendCodePoint(cleaned.codePointAt(0)); + count++; + } + } + + if (builder.length() == 0) { + return null; + } else { + return builder.toString(); + } + } + + @Override + public Drawable asCallCard(Context context) { + return AppCompatResources.getDrawable(context, R.drawable.ic_person_large); + + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/GroupRecordContactPhoto.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/GroupRecordContactPhoto.java new file mode 100644 index 0000000000..c9689b9f93 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/GroupRecordContactPhoto.java @@ -0,0 +1,74 @@ +package org.session.libsession.messaging.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.session.libsession.messaging.MessagingConfiguration; +import org.session.libsession.messaging.StorageProtocol; +import org.session.libsession.messaging.threads.Address; +import org.session.libsession.messaging.threads.GroupRecord; +import org.session.libsession.utilities.Conversions; +import org.session.libsignal.libsignal.util.guava.Optional; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; + +public class GroupRecordContactPhoto implements ContactPhoto { + + private final @NonNull + Address address; + private final long avatarId; + + public GroupRecordContactPhoto(@NonNull Address address, long avatarId) { + this.address = address; + this.avatarId = avatarId; + } + + @Override + public InputStream openInputStream(Context context) throws IOException { + StorageProtocol groupDatabase = MessagingConfiguration.shared.getStorage(); + Optional groupRecord = Optional.of(groupDatabase.getGroup(address.toGroupString())); + + if (groupRecord.isPresent() && groupRecord.get().getAvatar() != null) { + return new ByteArrayInputStream(groupRecord.get().getAvatar()); + } + + throw new IOException("Couldn't load avatar for group: " + address.toGroupString()); + } + + @Override + public @Nullable Uri getUri(@NonNull Context context) { + return null; + } + + @Override + public boolean isProfilePhoto() { + return false; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(address.serialize().getBytes()); + messageDigest.update(Conversions.longToByteArray(avatarId)); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof GroupRecordContactPhoto)) return false; + + GroupRecordContactPhoto that = (GroupRecordContactPhoto)other; + return this.address.equals(that.address) && this.avatarId == that.avatarId; + } + + @Override + public int hashCode() { + return this.address.hashCode() ^ (int) avatarId; + } + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ProfileContactPhoto.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ProfileContactPhoto.java new file mode 100644 index 0000000000..fd9d5b5e43 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ProfileContactPhoto.java @@ -0,0 +1,61 @@ +package org.session.libsession.messaging.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.session.libsession.messaging.threads.Address; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; + +public class ProfileContactPhoto implements ContactPhoto { + + private final @NonNull + Address address; + public final @NonNull String avatarObject; + + public ProfileContactPhoto(@NonNull Address address, @NonNull String avatarObject) { + this.address = address; + this.avatarObject = avatarObject; + } + + @Override + public InputStream openInputStream(Context context) throws IOException { + return AvatarHelper.getInputStreamFor(context, address); + } + + @Override + public @Nullable Uri getUri(@NonNull Context context) { + return Uri.fromFile(AvatarHelper.getAvatarFile(context, address)); + } + + @Override + public boolean isProfilePhoto() { + return true; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(address.serialize().getBytes()); + messageDigest.update(avatarObject.getBytes()); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof ProfileContactPhoto)) return false; + + ProfileContactPhoto that = (ProfileContactPhoto)other; + + return this.address.equals(that.address) && this.avatarObject.equals(that.avatarObject); + } + + @Override + public int hashCode() { + return address.hashCode() ^ avatarObject.hashCode(); + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ResourceContactPhoto.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ResourceContactPhoto.java new file mode 100644 index 0000000000..dabc30b605 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/ResourceContactPhoto.java @@ -0,0 +1,77 @@ +package org.session.libsession.messaging.contacts.avatars; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.widget.ImageView; + +import androidx.annotation.DrawableRes; +import androidx.appcompat.content.res.AppCompatResources; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.makeramen.roundedimageview.RoundedDrawable; + + +import org.session.libsession.R; +import org.session.libsession.utilities.ThemeUtil; + +public class ResourceContactPhoto implements FallbackContactPhoto { + + private final int resourceId; + private final int callCardResourceId; + + public ResourceContactPhoto(@DrawableRes int resourceId) { + this(resourceId, resourceId); + } + + public ResourceContactPhoto(@DrawableRes int resourceId, @DrawableRes int callCardResourceId) { + this.resourceId = resourceId; + this.callCardResourceId = callCardResourceId; + } + + @Override + public Drawable asDrawable(Context context, int color) { + return asDrawable(context, color, false); + } + + @Override + public Drawable asDrawable(Context context, int color, boolean inverted) { + Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color); + RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(context.getResources().getDrawable(resourceId)); + + foreground.setScaleType(ImageView.ScaleType.CENTER); + + if (inverted) { + foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); + } + + Drawable gradient = context.getResources().getDrawable(ThemeUtil.isDarkTheme(context) ? R.drawable.avatar_gradient_dark + : R.drawable.avatar_gradient_light); + + return new ExpandingLayerDrawable(new Drawable[] {background, foreground, gradient}); + } + + @Override + public Drawable asCallCard(Context context) { + return AppCompatResources.getDrawable(context, callCardResourceId); + } + + private static class ExpandingLayerDrawable extends LayerDrawable { + public ExpandingLayerDrawable(Drawable[] layers) { + super(layers); + } + + @Override + public int getIntrinsicWidth() { + return -1; + } + + @Override + public int getIntrinsicHeight() { + return -1; + } + } + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/SystemContactPhoto.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/SystemContactPhoto.java new file mode 100644 index 0000000000..53b798f7ce --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/SystemContactPhoto.java @@ -0,0 +1,67 @@ +package org.session.libsession.messaging.contacts.avatars; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + + +import org.session.libsession.messaging.threads.Address; +import org.session.libsession.utilities.Conversions; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.security.MessageDigest; + +public class SystemContactPhoto implements ContactPhoto { + + private final @NonNull + Address address; + private final @NonNull Uri contactPhotoUri; + private final long lastModifiedTime; + + public SystemContactPhoto(@NonNull Address address, @NonNull Uri contactPhotoUri, long lastModifiedTime) { + this.address = address; + this.contactPhotoUri = contactPhotoUri; + this.lastModifiedTime = lastModifiedTime; + } + + @Override + public InputStream openInputStream(Context context) throws FileNotFoundException { + return context.getContentResolver().openInputStream(contactPhotoUri); + } + + @Override + public @Nullable Uri getUri(@NonNull Context context) { + return contactPhotoUri; + } + + @Override + public boolean isProfilePhoto() { + return false; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(address.serialize().getBytes()); + messageDigest.update(contactPhotoUri.toString().getBytes()); + messageDigest.update(Conversions.longToByteArray(lastModifiedTime)); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof SystemContactPhoto)) return false; + + SystemContactPhoto that = (SystemContactPhoto)other; + + return this.address.equals(that.address) && this.contactPhotoUri.equals(that.contactPhotoUri) && this.lastModifiedTime == that.lastModifiedTime; + } + + @Override + public int hashCode() { + return address.hashCode() ^ contactPhotoUri.hashCode() ^ (int)lastModifiedTime; + } + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/TransparentContactPhoto.java b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/TransparentContactPhoto.java new file mode 100644 index 0000000000..c6a685a4bd --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/avatars/TransparentContactPhoto.java @@ -0,0 +1,26 @@ +package org.session.libsession.messaging.contacts.avatars; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +import androidx.core.content.ContextCompat; + +import com.makeramen.roundedimageview.RoundedDrawable; + +import org.session.libsession.R; + +public class TransparentContactPhoto implements FallbackContactPhoto { + + public TransparentContactPhoto() {} + + @Override + public Drawable asDrawable(Context context, int color) { + return asDrawable(context, color, false); + } + + @Override + public Drawable asDrawable(Context context, int color, boolean inverted) { + return RoundedDrawable.fromDrawable(context.getResources().getDrawable(android.R.color.transparent)); + } + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/Recipient.java b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/Recipient.java new file mode 100644 index 0000000000..df5a5fdca5 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/Recipient.java @@ -0,0 +1,919 @@ +/* + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 - 2017 Open Whisper Systems + * + * 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 org.session.libsession.messaging.threads.recipients; + +import android.content.Context; +import android.net.Uri; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.function.Consumer; + +import org.greenrobot.eventbus.EventBus; +import org.session.libsession.messaging.MessagingConfiguration; +import org.session.libsession.messaging.threads.Address; +import org.session.libsession.messaging.threads.GroupRecord; +import org.session.libsession.messaging.threads.recipients.RecipientProvider.RecipientDetails; +import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.color.MaterialColor; +import org.session.libsignal.libsignal.logging.Log; +import org.session.libsignal.libsignal.util.guava.Optional; +import org.session.libsignal.service.loki.protocol.shelved.multidevice.MultiDeviceProtocol; +import org.session.libsession.messaging.contacts.avatars.ContactColors; +import org.session.libsession.messaging.contacts.avatars.ContactPhoto; +import org.session.libsession.messaging.contacts.avatars.GroupRecordContactPhoto; +import org.session.libsession.messaging.contacts.avatars.ProfileContactPhoto; +import org.session.libsession.messaging.contacts.avatars.SystemContactPhoto; +import org.session.libsession.utilities.ProfilePictureModifiedEvent; +import org.session.libsession.utilities.FutureTaskListener; +import org.session.libsession.utilities.ListenableFutureTask; + +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ExecutionException; + +import org.session.libsession.R; + +public class Recipient implements RecipientModifiedListener { + + private static final String TAG = Recipient.class.getSimpleName(); + private static final RecipientProvider provider = new RecipientProvider(); + + private final Set listeners = Collections.newSetFromMap(new WeakHashMap()); + + private final @NonNull Address address; + private final @NonNull List participants = new LinkedList<>(); + + private Context context; + private @Nullable String name; + private @Nullable String customLabel; + private boolean resolving; + private boolean isLocalNumber; + + private @Nullable Uri systemContactPhoto; + private @Nullable Long groupAvatarId; + private Uri contactUri; + private @Nullable Uri messageRingtone = null; + private @Nullable Uri callRingtone = null; + public long mutedUntil = 0; + private boolean blocked = false; + private VibrateState messageVibrate = VibrateState.DEFAULT; + private VibrateState callVibrate = VibrateState.DEFAULT; + private int expireMessages = 0; + private Optional defaultSubscriptionId = Optional.absent(); + private @NonNull RegisteredState registered = RegisteredState.UNKNOWN; + + private @Nullable MaterialColor color; + private @Nullable byte[] profileKey; + private @Nullable String profileName; + private @Nullable String profileAvatar; + private boolean profileSharing; + private String notificationChannel; + private boolean forceSmsSelection; + + private @NonNull UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.ENABLED; + + @SuppressWarnings("ConstantConditions") + public static @NonNull Recipient from(@NonNull Context context, @NonNull Address address, boolean asynchronous) { + if (address == null) throw new AssertionError(address); + return provider.getRecipient(context, address, Optional.absent(), Optional.absent(), asynchronous); + } + + @SuppressWarnings("ConstantConditions") + public static @NonNull Recipient from(@NonNull Context context, @NonNull Address address, @NonNull Optional settings, @NonNull Optional groupRecord, boolean asynchronous) { + if (address == null) throw new AssertionError(address); + return provider.getRecipient(context, address, settings, groupRecord, asynchronous); + } + + public static void applyCached(@NonNull Address address, Consumer consumer) { + Optional recipient = provider.getCached(address); + if (recipient.isPresent()) consumer.accept(recipient.get()); + } + + public static boolean removeCached(@NonNull Address address) { + return provider.removeCached(address); + } + + Recipient(@NonNull Context context, + @NonNull Address address, + @Nullable Recipient stale, + @NonNull Optional details, + @NonNull ListenableFutureTask future) + { + this.context = context; + this.address = address; + this.color = null; + this.resolving = true; + + if (stale != null) { + this.name = stale.name; + this.contactUri = stale.contactUri; + this.systemContactPhoto = stale.systemContactPhoto; + this.groupAvatarId = stale.groupAvatarId; + this.isLocalNumber = stale.isLocalNumber; + this.color = stale.color; + this.customLabel = stale.customLabel; + this.messageRingtone = stale.messageRingtone; + this.callRingtone = stale.callRingtone; + this.mutedUntil = stale.mutedUntil; + this.blocked = stale.blocked; + this.messageVibrate = stale.messageVibrate; + this.callVibrate = stale.callVibrate; + this.expireMessages = stale.expireMessages; + this.defaultSubscriptionId = stale.defaultSubscriptionId; + this.registered = stale.registered; + this.notificationChannel = stale.notificationChannel; + this.profileKey = stale.profileKey; + this.profileName = stale.profileName; + this.profileAvatar = stale.profileAvatar; + this.profileSharing = stale.profileSharing; + this.unidentifiedAccessMode = stale.unidentifiedAccessMode; + this.forceSmsSelection = stale.forceSmsSelection; + + this.participants.clear(); + this.participants.addAll(stale.participants); + } + + if (details.isPresent()) { + this.name = details.get().name; + this.systemContactPhoto = details.get().systemContactPhoto; + this.groupAvatarId = details.get().groupAvatarId; + this.isLocalNumber = details.get().isLocalNumber; + this.color = details.get().color; + this.messageRingtone = details.get().messageRingtone; + this.callRingtone = details.get().callRingtone; + this.mutedUntil = details.get().mutedUntil; + this.blocked = details.get().blocked; + this.messageVibrate = details.get().messageVibrateState; + this.callVibrate = details.get().callVibrateState; + this.expireMessages = details.get().expireMessages; + this.defaultSubscriptionId = details.get().defaultSubscriptionId; + this.registered = details.get().registered; + this.notificationChannel = details.get().notificationChannel; + this.profileKey = details.get().profileKey; + this.profileName = details.get().profileName; + this.profileAvatar = details.get().profileAvatar; + this.profileSharing = details.get().profileSharing; + this.unidentifiedAccessMode = details.get().unidentifiedAccessMode; + this.forceSmsSelection = details.get().forceSmsSelection; + + this.participants.clear(); + this.participants.addAll(details.get().participants); + } + + future.addListener(new FutureTaskListener() { + @Override + public void onSuccess(RecipientDetails result) { + if (result != null) { + synchronized (Recipient.this) { + Recipient.this.name = result.name; + Recipient.this.contactUri = result.contactUri; + Recipient.this.systemContactPhoto = result.systemContactPhoto; + Recipient.this.groupAvatarId = result.groupAvatarId; + Recipient.this.isLocalNumber = result.isLocalNumber; + Recipient.this.color = result.color; + Recipient.this.customLabel = result.customLabel; + Recipient.this.messageRingtone = result.messageRingtone; + Recipient.this.callRingtone = result.callRingtone; + Recipient.this.mutedUntil = result.mutedUntil; + Recipient.this.blocked = result.blocked; + Recipient.this.messageVibrate = result.messageVibrateState; + Recipient.this.callVibrate = result.callVibrateState; + Recipient.this.expireMessages = result.expireMessages; + Recipient.this.defaultSubscriptionId = result.defaultSubscriptionId; + Recipient.this.registered = result.registered; + Recipient.this.notificationChannel = result.notificationChannel; + Recipient.this.profileKey = result.profileKey; + Recipient.this.profileName = result.profileName; + Recipient.this.profileAvatar = result.profileAvatar; + Recipient.this.profileSharing = result.profileSharing; + Recipient.this.unidentifiedAccessMode = result.unidentifiedAccessMode; + Recipient.this.forceSmsSelection = result.forceSmsSelection; + + Recipient.this.participants.clear(); + Recipient.this.participants.addAll(result.participants); + Recipient.this.resolving = false; + + if (!listeners.isEmpty()) { + for (Recipient recipient : participants) recipient.addListener(Recipient.this); + } + + Recipient.this.notifyAll(); + } + + notifyListeners(); + } + } + + @Override + public void onFailure(ExecutionException error) { + Log.w(TAG, error); + } + }); + } + + Recipient(@NonNull Context context, @NonNull Address address, @NonNull RecipientDetails details) { + this.context = context; + this.address = address; + this.contactUri = details.contactUri; + this.name = details.name; + this.systemContactPhoto = details.systemContactPhoto; + this.groupAvatarId = details.groupAvatarId; + this.isLocalNumber = details.isLocalNumber; + this.color = details.color; + this.customLabel = details.customLabel; + this.messageRingtone = details.messageRingtone; + this.callRingtone = details.callRingtone; + this.mutedUntil = details.mutedUntil; + this.blocked = details.blocked; + this.messageVibrate = details.messageVibrateState; + this.callVibrate = details.callVibrateState; + this.expireMessages = details.expireMessages; + this.defaultSubscriptionId = details.defaultSubscriptionId; + this.registered = details.registered; + this.notificationChannel = details.notificationChannel; + this.profileKey = details.profileKey; + this.profileName = details.profileName; + this.profileAvatar = details.profileAvatar; + this.profileSharing = details.profileSharing; + this.unidentifiedAccessMode = details.unidentifiedAccessMode; + this.forceSmsSelection = details.forceSmsSelection; + + this.participants.addAll(details.participants); + this.resolving = false; + } + + public boolean isLocalNumber() { + return isLocalNumber; + } + + public boolean isUserMasterDevice() { + String userMasterDevice = TextSecurePreferences.getMasterHexEncodedPublicKey(context); + return userMasterDevice != null && userMasterDevice.equals(getAddress().serialize()); + } + + public synchronized @Nullable Uri getContactUri() { + return this.contactUri; + } + + public void setContactUri(@Nullable Uri contactUri) { + boolean notify = false; + + synchronized (this) { + if (!Util.equals(contactUri, this.contactUri)) { + this.contactUri = contactUri; + notify = true; + } + } + + if (notify) notifyListeners(); + } + + public synchronized @Nullable String getName() { + String displayName = MessagingConfiguration.shared.getStorage().getDisplayName(this.address.toString()); + if (displayName != null) { return displayName; } + + if (this.name == null && isMmsGroupRecipient()) { + List names = new LinkedList<>(); + + for (Recipient recipient : participants) { + names.add(recipient.toShortString()); + } + + return Util.join(names, ", "); + } + + return this.name; + } + + public void setName(@Nullable String name) { + boolean notify = false; + + synchronized (this) { + if (!Util.equals(this.name, name)) { + this.name = name; + notify = true; + } + } + + if (notify) notifyListeners(); + } + + public synchronized @NonNull MaterialColor getColor() { + if (isGroupRecipient()) return MaterialColor.GROUP; + else if (color != null) return color; + else if (name != null) return ContactColors.generateFor(name); + else return ContactColors.UNKNOWN_COLOR; + } + + public void setColor(@NonNull MaterialColor color) { + synchronized (this) { + this.color = color; + } + + notifyListeners(); + } + + public @NonNull Address getAddress() { + return address; + } + + public synchronized @Nullable String getCustomLabel() { + return customLabel; + } + + public void setCustomLabel(@Nullable String customLabel) { + boolean notify = false; + + synchronized (this) { + if (!Util.equals(customLabel, this.customLabel)) { + this.customLabel = customLabel; + notify = true; + } + } + + if (notify) notifyListeners(); + } + + public synchronized Optional getDefaultSubscriptionId() { + return defaultSubscriptionId; + } + + public void setDefaultSubscriptionId(Optional defaultSubscriptionId) { + synchronized (this) { + this.defaultSubscriptionId = defaultSubscriptionId; + } + + notifyListeners(); + } + + public synchronized @Nullable String getProfileName() { + return profileName; + } + + public void setProfileName(@Nullable String profileName) { + synchronized (this) { + this.profileName = profileName; + } + + notifyListeners(); + } + + public synchronized @Nullable String getProfileAvatar() { + return profileAvatar; + } + + public void setProfileAvatar(@Nullable String profileAvatar) { + synchronized (this) { + this.profileAvatar = profileAvatar; + } + + notifyListeners(); + EventBus.getDefault().post(new ProfilePictureModifiedEvent(this)); + } + + public synchronized boolean isProfileSharing() { + return profileSharing; + } + + public void setProfileSharing(boolean value) { + synchronized (this) { + this.profileSharing = value; + } + + notifyListeners(); + } + + public boolean isGroupRecipient() { + return address.isGroup(); + } + + public boolean isOpenGroupRecipient() { + return address.isOpenGroup(); + } + + public boolean isMmsGroupRecipient() { + return address.isMmsGroup(); + } + + public boolean isPushGroupRecipient() { + return address.isGroup() && !address.isMmsGroup(); + } + + public @NonNull synchronized List getParticipants() { + return new LinkedList<>(participants); + } + + public void setParticipants(@NonNull List participants) { + synchronized (this) { + this.participants.clear(); + this.participants.addAll(participants); + } + + notifyListeners(); + } + + public synchronized void addListener(RecipientModifiedListener listener) { + if (listeners.isEmpty()) { + for (Recipient recipient : participants) recipient.addListener(this); + } + listeners.add(listener); + } + + public synchronized void removeListener(RecipientModifiedListener listener) { + listeners.remove(listener); + + if (listeners.isEmpty()) { + for (Recipient recipient : participants) recipient.removeListener(this); + } + } + + public synchronized String toShortString() { + String name = getName(); + return (name != null ? name : address.serialize()); + } + + public synchronized @Nullable ContactPhoto getContactPhoto() { + if (isLocalNumber) return new ProfileContactPhoto(address, String.valueOf(TextSecurePreferences.getProfileAvatarId(context))); + else if (isGroupRecipient() && groupAvatarId != null) return new GroupRecordContactPhoto(address, groupAvatarId); + else if (systemContactPhoto != null) return new SystemContactPhoto(address, systemContactPhoto, 0); + else if (profileAvatar != null) return new ProfileContactPhoto(address, profileAvatar); + else return null; + } + + public void setSystemContactPhoto(@Nullable Uri systemContactPhoto) { + boolean notify = false; + + synchronized (this) { + if (!Util.equals(systemContactPhoto, this.systemContactPhoto)) { + this.systemContactPhoto = systemContactPhoto; + notify = true; + } + } + + if (notify) notifyListeners(); + } + + public void setGroupAvatarId(@Nullable Long groupAvatarId) { + boolean notify = false; + + synchronized (this) { + if (!Util.equals(this.groupAvatarId, groupAvatarId)) { + this.groupAvatarId = groupAvatarId; + notify = true; + } + } + + if (notify) notifyListeners(); + } + + @Nullable + public synchronized Long getGroupAvatarId() { + return groupAvatarId; + } + + public synchronized @Nullable Uri getMessageRingtone() { + if (messageRingtone != null && messageRingtone.getScheme() != null && messageRingtone.getScheme().startsWith("file")) { + return null; + } + + return messageRingtone; + } + + public void setMessageRingtone(@Nullable Uri ringtone) { + synchronized (this) { + this.messageRingtone = ringtone; + } + + notifyListeners(); + } + + public synchronized @Nullable Uri getCallRingtone() { + if (callRingtone != null && callRingtone.getScheme() != null && callRingtone.getScheme().startsWith("file")) { + return null; + } + + return callRingtone; + } + + public void setCallRingtone(@Nullable Uri ringtone) { + synchronized (this) { + this.callRingtone = ringtone; + } + + notifyListeners(); + } + + public synchronized boolean isMuted() { + return System.currentTimeMillis() <= mutedUntil; + } + + public void setMuted(long mutedUntil) { + synchronized (this) { + this.mutedUntil = mutedUntil; + } + + notifyListeners(); + } + + public synchronized boolean isBlocked() { + String masterPublicKey = MultiDeviceProtocol.shared.getMasterDevice(this.address.serialize()); + if (masterPublicKey != null) { + return Recipient.from(context, Address.Companion.fromSerialized(masterPublicKey), false).blocked; + } else { + return blocked; + } + } + + public void setBlocked(boolean blocked) { + synchronized (this) { + this.blocked = blocked; + } + + notifyListeners(); + } + + public synchronized VibrateState getMessageVibrate() { + return messageVibrate; + } + + public void setMessageVibrate(VibrateState vibrate) { + synchronized (this) { + this.messageVibrate = vibrate; + } + + notifyListeners(); + } + + public synchronized VibrateState getCallVibrate() { + return callVibrate; + } + + public void setCallVibrate(VibrateState vibrate) { + synchronized (this) { + this.callVibrate = vibrate; + } + + notifyListeners(); + } + + public synchronized int getExpireMessages() { + return expireMessages; + } + + public void setExpireMessages(int expireMessages) { + synchronized (this) { + this.expireMessages = expireMessages; + } + + notifyListeners(); + } + + public synchronized RegisteredState getRegistered() { + if (isPushGroupRecipient()) return RegisteredState.REGISTERED; + else if (isMmsGroupRecipient()) return RegisteredState.NOT_REGISTERED; + + return registered; + } + + public void setRegistered(@NonNull RegisteredState value) { + boolean notify = false; + + synchronized (this) { + if (this.registered != value) { + this.registered = value; + notify = true; + } + } + + if (notify) notifyListeners(); + } + + public synchronized @Nullable String getNotificationChannel() { + return !(Build.VERSION.SDK_INT >= 26) ? null : notificationChannel; + } + + public void setNotificationChannel(@Nullable String value) { + boolean notify = false; + + synchronized (this) { + if (!Util.equals(this.notificationChannel, value)) { + this.notificationChannel = value; + notify = true; + } + } + + if (notify) notifyListeners(); + } + + public boolean isForceSmsSelection() { + return forceSmsSelection; + } + + public void setForceSmsSelection(boolean value) { + synchronized (this) { + this.forceSmsSelection = value; + } + + notifyListeners(); + } + + public synchronized @Nullable byte[] getProfileKey() { + return profileKey; + } + + public void setProfileKey(@Nullable byte[] profileKey) { + synchronized (this) { + this.profileKey = profileKey; + } + + notifyListeners(); + } + + public @NonNull synchronized UnidentifiedAccessMode getUnidentifiedAccessMode() { + return unidentifiedAccessMode; + } + + public void setUnidentifiedAccessMode(@NonNull UnidentifiedAccessMode unidentifiedAccessMode) { + synchronized (this) { + this.unidentifiedAccessMode = unidentifiedAccessMode; + } + + notifyListeners(); + } + + public synchronized boolean isSystemContact() { + return contactUri != null; + } + + public synchronized Recipient resolve() { + while (resolving) Util.wait(this, 0); + return this; + } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || !(o instanceof Recipient)) return false; + + Recipient that = (Recipient) o; + + return this.address.equals(that.address); + } + + @Override + public int hashCode() { + return this.address.hashCode(); + } + + public void notifyListeners() { + Set localListeners; + + synchronized (this) { + localListeners = new HashSet<>(listeners); + } + + for (RecipientModifiedListener listener : localListeners) + listener.onModified(this); + } + + @Override + public void onModified(Recipient recipient) { + notifyListeners(); + } + + public synchronized boolean isResolving() { + return resolving; + } + + public enum VibrateState { + DEFAULT(0), ENABLED(1), DISABLED(2); + + private final int id; + + VibrateState(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static VibrateState fromId(int id) { + return values()[id]; + } + } + + public enum RegisteredState { + UNKNOWN(0), REGISTERED(1), NOT_REGISTERED(2); + + private final int id; + + RegisteredState(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static RegisteredState fromId(int id) { + return values()[id]; + } + } + + public enum UnidentifiedAccessMode { + UNKNOWN(0), DISABLED(1), ENABLED(2), UNRESTRICTED(3); + + private final int mode; + + UnidentifiedAccessMode(int mode) { + this.mode = mode; + } + + public int getMode() { + return mode; + } + + public static UnidentifiedAccessMode fromMode(int mode) { + return values()[mode]; + } + } + + public static class RecipientSettings { + private final boolean blocked; + private final long muteUntil; + private final VibrateState messageVibrateState; + private final VibrateState callVibrateState; + private final Uri messageRingtone; + private final Uri callRingtone; + private final MaterialColor color; + private final int defaultSubscriptionId; + private final int expireMessages; + private final RegisteredState registered; + private final byte[] profileKey; + private final String systemDisplayName; + private final String systemContactPhoto; + private final String systemPhoneLabel; + private final String systemContactUri; + private final String signalProfileName; + private final String signalProfileAvatar; + private final boolean profileSharing; + private final String notificationChannel; + private final UnidentifiedAccessMode unidentifiedAccessMode; + private final boolean forceSmsSelection; + + RecipientSettings(boolean blocked, long muteUntil, + @NonNull VibrateState messageVibrateState, + @NonNull VibrateState callVibrateState, + @Nullable Uri messageRingtone, + @Nullable Uri callRingtone, + @Nullable MaterialColor color, + int defaultSubscriptionId, + int expireMessages, + @NonNull RegisteredState registered, + @Nullable byte[] profileKey, + @Nullable String systemDisplayName, + @Nullable String systemContactPhoto, + @Nullable String systemPhoneLabel, + @Nullable String systemContactUri, + @Nullable String signalProfileName, + @Nullable String signalProfileAvatar, + boolean profileSharing, + @Nullable String notificationChannel, + @NonNull UnidentifiedAccessMode unidentifiedAccessMode, + boolean forceSmsSelection) + { + this.blocked = blocked; + this.muteUntil = muteUntil; + this.messageVibrateState = messageVibrateState; + this.callVibrateState = callVibrateState; + this.messageRingtone = messageRingtone; + this.callRingtone = callRingtone; + this.color = color; + this.defaultSubscriptionId = defaultSubscriptionId; + this.expireMessages = expireMessages; + this.registered = registered; + this.profileKey = profileKey; + this.systemDisplayName = systemDisplayName; + this.systemContactPhoto = systemContactPhoto; + this.systemPhoneLabel = systemPhoneLabel; + this.systemContactUri = systemContactUri; + this.signalProfileName = signalProfileName; + this.signalProfileAvatar = signalProfileAvatar; + this.profileSharing = profileSharing; + this.notificationChannel = notificationChannel; + this.unidentifiedAccessMode = unidentifiedAccessMode; + this.forceSmsSelection = forceSmsSelection; + } + + public @Nullable MaterialColor getColor() { + return color; + } + + public boolean isBlocked() { + return blocked; + } + + public long getMuteUntil() { + return muteUntil; + } + + public @NonNull VibrateState getMessageVibrateState() { + return messageVibrateState; + } + + public @NonNull VibrateState getCallVibrateState() { + return callVibrateState; + } + + public @Nullable Uri getMessageRingtone() { + return messageRingtone; + } + + public @Nullable Uri getCallRingtone() { + return callRingtone; + } + + public Optional getDefaultSubscriptionId() { + return defaultSubscriptionId != -1 ? Optional.of(defaultSubscriptionId) : Optional.absent(); + } + + public int getExpireMessages() { + return expireMessages; + } + + public RegisteredState getRegistered() { + return registered; + } + + public @Nullable byte[] getProfileKey() { + return profileKey; + } + + public @Nullable String getSystemDisplayName() { + return systemDisplayName; + } + + public @Nullable String getSystemContactPhotoUri() { + return systemContactPhoto; + } + + public @Nullable String getSystemPhoneLabel() { + return systemPhoneLabel; + } + + public @Nullable String getSystemContactUri() { + return systemContactUri; + } + + public @Nullable String getProfileName() { + return signalProfileName; + } + + public @Nullable String getProfileAvatar() { + return signalProfileAvatar; + } + + public boolean isProfileSharing() { + return profileSharing; + } + + public @Nullable String getNotificationChannel() { + return notificationChannel; + } + + public @NonNull UnidentifiedAccessMode getUnidentifiedAccessMode() { + return unidentifiedAccessMode; + } + + public boolean isForceSmsSelection() { + return forceSmsSelection; + } + } + + +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientExporter.java b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientExporter.java new file mode 100644 index 0000000000..855ef016de --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientExporter.java @@ -0,0 +1,46 @@ +package org.session.libsession.messaging.threads.recipients; + +import android.content.Intent; +import android.provider.ContactsContract; +import android.text.TextUtils; + +import org.thoughtcrime.securesms.database.Address; + +import static android.content.Intent.ACTION_INSERT_OR_EDIT; + +public final class RecipientExporter { + + public static RecipientExporter export(Recipient recipient) { + return new RecipientExporter(recipient); + } + + private final Recipient recipient; + + private RecipientExporter(Recipient recipient) { + this.recipient = recipient; + } + + public Intent asAddContactIntent() { + Intent intent = new Intent(ACTION_INSERT_OR_EDIT); + intent.setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE); + addNameToIntent(intent, recipient.getProfileName()); + addAddressToIntent(intent, recipient.getAddress()); + return intent; + } + + private static void addNameToIntent(Intent intent, String profileName) { + if (!TextUtils.isEmpty(profileName)) { + intent.putExtra(ContactsContract.Intents.Insert.NAME, profileName); + } + } + + private static void addAddressToIntent(Intent intent, Address address) { + if (address.isPhone()) { + intent.putExtra(ContactsContract.Intents.Insert.PHONE, address.toPhoneString()); + } else if (address.isEmail()) { + intent.putExtra(ContactsContract.Intents.Insert.EMAIL, address.toEmailString()); + } else { + throw new RuntimeException("Cannot export Recipient with neither phone nor email"); + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientFormattingException.java b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientFormattingException.java new file mode 100644 index 0000000000..7a97811d9d --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientFormattingException.java @@ -0,0 +1,35 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * 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 org.session.libsession.messaging.threads.recipients; + +public class RecipientFormattingException extends Exception { + public RecipientFormattingException() { + super(); + } + + public RecipientFormattingException(String message) { + super(message); + } + + public RecipientFormattingException(String message, Throwable nested) { + super(message, nested); + } + + public RecipientFormattingException(Throwable nested) { + super(nested); + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientModifiedListener.java b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientModifiedListener.java new file mode 100644 index 0000000000..e3f1b3355f --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientModifiedListener.java @@ -0,0 +1,6 @@ +package org.session.libsession.messaging.threads.recipients; + + +public interface RecipientModifiedListener { + public void onModified(Recipient recipient); +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientProvider.java b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientProvider.java new file mode 100644 index 0000000000..778003fa8f --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientProvider.java @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * 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 org.session.libsession.messaging.threads.recipients; + +import android.content.Context; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.session.libsession.messaging.MessagingConfiguration; +import org.session.libsignal.libsignal.util.guava.Optional; +import org.session.libsession.utilities.color.MaterialColor; +import org.session.libsession.messaging.threads.Address; +import org.session.libsession.messaging.threads.GroupRecord; +import org.session.libsession.messaging.threads.recipients.Recipient.RecipientSettings; +import org.session.libsession.messaging.threads.recipients.Recipient.RegisteredState; +import org.session.libsession.messaging.threads.recipients.Recipient.UnidentifiedAccessMode; +import org.session.libsession.messaging.threads.recipients.Recipient.VibrateState; +import org.session.libsession.utilities.ListenableFutureTask; +import org.session.libsession.utilities.SoftHashMap; +import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.Util; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; + +import org.session.libsession.R; + +class RecipientProvider { + + @SuppressWarnings("unused") + private static final String TAG = RecipientProvider.class.getSimpleName(); + + private static final RecipientCache recipientCache = new RecipientCache(); + private static final ExecutorService asyncRecipientResolver = Util.newSingleThreadedLifoExecutor(); + + private static final Map STATIC_DETAILS = new HashMap() {{ + put("262966", new RecipientDetails("Amazon", null, false, false, null, null)); + }}; + + @NonNull Recipient getRecipient(@NonNull Context context, @NonNull Address address, @NonNull Optional settings, @NonNull Optional groupRecord, boolean asynchronous) { + Recipient cachedRecipient = recipientCache.get(address); + + if (cachedRecipient != null && (asynchronous || !cachedRecipient.isResolving()) && ((!groupRecord.isPresent() && !settings.isPresent()) || !cachedRecipient.isResolving() || cachedRecipient.getName() != null)) { + return cachedRecipient; + } + + Optional prefetchedRecipientDetails = createPrefetchedRecipientDetails(context, address, settings, groupRecord); + + if (asynchronous) { + cachedRecipient = new Recipient(context, address, cachedRecipient, prefetchedRecipientDetails, getRecipientDetailsAsync(context, address, settings, groupRecord)); + } else { + cachedRecipient = new Recipient(context, address, getRecipientDetailsSync(context, address, settings, groupRecord, false)); + } + + recipientCache.set(address, cachedRecipient); + return cachedRecipient; + } + + @NonNull Optional getCached(@NonNull Address address) { + return Optional.fromNullable(recipientCache.get(address)); + } + + boolean removeCached(@NonNull Address address) { + return recipientCache.remove(address); + } + + private @NonNull Optional createPrefetchedRecipientDetails(@NonNull Context context, @NonNull Address address, + @NonNull Optional settings, + @NonNull Optional groupRecord) + { + if (address.isGroup() && settings.isPresent() && groupRecord.isPresent()) { + return Optional.of(getGroupRecipientDetails(context, address, groupRecord, settings, true)); + } else if (!address.isGroup() && settings.isPresent()) { + boolean isLocalNumber = address.serialize().equals(TextSecurePreferences.getLocalNumber(context)); + return Optional.of(new RecipientDetails(null, null, !TextUtils.isEmpty(settings.get().getSystemDisplayName()), isLocalNumber, settings.get(), null)); + } + + return Optional.absent(); + } + + private @NonNull ListenableFutureTask getRecipientDetailsAsync(final Context context, final @NonNull Address address, final @NonNull Optional settings, final @NonNull Optional groupRecord) + { + Callable task = () -> getRecipientDetailsSync(context, address, settings, groupRecord, true); + + ListenableFutureTask future = new ListenableFutureTask<>(task); + asyncRecipientResolver.submit(future); + return future; + } + + private @NonNull RecipientDetails getRecipientDetailsSync(Context context, @NonNull Address address, Optional settings, Optional groupRecord, boolean nestedAsynchronous) { + if (address.isGroup()) return getGroupRecipientDetails(context, address, groupRecord, settings, nestedAsynchronous); + else return getIndividualRecipientDetails(context, address, settings); + } + + private @NonNull RecipientDetails getIndividualRecipientDetails(Context context, @NonNull Address address, Optional settings) { + if (!settings.isPresent()) { + settings = Optional.of(MessagingConfiguration.shared.getStorage().getRecipientSettings(address)); + } + + if (!settings.isPresent() && STATIC_DETAILS.containsKey(address.serialize())) { + return STATIC_DETAILS.get(address.serialize()); + } else { + boolean systemContact = settings.isPresent() && !TextUtils.isEmpty(settings.get().getSystemDisplayName()); + boolean isLocalNumber = address.serialize().equals(TextSecurePreferences.getLocalNumber(context)); + return new RecipientDetails(null, null, systemContact, isLocalNumber, settings.orNull(), null); + } + } + + private @NonNull RecipientDetails getGroupRecipientDetails(Context context, Address groupId, Optional groupRecord, Optional settings, boolean asynchronous) { + + if (!groupRecord.isPresent()) { + groupRecord = Optional.of(MessagingConfiguration.shared.getStorage().getGroup(groupId.toGroupString())); + } + + if (!settings.isPresent()) { + settings = Optional.of(MessagingConfiguration.shared.getStorage().getRecipientSettings(groupId)); + } + + if (groupRecord.isPresent()) { + String title = groupRecord.get().getTitle(); + List
memberAddresses = groupRecord.get().getMembers(); + List members = new LinkedList<>(); + Long avatarId = null; + + for (Address memberAddress : memberAddresses) { + members.add(getRecipient(context, memberAddress, Optional.absent(), Optional.absent(), asynchronous)); + } + + if (!groupId.isMmsGroup() && title == null) { + title = context.getString(R.string.RecipientProvider_unnamed_group); + } + + if (groupRecord.get().getAvatar() != null && groupRecord.get().getAvatar().length > 0) { + avatarId = groupRecord.get().getAvatarId(); + } + + return new RecipientDetails(title, avatarId, false, false, settings.orNull(), members); + } + + return new RecipientDetails(context.getString(R.string.RecipientProvider_unnamed_group), null, false, false, settings.orNull(), null); + } + + static class RecipientDetails { + @Nullable final String name; + @Nullable final String customLabel; + @Nullable final Uri systemContactPhoto; + @Nullable final Uri contactUri; + @Nullable final Long groupAvatarId; + @Nullable final MaterialColor color; + @Nullable final Uri messageRingtone; + @Nullable final Uri callRingtone; + final long mutedUntil; + @Nullable final VibrateState messageVibrateState; + @Nullable final VibrateState callVibrateState; + final boolean blocked; + final int expireMessages; + @NonNull final List participants; + @Nullable final String profileName; + final Optional defaultSubscriptionId; + @NonNull final RegisteredState registered; + @Nullable final byte[] profileKey; + @Nullable final String profileAvatar; + final boolean profileSharing; + final boolean systemContact; + final boolean isLocalNumber; + @Nullable final String notificationChannel; + @NonNull final UnidentifiedAccessMode unidentifiedAccessMode; + final boolean forceSmsSelection; + + RecipientDetails(@Nullable String name, @Nullable Long groupAvatarId, + boolean systemContact, boolean isLocalNumber, @Nullable RecipientSettings settings, + @Nullable List participants) + { + this.groupAvatarId = groupAvatarId; + this.systemContactPhoto = settings != null ? Util.uri(settings.getSystemContactPhotoUri()) : null; + this.customLabel = settings != null ? settings.getSystemPhoneLabel() : null; + this.contactUri = settings != null ? Util.uri(settings.getSystemContactUri()) : null; + this.color = settings != null ? settings.getColor() : null; + this.messageRingtone = settings != null ? settings.getMessageRingtone() : null; + this.callRingtone = settings != null ? settings.getCallRingtone() : null; + this.mutedUntil = settings != null ? settings.getMuteUntil() : 0; + this.messageVibrateState = settings != null ? settings.getMessageVibrateState() : null; + this.callVibrateState = settings != null ? settings.getCallVibrateState() : null; + this.blocked = settings != null && settings.isBlocked(); + this.expireMessages = settings != null ? settings.getExpireMessages() : 0; + this.participants = participants == null ? new LinkedList<>() : participants; + this.profileName = settings != null ? settings.getProfileName() : null; + this.defaultSubscriptionId = settings != null ? settings.getDefaultSubscriptionId() : Optional.absent(); + this.registered = settings != null ? settings.getRegistered() : RegisteredState.UNKNOWN; + this.profileKey = settings != null ? settings.getProfileKey() : null; + this.profileAvatar = settings != null ? settings.getProfileAvatar() : null; + this.profileSharing = settings != null && settings.isProfileSharing(); + this.systemContact = systemContact; + this.isLocalNumber = isLocalNumber; + this.notificationChannel = settings != null ? settings.getNotificationChannel() : null; + this.unidentifiedAccessMode = settings != null ? settings.getUnidentifiedAccessMode() : UnidentifiedAccessMode.DISABLED; + this.forceSmsSelection = settings != null && settings.isForceSmsSelection(); + + if (name == null && settings != null) this.name = settings.getSystemDisplayName(); + else this.name = name; + } + } + + private static class RecipientCache { + + private final Map cache = new SoftHashMap<>(1000); + + public synchronized Recipient get(Address address) { + return cache.get(address); + } + + public synchronized void set(Address address, Recipient recipient) { + cache.put(address, recipient); + } + + public synchronized boolean remove(Address address) { + return cache.remove(address) != null; + } + + } + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientsFormatter.java b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientsFormatter.java new file mode 100644 index 0000000000..bdce30b69c --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/threads/recipients/RecipientsFormatter.java @@ -0,0 +1,76 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * 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 org.session.libsession.messaging.threads.recipients; + +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringTokenizer; + +public class RecipientsFormatter { + + private static String parseBracketedNumber(String recipient) throws RecipientFormattingException { + int begin = recipient.indexOf('<'); + int end = recipient.indexOf('>'); + String value = recipient.substring(begin + 1, end); + + if (PhoneNumberUtils.isWellFormedSmsAddress(value)) + return value; + else + throw new RecipientFormattingException("Bracketed value: " + value + " is not valid."); + } + + private static String parseRecipient(String recipient) throws RecipientFormattingException { + recipient = recipient.trim(); + + if ((recipient.indexOf('<') != -1) && (recipient.indexOf('>') != -1)) + return parseBracketedNumber(recipient); + + if (PhoneNumberUtils.isWellFormedSmsAddress(recipient)) + return recipient; + + throw new RecipientFormattingException("Recipient: " + recipient + " is badly formatted."); + } + + public static List getRecipients(String rawText) throws RecipientFormattingException { + ArrayList results = new ArrayList(); + StringTokenizer tokenizer = new StringTokenizer(rawText, ","); + + while (tokenizer.hasMoreTokens()) { + results.add(parseRecipient(tokenizer.nextToken())); + } + + return results; + } + + public static String formatNameAndNumber(String name, String number) { + // Format like this: Mike Cleron <(650) 555-1234> + // Erick Tseng <(650) 555-1212> + // Tutankhamun + // (408) 555-1289 + String formattedNumber = PhoneNumberUtils.formatNumber(number); + if (!TextUtils.isEmpty(name) && !name.equals(number)) { + return name + " <" + formattedNumber + ">"; + } else { + return formattedNumber; + } + } + + +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/Conversions.java b/libsession/src/main/java/org/session/libsession/utilities/Conversions.java new file mode 100644 index 0000000000..37fa7a655f --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/Conversions.java @@ -0,0 +1,180 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * 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 org.session.libsession.utilities; + +public class Conversions { + + public static byte intsToByteHighAndLow(int highValue, int lowValue) { + return (byte)((highValue << 4 | lowValue) & 0xFF); + } + + public static int highBitsToInt(byte value) { + return (value & 0xFF) >> 4; + } + + public static int lowBitsToInt(byte value) { + return (value & 0xF); + } + + public static int highBitsToMedium(int value) { + return (value >> 12); + } + + public static int lowBitsToMedium(int value) { + return (value & 0xFFF); + } + + public static byte[] shortToByteArray(int value) { + byte[] bytes = new byte[2]; + shortToByteArray(bytes, 0, value); + return bytes; + } + + public static int shortToByteArray(byte[] bytes, int offset, int value) { + bytes[offset+1] = (byte)value; + bytes[offset] = (byte)(value >> 8); + return 2; + } + + public static int shortToLittleEndianByteArray(byte[] bytes, int offset, int value) { + bytes[offset] = (byte)value; + bytes[offset+1] = (byte)(value >> 8); + return 2; + } + + public static byte[] mediumToByteArray(int value) { + byte[] bytes = new byte[3]; + mediumToByteArray(bytes, 0, value); + return bytes; + } + + public static int mediumToByteArray(byte[] bytes, int offset, int value) { + bytes[offset + 2] = (byte)value; + bytes[offset + 1] = (byte)(value >> 8); + bytes[offset] = (byte)(value >> 16); + return 3; + } + + public static byte[] intToByteArray(int value) { + byte[] bytes = new byte[4]; + intToByteArray(bytes, 0, value); + return bytes; + } + + public static int intToByteArray(byte[] bytes, int offset, int value) { + bytes[offset + 3] = (byte)value; + bytes[offset + 2] = (byte)(value >> 8); + bytes[offset + 1] = (byte)(value >> 16); + bytes[offset] = (byte)(value >> 24); + return 4; + } + + public static int intToLittleEndianByteArray(byte[] bytes, int offset, int value) { + bytes[offset] = (byte)value; + bytes[offset+1] = (byte)(value >> 8); + bytes[offset+2] = (byte)(value >> 16); + bytes[offset+3] = (byte)(value >> 24); + return 4; + } + + public static byte[] longToByteArray(long l) { + byte[] bytes = new byte[8]; + longToByteArray(bytes, 0, l); + return bytes; + } + + public static int longToByteArray(byte[] bytes, int offset, long value) { + bytes[offset + 7] = (byte)value; + bytes[offset + 6] = (byte)(value >> 8); + bytes[offset + 5] = (byte)(value >> 16); + bytes[offset + 4] = (byte)(value >> 24); + bytes[offset + 3] = (byte)(value >> 32); + bytes[offset + 2] = (byte)(value >> 40); + bytes[offset + 1] = (byte)(value >> 48); + bytes[offset] = (byte)(value >> 56); + return 8; + } + + public static int longTo4ByteArray(byte[] bytes, int offset, long value) { + bytes[offset + 3] = (byte)value; + bytes[offset + 2] = (byte)(value >> 8); + bytes[offset + 1] = (byte)(value >> 16); + bytes[offset + 0] = (byte)(value >> 24); + return 4; + } + + public static int byteArrayToShort(byte[] bytes) { + return byteArrayToShort(bytes, 0); + } + + public static int byteArrayToShort(byte[] bytes, int offset) { + return + (bytes[offset] & 0xff) << 8 | (bytes[offset + 1] & 0xff); + } + + // The SSL patented 3-byte Value. + public static int byteArrayToMedium(byte[] bytes, int offset) { + return + (bytes[offset] & 0xff) << 16 | + (bytes[offset + 1] & 0xff) << 8 | + (bytes[offset + 2] & 0xff); + } + + public static int byteArrayToInt(byte[] bytes) { + return byteArrayToInt(bytes, 0); + } + + public static int byteArrayToInt(byte[] bytes, int offset) { + return + (bytes[offset] & 0xff) << 24 | + (bytes[offset + 1] & 0xff) << 16 | + (bytes[offset + 2] & 0xff) << 8 | + (bytes[offset + 3] & 0xff); + } + + public static int byteArrayToIntLittleEndian(byte[] bytes, int offset) { + return + (bytes[offset + 3] & 0xff) << 24 | + (bytes[offset + 2] & 0xff) << 16 | + (bytes[offset + 1] & 0xff) << 8 | + (bytes[offset] & 0xff); + } + + public static long byteArrayToLong(byte[] bytes) { + return byteArrayToLong(bytes, 0); + } + + public static long byteArray4ToLong(byte[] bytes, int offset) { + return + ((bytes[offset + 0] & 0xffL) << 24) | + ((bytes[offset + 1] & 0xffL) << 16) | + ((bytes[offset + 2] & 0xffL) << 8) | + ((bytes[offset + 3] & 0xffL)); + } + + public static long byteArrayToLong(byte[] bytes, int offset) { + return + ((bytes[offset] & 0xffL) << 56) | + ((bytes[offset + 1] & 0xffL) << 48) | + ((bytes[offset + 2] & 0xffL) << 40) | + ((bytes[offset + 3] & 0xffL) << 32) | + ((bytes[offset + 4] & 0xffL) << 24) | + ((bytes[offset + 5] & 0xffL) << 16) | + ((bytes[offset + 6] & 0xffL) << 8) | + ((bytes[offset + 7] & 0xffL)); + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/DynamicLanguage.java b/libsession/src/main/java/org/session/libsession/utilities/DynamicLanguage.java new file mode 100644 index 0000000000..f363f92225 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/DynamicLanguage.java @@ -0,0 +1,55 @@ +package org.session.libsession.utilities; + +import android.app.Activity; +import android.app.Service; +import android.content.Context; +import android.content.res.Configuration; + +import org.session.libsession.utilities.dynamiclanguage.LanguageString; + +import java.util.Locale; + +/** + * @deprecated Use a base activity that uses the {@link org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper} + */ +@Deprecated +public class DynamicLanguage { + + public void onCreate(Activity activity) { + } + + public void onResume(Activity activity) { + } + + public void updateServiceLocale(Service service) { + setContextLocale(service, getSelectedLocale(service)); + } + + public Locale getCurrentLocale() { + return Locale.getDefault(); + } + + static int getLayoutDirection(Context context) { + Configuration configuration = context.getResources().getConfiguration(); + return configuration.getLayoutDirection(); + } + + private static void setContextLocale(Context context, Locale selectedLocale) { + Configuration configuration = context.getResources().getConfiguration(); + + if (!configuration.locale.equals(selectedLocale)) { + configuration.setLocale(selectedLocale); + context.getResources().updateConfiguration(configuration, + context.getResources().getDisplayMetrics()); + } + } + + private static Locale getSelectedLocale(Context context) { + Locale locale = LanguageString.parseLocale(TextSecurePreferences.getLanguage(context)); + if (locale == null) { + return Locale.getDefault(); + } else { + return locale; + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/FutureTaskListener.java b/libsession/src/main/java/org/session/libsession/utilities/FutureTaskListener.java new file mode 100644 index 0000000000..4bcee68453 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/FutureTaskListener.java @@ -0,0 +1,24 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * 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 org.session.libsession.utilities; + +import java.util.concurrent.ExecutionException; + +public interface FutureTaskListener { + public void onSuccess(V result); + public void onFailure(ExecutionException exception); +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/LinkedBlockingLifoQueue.java b/libsession/src/main/java/org/session/libsession/utilities/LinkedBlockingLifoQueue.java new file mode 100644 index 0000000000..053b7d4933 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/LinkedBlockingLifoQueue.java @@ -0,0 +1,23 @@ +package org.session.libsession.utilities; + + +import java.util.concurrent.LinkedBlockingDeque; + +public class LinkedBlockingLifoQueue extends LinkedBlockingDeque { + @Override + public void put(E runnable) throws InterruptedException { + super.putFirst(runnable); + } + + @Override + public boolean add(E runnable) { + super.addFirst(runnable); + return true; + } + + @Override + public boolean offer(E runnable) { + super.addFirst(runnable); + return true; + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/ListenableFutureTask.java b/libsession/src/main/java/org/session/libsession/utilities/ListenableFutureTask.java new file mode 100644 index 0000000000..cb62e2218b --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/ListenableFutureTask.java @@ -0,0 +1,126 @@ +/** + * Copyright (C) 2014 Open Whisper Systems + * + * 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 org.session.libsession.utilities; + +import androidx.annotation.Nullable; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.FutureTask; + +public class ListenableFutureTask extends FutureTask { + + private final List> listeners = new LinkedList<>(); + + @Nullable + private final Object identifier; + + @Nullable + private final Executor callbackExecutor; + + public ListenableFutureTask(Callable callable) { + this(callable, null); + } + + public ListenableFutureTask(Callable callable, @Nullable Object identifier) { + this(callable, identifier, null); + } + + public ListenableFutureTask(Callable callable, @Nullable Object identifier, @Nullable Executor callbackExecutor) { + super(callable); + this.identifier = identifier; + this.callbackExecutor = callbackExecutor; + } + + + public ListenableFutureTask(final V result) { + this(result, null); + } + + public ListenableFutureTask(final V result, @Nullable Object identifier) { + super(new Callable() { + @Override + public V call() throws Exception { + return result; + } + }); + this.identifier = identifier; + this.callbackExecutor = null; + this.run(); + } + + public synchronized void addListener(FutureTaskListener listener) { + if (this.isDone()) { + callback(listener); + } else { + this.listeners.add(listener); + } + } + + public synchronized void removeListener(FutureTaskListener listener) { + this.listeners.remove(listener); + } + + @Override + protected synchronized void done() { + callback(); + } + + private void callback() { + Runnable callbackRunnable = new Runnable() { + @Override + public void run() { + for (FutureTaskListener listener : listeners) { + callback(listener); + } + } + }; + + if (callbackExecutor == null) callbackRunnable.run(); + else callbackExecutor.execute(callbackRunnable); + } + + private void callback(FutureTaskListener listener) { + if (listener != null) { + try { + listener.onSuccess(get()); + } catch (InterruptedException e) { + throw new AssertionError(e); + } catch (ExecutionException e) { + listener.onFailure(e); + } + } + } + + @Override + public boolean equals(Object other) { + if (other != null && other instanceof ListenableFutureTask && this.identifier != null) { + return identifier.equals(other); + } else { + return super.equals(other); + } + } + + @Override + public int hashCode() { + if (identifier != null) return identifier.hashCode(); + else return super.hashCode(); + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureModifiedEvent.kt b/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureModifiedEvent.kt new file mode 100644 index 0000000000..1bfe443253 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureModifiedEvent.kt @@ -0,0 +1,5 @@ +package org.session.libsession.utilities + +import org.session.libsession.messaging.threads.recipients.Recipient + +data class ProfilePictureModifiedEvent(val recipient: Recipient) \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt new file mode 100644 index 0000000000..96a6341665 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt @@ -0,0 +1,46 @@ +package org.session.libsession.utilities + +import android.content.Context +import org.session.libsession.messaging.threads.Address +import org.session.libsession.messaging.threads.recipients.Recipient + +class SSKEnvironment( + val typingIndicators: TypingIndicatorsProtocol, + val blockManager: BlockingManagerProtocol, + val readReceiptManager: ReadReceiptManagerProtocol, + val profileManager: ProfileManagerProtocol +) { + interface TypingIndicatorsProtocol { + fun didReceiveTypingStartedMessage(context: Context, threadId: Long, author: Address, device: Int) + fun didReceiveTypingStoppedMessage(context: Context, threadId: Long, author: Address, device: Int, isReplacedByIncomingMessage: Boolean) + fun didReceiveIncomingMessage(context: Context, threadId: Long, author: Address, device: Int) + } + + interface BlockingManagerProtocol { + fun isRecipientIdBlocked(publicKey: String): Boolean + } + + interface ReadReceiptManagerProtocol { + fun processReadReceipts(fromRecipientId: String, sentTimestamps: List, readTimestamp: Long) + } + + interface ProfileManagerProtocol { + fun setDisplayName(recipient: Recipient, displayName: String) + fun setProfilePictureURL(recipient: Recipient, profilePictureURL: String) + fun setProfileKey(recipient: Recipient, profileKey: ByteArray) + fun setUnidentifiedAccessMode(recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) + fun updateOpenGroupProfilePicturesIfNeeded() + } + + companion object { + lateinit var shared: SSKEnvironment + + fun configure(typingIndicators: TypingIndicatorsProtocol, + blockManager: BlockingManagerProtocol, + readReceiptManager: ReadReceiptManagerProtocol, + profileManager: ProfileManagerProtocol) { + if (Companion::shared.isInitialized) { return } + shared = SSKEnvironment(typingIndicators, blockManager, readReceiptManager, profileManager) + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/SoftHashMap.java b/libsession/src/main/java/org/session/libsession/utilities/SoftHashMap.java new file mode 100644 index 0000000000..4d57bb09d9 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/SoftHashMap.java @@ -0,0 +1,328 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.session.libsession.utilities; + +import androidx.annotation.NonNull; + +import java.lang.ref.ReferenceQueue; +import java.lang.ref.SoftReference; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A SoftHashMap is a memory-constrained map that stores its values in + * {@link SoftReference SoftReference}s. (Contrast this with the JDK's + * {@link WeakHashMap WeakHashMap}, which uses weak references for its keys, which is of little value if you + * want the cache to auto-resize itself based on memory constraints). + *

+ * Having the values wrapped by soft references allows the cache to automatically reduce its size based on memory + * limitations and garbage collection. This ensures that the cache will not cause memory leaks by holding strong + * references to all of its values. + *

+ * This class is a generics-enabled Map based on initial ideas from Heinz Kabutz's and Sydney Redelinghuys's + * publicly posted version (with their approval), with + * continued modifications. + *

+ * This implementation is thread-safe and usable in concurrent environments. + * + * @since 1.0 + */ +public class SoftHashMap implements Map { + + /** + * The default value of the RETENTION_SIZE attribute, equal to 100. + */ + private static final int DEFAULT_RETENTION_SIZE = 100; + + /** + * The internal HashMap that will hold the SoftReference. + */ + private final Map> map; + + /** + * The number of strong references to hold internally, that is, the number of instances to prevent + * from being garbage collected automatically (unlike other soft references). + */ + private final int RETENTION_SIZE; + + /** + * The FIFO list of strong references (not to be garbage collected), order of last access. + */ + private final Queue strongReferences; //guarded by 'strongReferencesLock' + private final ReentrantLock strongReferencesLock; + + /** + * Reference queue for cleared SoftReference objects. + */ + private final ReferenceQueue queue; + + /** + * Creates a new SoftHashMap with a default retention size size of + * {@link #DEFAULT_RETENTION_SIZE DEFAULT_RETENTION_SIZE} (100 entries). + * + * @see #SoftHashMap(int) + */ + public SoftHashMap() { + this(DEFAULT_RETENTION_SIZE); + } + + /** + * Creates a new SoftHashMap with the specified retention size. + *

+ * The retention size (n) is the total number of most recent entries in the map that will be strongly referenced + * (ie 'retained') to prevent them from being eagerly garbage collected. That is, the point of a SoftHashMap is to + * allow the garbage collector to remove as many entries from this map as it desires, but there will always be (n) + * elements retained after a GC due to the strong references. + *

+ * Note that in a highly concurrent environments the exact total number of strong references may differ slightly + * than the actual retentionSize value. This number is intended to be a best-effort retention low + * water mark. + * + * @param retentionSize the total number of most recent entries in the map that will be strongly referenced + * (retained), preventing them from being eagerly garbage collected by the JVM. + */ + @SuppressWarnings({"unchecked"}) + public SoftHashMap(int retentionSize) { + super(); + RETENTION_SIZE = Math.max(0, retentionSize); + queue = new ReferenceQueue(); + strongReferencesLock = new ReentrantLock(); + map = new ConcurrentHashMap>(); + strongReferences = new ConcurrentLinkedQueue(); + } + + /** + * Creates a {@code SoftHashMap} backed by the specified {@code source}, with a default retention + * size of {@link #DEFAULT_RETENTION_SIZE DEFAULT_RETENTION_SIZE} (100 entries). + * + * @param source the backing map to populate this {@code SoftHashMap} + * @see #SoftHashMap(Map,int) + */ + public SoftHashMap(Map source) { + this(DEFAULT_RETENTION_SIZE); + putAll(source); + } + + /** + * Creates a {@code SoftHashMap} backed by the specified {@code source}, with the specified retention size. + *

+ * The retention size (n) is the total number of most recent entries in the map that will be strongly referenced + * (ie 'retained') to prevent them from being eagerly garbage collected. That is, the point of a SoftHashMap is to + * allow the garbage collector to remove as many entries from this map as it desires, but there will always be (n) + * elements retained after a GC due to the strong references. + *

+ * Note that in a highly concurrent environments the exact total number of strong references may differ slightly + * than the actual retentionSize value. This number is intended to be a best-effort retention low + * water mark. + * + * @param source the backing map to populate this {@code SoftHashMap} + * @param retentionSize the total number of most recent entries in the map that will be strongly referenced + * (retained), preventing them from being eagerly garbage collected by the JVM. + */ + public SoftHashMap(Map source, int retentionSize) { + this(retentionSize); + putAll(source); + } + + public V get(Object key) { + processQueue(); + + V result = null; + SoftValue value = map.get(key); + + if (value != null) { + //unwrap the 'real' value from the SoftReference + result = value.get(); + if (result == null) { + //The wrapped value was garbage collected, so remove this entry from the backing map: + //noinspection SuspiciousMethodCalls + map.remove(key); + } else { + //Add this value to the beginning of the strong reference queue (FIFO). + addToStrongReferences(result); + } + } + return result; + } + + private void addToStrongReferences(V result) { + strongReferencesLock.lock(); + try { + strongReferences.add(result); + trimStrongReferencesIfNecessary(); + } finally { + strongReferencesLock.unlock(); + } + + } + + //Guarded by the strongReferencesLock in the addToStrongReferences method + + private void trimStrongReferencesIfNecessary() { + //trim the strong ref queue if necessary: + while (strongReferences.size() > RETENTION_SIZE) { + strongReferences.poll(); + } + } + + /** + * Traverses the ReferenceQueue and removes garbage-collected SoftValue objects from the backing map + * by looking them up using the SoftValue.key data member. + */ + private void processQueue() { + SoftValue sv; + while ((sv = (SoftValue) queue.poll()) != null) { + //noinspection SuspiciousMethodCalls + map.remove(sv.key); // we can access private data! + } + } + + public boolean isEmpty() { + processQueue(); + return map.isEmpty(); + } + + public boolean containsKey(Object key) { + processQueue(); + return map.containsKey(key); + } + + public boolean containsValue(Object value) { + processQueue(); + Collection values = values(); + return values != null && values.contains(value); + } + + public void putAll(@NonNull Map m) { + if (m == null || m.isEmpty()) { + processQueue(); + return; + } + for (Entry entry : m.entrySet()) { + put(entry.getKey(), entry.getValue()); + } + } + + public @NonNull Set keySet() { + processQueue(); + return map.keySet(); + } + + public @NonNull Collection values() { + processQueue(); + Collection keys = map.keySet(); + if (keys.isEmpty()) { + //noinspection unchecked + return Collections.EMPTY_SET; + } + Collection values = new ArrayList(keys.size()); + for (K key : keys) { + V v = get(key); + if (v != null) { + values.add(v); + } + } + return values; + } + + /** + * Creates a new entry, but wraps the value in a SoftValue instance to enable auto garbage collection. + */ + public V put(@NonNull K key, @NonNull V value) { + processQueue(); // throw out garbage collected values first + SoftValue sv = new SoftValue(value, key, queue); + SoftValue previous = map.put(key, sv); + addToStrongReferences(value); + return previous != null ? previous.get() : null; + } + + public V remove(Object key) { + processQueue(); // throw out garbage collected values first + SoftValue raw = map.remove(key); + return raw != null ? raw.get() : null; + } + + public void clear() { + strongReferencesLock.lock(); + try { + strongReferences.clear(); + } finally { + strongReferencesLock.unlock(); + } + processQueue(); // throw out garbage collected values + map.clear(); + } + + public int size() { + processQueue(); // throw out garbage collected values first + return map.size(); + } + + public @NonNull Set> entrySet() { + processQueue(); // throw out garbage collected values first + Collection keys = map.keySet(); + if (keys.isEmpty()) { + //noinspection unchecked + return Collections.EMPTY_SET; + } + + Map kvPairs = new HashMap(keys.size()); + for (K key : keys) { + V v = get(key); + if (v != null) { + kvPairs.put(key, v); + } + } + return kvPairs.entrySet(); + } + + /** + * We define our own subclass of SoftReference which contains + * not only the value but also the key to make it easier to find + * the entry in the HashMap after it's been garbage collected. + */ + private static class SoftValue extends SoftReference { + + private final K key; + + /** + * Constructs a new instance, wrapping the value, key, and queue, as + * required by the superclass. + * + * @param value the map value + * @param key the map key + * @param queue the soft reference queue to poll to determine if the entry had been reaped by the GC. + */ + private SoftValue(V value, K key, ReferenceQueue queue) { + super(value, queue); + this.key = key; + } + + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index a0563ed337..180698362c 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -386,6 +386,7 @@ object TextSecurePreferences { setIntegerPrefrence(context, PROFILE_AVATAR_ID_PREF, id) } + @JvmStatic fun getProfileAvatarId(context: Context): Int { return getIntegerPreference(context, PROFILE_AVATAR_ID_PREF, 0) } @@ -606,6 +607,7 @@ object TextSecurePreferences { return getStringPreference(context, UPDATE_APK_DIGEST, null) } + @JvmStatic fun getLocalNumber(context: Context): String? { return getStringPreference(context, LOCAL_NUMBER_PREF, null) } @@ -820,6 +822,7 @@ object TextSecurePreferences { setIntegerPrefrence(context, PASSPHRASE_TIMEOUT_INTERVAL_PREF, interval) } + @JvmStatic fun getLanguage(context: Context): String? { return getStringPreference(context, LANGUAGE_PREF, "zz") } @@ -1100,6 +1103,7 @@ object TextSecurePreferences { setBooleanPreference(context, "is_chat_set_up?chat=$id", true) } + @JvmStatic fun getMasterHexEncodedPublicKey(context: Context): String? { return getStringPreference(context, "master_hex_encoded_public_key", null) } diff --git a/libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java b/libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java new file mode 100644 index 0000000000..144f10b452 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java @@ -0,0 +1,70 @@ +package org.session.libsession.utilities; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import android.util.TypedValue; +import android.view.LayoutInflater; + +import androidx.annotation.AttrRes; +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.appcompat.view.ContextThemeWrapper; + +import org.session.libsignal.libsignal.logging.Log; + +import org.session.libsession.R; + +public class ThemeUtil { + private static final String TAG = ThemeUtil.class.getSimpleName(); + + public static boolean isDarkTheme(@NonNull Context context) { + return getAttributeText(context, R.attr.theme_type, "light").equals("dark"); + } + + @ColorInt + public static int getThemedColor(@NonNull Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(attr, typedValue, true)) { + return typedValue.data; + } else { + Log.e(TAG, "Couldn't find a color attribute with id: " + attr); + return Color.RED; + } + } + + @DrawableRes + public static int getThemedDrawableResId(@NonNull Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(attr, typedValue, true)) { + return typedValue.resourceId; + } else { + Log.e(TAG, "Couldn't find a drawable attribute with id: " + attr); + return 0; + } + } + + public static LayoutInflater getThemedInflater(@NonNull Context context, @NonNull LayoutInflater inflater, @StyleRes int theme) { + Context contextThemeWrapper = new ContextThemeWrapper(context, theme); + return inflater.cloneInContext(contextThemeWrapper); + } + + private static String getAttributeText(Context context, int attribute, String defaultValue) { + TypedValue outValue = new TypedValue(); + + if (context.getTheme().resolveAttribute(attribute, outValue, true)) { + CharSequence charSequence = outValue.coerceToString(); + if (charSequence != null) { + return charSequence.toString(); + } + } + + return defaultValue; + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/Util.kt b/libsession/src/main/java/org/session/libsession/utilities/Util.kt index 0350307423..bf539a1b49 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Util.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Util.kt @@ -1,6 +1,61 @@ package org.session.libsession.utilities +import android.net.Uri +import android.os.Handler +import android.os.Looper +import java.util.concurrent.ExecutorService +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + object Util { + @Volatile + private var handler: Handler? = null + + fun isMainThread(): Boolean { + return Looper.myLooper() == Looper.getMainLooper() + } + + @JvmStatic + fun uri(uri: String?): Uri? { + return if (uri == null) null else Uri.parse(uri) + } + + @JvmStatic + fun runOnMain(runnable: Runnable) { + if (isMainThread()) runnable.run() + else getHandler()?.post(runnable) + } + + private fun getHandler(): Handler? { + if (handler == null) { + synchronized(Util::class.java) { + if (handler == null) { + handler = Handler(Looper.getMainLooper()) + } + } + } + return handler + } + + @JvmStatic + fun wait(lock: Object, timeout: Long) { + try { + lock.wait(timeout) + } catch (ie: InterruptedException) { + throw AssertionError(ie) + } + } + + @JvmStatic + fun newSingleThreadedLifoExecutor(): ExecutorService { + val executor = ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, LinkedBlockingLifoQueue()) + executor.execute { + Thread.currentThread().priority = Thread.MIN_PRIORITY + } + return executor + } + + @JvmStatic fun join(list: Collection, delimiter: String?): String { val result = StringBuilder() var i = 0 @@ -10,4 +65,10 @@ object Util { } return result.toString() } + + @JvmStatic + fun equals(a: Any?, b: Any?): Boolean { + return a === b || a != null && a == b + } + } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/ViewUtil.java b/libsession/src/main/java/org/session/libsession/utilities/ViewUtil.java new file mode 100644 index 0000000000..07467e40fd --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/ViewUtil.java @@ -0,0 +1,256 @@ +/** + * Copyright (C) 2015 Open Whisper Systems + * + * 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 org.session.libsession.utilities; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.widget.LinearLayout.LayoutParams; +import android.widget.TextView; + +import androidx.annotation.IdRes; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; + +import org.session.libsession.utilities.concurrent.ListenableFuture; +import org.session.libsession.utilities.concurrent.SettableFuture; +import org.session.libsession.utilities.views.Stub; + +public class ViewUtil { + @SuppressWarnings("deprecation") + public static void setBackground(final @NonNull View v, final @Nullable Drawable drawable) { + if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { + v.setBackground(drawable); + } else { + v.setBackgroundDrawable(drawable); + } + } + + public static void setY(final @NonNull View v, final int y) { + if (VERSION.SDK_INT >= 11) { + ViewCompat.setY(v, y); + } else { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams)v.getLayoutParams(); + params.topMargin = y; + v.setLayoutParams(params); + } + } + + public static float getY(final @NonNull View v) { + if (VERSION.SDK_INT >= 11) { + return ViewCompat.getY(v); + } else { + return ((ViewGroup.MarginLayoutParams)v.getLayoutParams()).topMargin; + } + } + + public static void setX(final @NonNull View v, final int x) { + if (VERSION.SDK_INT >= 11) { + ViewCompat.setX(v, x); + } else { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams)v.getLayoutParams(); + params.leftMargin = x; + v.setLayoutParams(params); + } + } + + public static float getX(final @NonNull View v) { + if (VERSION.SDK_INT >= 11) { + return ViewCompat.getX(v); + } else { + return ((LayoutParams)v.getLayoutParams()).leftMargin; + } + } + + public static void swapChildInPlace(ViewGroup parent, View toRemove, View toAdd, int defaultIndex) { + int childIndex = parent.indexOfChild(toRemove); + if (childIndex > -1) parent.removeView(toRemove); + parent.addView(toAdd, childIndex > -1 ? childIndex : defaultIndex); + } + + @SuppressWarnings("unchecked") + public static T inflateStub(@NonNull View parent, @IdRes int stubId) { + return (T)((ViewStub)parent.findViewById(stubId)).inflate(); + } + + @SuppressWarnings("unchecked") + public static T findById(@NonNull View parent, @IdRes int resId) { + return (T) parent.findViewById(resId); + } + + @SuppressWarnings("unchecked") + public static T findById(@NonNull Activity parent, @IdRes int resId) { + return (T) parent.findViewById(resId); + } + + public static Stub findStubById(@NonNull Activity parent, @IdRes int resId) { + return new Stub((ViewStub)parent.findViewById(resId)); + } + + private static Animation getAlphaAnimation(float from, float to, int duration) { + final Animation anim = new AlphaAnimation(from, to); + anim.setInterpolator(new FastOutSlowInInterpolator()); + anim.setDuration(duration); + return anim; + } + + public static void fadeIn(final @NonNull View view, final int duration) { + animateIn(view, getAlphaAnimation(0f, 1f, duration)); + } + + public static ListenableFuture fadeOut(final @NonNull View view, final int duration) { + return fadeOut(view, duration, View.GONE); + } + + public static ListenableFuture fadeOut(@NonNull View view, int duration, int visibility) { + return animateOut(view, getAlphaAnimation(1f, 0f, duration), visibility); + } + + public static ListenableFuture animateOut(final @NonNull View view, final @NonNull Animation animation, final int visibility) { + final SettableFuture future = new SettableFuture(); + if (view.getVisibility() == visibility) { + future.set(true); + } else { + view.clearAnimation(); + animation.reset(); + animation.setStartTime(0); + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationRepeat(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + view.setVisibility(visibility); + future.set(true); + } + }); + view.startAnimation(animation); + } + return future; + } + + public static void animateIn(final @NonNull View view, final @NonNull Animation animation) { + if (view.getVisibility() == View.VISIBLE) return; + + view.clearAnimation(); + animation.reset(); + animation.setStartTime(0); + view.setVisibility(View.VISIBLE); + view.startAnimation(animation); + } + + @SuppressWarnings("unchecked") + public static T inflate(@NonNull LayoutInflater inflater, + @NonNull ViewGroup parent, + @LayoutRes int layoutResId) + { + return (T)(inflater.inflate(layoutResId, parent, false)); + } + + @SuppressLint("RtlHardcoded") + public static void setTextViewGravityStart(final @NonNull TextView textView, @NonNull Context context) { + if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) { + if (DynamicLanguage.getLayoutDirection(context) == View.LAYOUT_DIRECTION_RTL) { + textView.setGravity(Gravity.RIGHT); + } else { + textView.setGravity(Gravity.LEFT); + } + } + } + + public static void mirrorIfRtl(View view, Context context) { + if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1 && + DynamicLanguage.getLayoutDirection(context) == View.LAYOUT_DIRECTION_RTL) { + view.setScaleX(-1.0f); + } + } + + public static int dpToPx(Context context, int dp) { + return (int)((dp * context.getResources().getDisplayMetrics().density) + 0.5); + } + + public static void updateLayoutParams(@NonNull View view, int width, int height) { + view.getLayoutParams().width = width; + view.getLayoutParams().height = height; + view.requestLayout(); + } + + public static int getLeftMargin(@NonNull View view) { + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin; + } + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin; + } + + public static int getRightMargin(@NonNull View view) { + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin; + } + return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin; + } + + public static void setLeftMargin(@NonNull View view, int margin) { + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin; + } else { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin; + } + view.forceLayout(); + view.requestLayout(); + } + + public static void setTopMargin(@NonNull View view, int margin) { + ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin = margin; + view.requestLayout(); + } + + public static void setPaddingTop(@NonNull View view, int padding) { + view.setPadding(view.getPaddingLeft(), padding, view.getPaddingRight(), view.getPaddingBottom()); + } + + public static void setPaddingBottom(@NonNull View view, int padding) { + view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), padding); + } + + public static boolean isPointInsideView(@NonNull View view, float x, float y) { + int[] location = new int[2]; + + view.getLocationOnScreen(location); + + int viewX = location[0]; + int viewY = location[1]; + + return x > viewX && x < viewX + view.getWidth() && + y > viewY && y < viewY + view.getHeight(); + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/color/MaterialColor.java b/libsession/src/main/java/org/session/libsession/utilities/color/MaterialColor.java new file mode 100644 index 0000000000..f68c016f0e --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/color/MaterialColor.java @@ -0,0 +1,135 @@ +package org.session.libsession.utilities.color; + +import android.content.Context; +import android.graphics.Color; + +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; + +import org.session.libsession.R; + +import java.util.HashMap; +import java.util.Map; + +import static org.session.libsession.utilities.ThemeUtil.isDarkTheme; + +public enum MaterialColor { + CRIMSON (R.color.conversation_crimson, R.color.conversation_crimson_tint, R.color.conversation_crimson_shade, "red"), + VERMILLION (R.color.conversation_vermillion, R.color.conversation_vermillion_tint, R.color.conversation_vermillion_shade, "orange"), + BURLAP (R.color.conversation_burlap, R.color.conversation_burlap_tint, R.color.conversation_burlap_shade, "brown"), + FOREST (R.color.conversation_forest, R.color.conversation_forest_tint, R.color.conversation_forest_shade, "green"), + WINTERGREEN(R.color.conversation_wintergreen, R.color.conversation_wintergreen_tint, R.color.conversation_wintergreen_shade, "light_green"), + TEAL (R.color.conversation_teal, R.color.conversation_teal_tint, R.color.conversation_teal_shade, "teal"), + BLUE (R.color.conversation_blue, R.color.conversation_blue_tint, R.color.conversation_blue_shade, "blue"), + INDIGO (R.color.conversation_indigo, R.color.conversation_indigo_tint, R.color.conversation_indigo_shade, "indigo"), + VIOLET (R.color.conversation_violet, R.color.conversation_violet_tint, R.color.conversation_violet_shade, "purple"), + PLUM (R.color.conversation_plumb, R.color.conversation_plumb_tint, R.color.conversation_plumb_shade, "pink"), + TAUPE (R.color.conversation_taupe, R.color.conversation_taupe_tint, R.color.conversation_taupe_shade, "blue_grey"), + STEEL (R.color.conversation_steel, R.color.conversation_steel_tint, R.color.conversation_steel_shade, "grey"), + GROUP (R.color.conversation_group, R.color.conversation_group_tint, R.color.conversation_group_shade, "blue"); + + private static final Map COLOR_MATCHES = new HashMap() {{ + put("red", CRIMSON); + put("deep_orange", CRIMSON); + put("orange", VERMILLION); + put("amber", VERMILLION); + put("brown", BURLAP); + put("yellow", BURLAP); + put("pink", PLUM); + put("purple", VIOLET); + put("deep_purple", VIOLET); + put("indigo", INDIGO); + put("blue", BLUE); + put("light_blue", BLUE); + put("cyan", TEAL); + put("teal", TEAL); + put("green", FOREST); + put("light_green", WINTERGREEN); + put("lime", WINTERGREEN); + put("blue_grey", TAUPE); + put("grey", STEEL); + put("group_color", GROUP); + }}; + + private final @ColorRes int mainColor; + private final @ColorRes int tintColor; + private final @ColorRes int shadeColor; + + private final String serialized; + + + MaterialColor(@ColorRes int mainColor, @ColorRes int tintColor, @ColorRes int shadeColor, String serialized) { + this.mainColor = mainColor; + this.tintColor = tintColor; + this.shadeColor = shadeColor; + this.serialized = serialized; + } + + public @ColorInt int toConversationColor(@NonNull Context context) { + return context.getResources().getColor(mainColor); + } + + public @ColorInt int toAvatarColor(@NonNull Context context) { + return context.getResources().getColor(isDarkTheme(context) ? shadeColor : mainColor); + } + + public @ColorInt int toActionBarColor(@NonNull Context context) { + return context.getResources().getColor(mainColor); + } + + public @ColorInt int toStatusBarColor(@NonNull Context context) { + return context.getResources().getColor(shadeColor); + } + + public @ColorRes int toQuoteBarColorResource(@NonNull Context context, boolean outgoing) { + if (outgoing) { + return isDarkTheme(context) ? tintColor : shadeColor ; + } + return R.color.core_white; + } + + public @ColorInt int toQuoteBackgroundColor(@NonNull Context context, boolean outgoing) { + if (outgoing) { + int color = toConversationColor(context); + int alpha = isDarkTheme(context) ? (int) (0.2 * 255) : (int) (0.4 * 255); + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); + } + return context.getResources().getColor(isDarkTheme(context) ? R.color.transparent_black_70 + : R.color.transparent_white_aa); + } + + public @ColorInt int toQuoteFooterColor(@NonNull Context context, boolean outgoing) { + if (outgoing) { + int color = toConversationColor(context); + int alpha = isDarkTheme(context) ? (int) (0.4 * 255) : (int) (0.6 * 255); + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); + } + return context.getResources().getColor(isDarkTheme(context) ? R.color.transparent_black_90 + : R.color.transparent_white_bb); + } + + public boolean represents(Context context, int colorValue) { + return context.getResources().getColor(mainColor) == colorValue || + context.getResources().getColor(tintColor) == colorValue || + context.getResources().getColor(shadeColor) == colorValue; + } + + public String serialize() { + return serialized; + } + + public static MaterialColor fromSerialized(String serialized) throws UnknownColorException { + if (COLOR_MATCHES.containsKey(serialized)) { + return COLOR_MATCHES.get(serialized); + } + + throw new UnknownColorException("Unknown color: " + serialized); + } + + public static class UnknownColorException extends Exception { + public UnknownColorException(String message) { + super(message); + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/color/MaterialColors.java b/libsession/src/main/java/org/session/libsession/utilities/color/MaterialColors.java new file mode 100644 index 0000000000..4f6b936db1 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/color/MaterialColors.java @@ -0,0 +1,70 @@ +package org.session.libsession.utilities.color; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MaterialColors { + + public static final MaterialColorList CONVERSATION_PALETTE = new MaterialColorList(new ArrayList<>(Arrays.asList( + MaterialColor.PLUM, + MaterialColor.CRIMSON, + MaterialColor.VERMILLION, + MaterialColor.VIOLET, + MaterialColor.BLUE, + MaterialColor.INDIGO, + MaterialColor.FOREST, + MaterialColor.WINTERGREEN, + MaterialColor.TEAL, + MaterialColor.BURLAP, + MaterialColor.TAUPE, + MaterialColor.STEEL + ))); + + public static class MaterialColorList { + + private final List colors; + + private MaterialColorList(List colors) { + this.colors = colors; + } + + public MaterialColor get(int index) { + return colors.get(index); + } + + public int size() { + return colors.size(); + } + + public @Nullable MaterialColor getByColor(Context context, int colorValue) { + for (MaterialColor color : colors) { + if (color.represents(context, colorValue)) { + return color; + } + } + + return null; + } + + public int[] asConversationColorArray(@NonNull Context context) { + int[] results = new int[colors.size()]; + int index = 0; + + for (MaterialColor color : colors) { + results[index++] = color.toConversationColor(context); + } + + return results; + } + + } + + +} + diff --git a/libsession/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java b/libsession/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java new file mode 100644 index 0000000000..7c21fa095f --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java @@ -0,0 +1,12 @@ +package org.session.libsession.utilities.concurrent; + +import org.session.libsession.utilities.concurrent.ListenableFuture.Listener; + +import java.util.concurrent.ExecutionException; + +public abstract class AssertedSuccessListener implements Listener { + @Override + public void onFailure(ExecutionException e) { + throw new AssertionError(e); + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/concurrent/ListenableFuture.java b/libsession/src/main/java/org/session/libsession/utilities/concurrent/ListenableFuture.java new file mode 100644 index 0000000000..896bae9baf --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/concurrent/ListenableFuture.java @@ -0,0 +1,13 @@ +package org.session.libsession.utilities.concurrent; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +public interface ListenableFuture extends Future { + void addListener(Listener listener); + + public interface Listener { + public void onSuccess(T result); + public void onFailure(ExecutionException e); + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/concurrent/SettableFuture.java b/libsession/src/main/java/org/session/libsession/utilities/concurrent/SettableFuture.java new file mode 100644 index 0000000000..ab7b4cfaf1 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/concurrent/SettableFuture.java @@ -0,0 +1,136 @@ +package org.session.libsession.utilities.concurrent; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class SettableFuture implements ListenableFuture { + + private final List> listeners = new LinkedList<>(); + + private boolean completed; + private boolean canceled; + private volatile T result; + private volatile Throwable exception; + + public SettableFuture() { } + + public SettableFuture(T value) { + this.result = value; + this.completed = true; + } + + @Override + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (!completed && !canceled) { + canceled = true; + return true; + } + + return false; + } + + @Override + public synchronized boolean isCancelled() { + return canceled; + } + + @Override + public synchronized boolean isDone() { + return completed; + } + + public boolean set(T result) { + synchronized (this) { + if (completed || canceled) return false; + + this.result = result; + this.completed = true; + + notifyAll(); + } + + notifyAllListeners(); + return true; + } + + public boolean setException(Throwable throwable) { + synchronized (this) { + if (completed || canceled) return false; + + this.exception = throwable; + this.completed = true; + + notifyAll(); + } + + notifyAllListeners(); + return true; + } + + public void deferTo(ListenableFuture other) { + other.addListener(new Listener() { + @Override + public void onSuccess(T result) { + SettableFuture.this.set(result); + } + + @Override + public void onFailure(ExecutionException e) { + SettableFuture.this.setException(e.getCause()); + } + }); + } + + @Override + public synchronized T get() throws InterruptedException, ExecutionException { + while (!completed) wait(); + + if (exception != null) throw new ExecutionException(exception); + else return result; + } + + @Override + public synchronized T get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException + { + long startTime = System.currentTimeMillis(); + + while (!completed && System.currentTimeMillis() - startTime > unit.toMillis(timeout)) { + wait(unit.toMillis(timeout)); + } + + if (!completed) throw new TimeoutException(); + else return get(); + } + + @Override + public void addListener(Listener listener) { + synchronized (this) { + listeners.add(listener); + + if (!completed) return; + } + + notifyListener(listener); + } + + private void notifyAllListeners() { + List> localListeners; + + synchronized (this) { + localListeners = new LinkedList<>(listeners); + } + + for (Listener listener : localListeners) { + notifyListener(listener); + } + } + + private void notifyListener(Listener listener) { + if (exception != null) listener.onFailure(new ExecutionException(exception)); + else listener.onSuccess(result); + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/concurrent/SignalExecutors.java b/libsession/src/main/java/org/session/libsession/utilities/concurrent/SignalExecutors.java new file mode 100644 index 0000000000..3d625b1c8e --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/concurrent/SignalExecutors.java @@ -0,0 +1,40 @@ +package org.session.libsession.utilities.concurrent; + +import androidx.annotation.NonNull; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class SignalExecutors { + + public static final ExecutorService UNBOUNDED = Executors.newCachedThreadPool(new NumberedThreadFactory("signal-unbounded")); + public static final ExecutorService BOUNDED = Executors.newFixedThreadPool(Math.max(2, Math.min(Runtime.getRuntime().availableProcessors() - 1, 4)), new NumberedThreadFactory("signal-bounded")); + public static final ExecutorService SERIAL = Executors.newSingleThreadExecutor(new NumberedThreadFactory("signal-serial")); + + public static ExecutorService newCachedSingleThreadExecutor(final String name) { + ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 15, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), r -> new Thread(r, name)); + executor.allowCoreThreadTimeOut(true); + return executor; + } + + private static class NumberedThreadFactory implements ThreadFactory { + + private final String baseName; + private final AtomicInteger counter; + + NumberedThreadFactory(@NonNull String baseName) { + this.baseName = baseName; + this.counter = new AtomicInteger(); + } + + @Override + public Thread newThread(@NonNull Runnable r) { + return new Thread(r, baseName + "-" + counter.getAndIncrement()); + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/concurrent/SimpleTask.java b/libsession/src/main/java/org/session/libsession/utilities/concurrent/SimpleTask.java new file mode 100644 index 0000000000..61f1c0570a --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/concurrent/SimpleTask.java @@ -0,0 +1,59 @@ +package org.session.libsession.utilities.concurrent; + +import android.os.AsyncTask; + +import androidx.annotation.NonNull; +import androidx.lifecycle.Lifecycle; + +import org.session.libsession.utilities.Util; + +public class SimpleTask { + + /** + * Runs a task in the background and passes the result of the computation to a task that is run + * on the main thread. Will only invoke the {@code foregroundTask} if the provided {@link Lifecycle} + * is in a valid (i.e. visible) state at that time. In this way, it is very similar to + * {@link AsyncTask}, but is safe in that you can guarantee your task won't be called when your + * view is in an invalid state. + */ + public static void run(@NonNull Lifecycle lifecycle, @NonNull BackgroundTask backgroundTask, @NonNull ForegroundTask foregroundTask) { + if (!isValid(lifecycle)) { + return; + } + + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { + final E result = backgroundTask.run(); + + if (isValid(lifecycle)) { + Util.runOnMain(() -> { + if (isValid(lifecycle)) { + foregroundTask.run(result); + } + }); + } + }); + } + + /** + * Runs a task in the background and passes the result of the computation to a task that is run on + * the main thread. Essentially {@link AsyncTask}, but lambda-compatible. + */ + public static void run(@NonNull BackgroundTask backgroundTask, @NonNull ForegroundTask foregroundTask) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { + final E result = backgroundTask.run(); + Util.runOnMain(() -> foregroundTask.run(result)); + }); + } + + private static boolean isValid(@NonNull Lifecycle lifecycle) { + return lifecycle.getCurrentState().isAtLeast(Lifecycle.State.CREATED); + } + + public interface BackgroundTask { + E run(); + } + + public interface ForegroundTask { + void run(E result); + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/DynamicLanguageActivityHelper.java b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/DynamicLanguageActivityHelper.java new file mode 100644 index 0000000000..8f1b20fed1 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/DynamicLanguageActivityHelper.java @@ -0,0 +1,40 @@ +package org.session.libsession.utilities.dynamiclanguage; + +import android.app.Activity; + +import androidx.annotation.MainThread; +import androidx.core.os.ConfigurationCompat; + +import org.session.libsignal.libsignal.logging.Log; + +import java.util.Locale; + +public final class DynamicLanguageActivityHelper { + + private static final String TAG = DynamicLanguageActivityHelper.class.getSimpleName(); + + private static String reentryProtection; + + /** + * If the activity isn't in the specified language, it will restart the activity. + */ + @MainThread + public static void recreateIfNotInCorrectLanguage(Activity activity, String language) { + Locale currentActivityLocale = ConfigurationCompat.getLocales(activity.getResources().getConfiguration()).get(0); + Locale selectedLocale = LocaleParser.findBestMatchingLocaleForLanguage(language); + + if (currentActivityLocale.equals(selectedLocale)) { + reentryProtection = ""; + return; + } + + String reentryKey = activity.getClass().getName() + ":" + selectedLocale; + if (!reentryKey.equals(reentryProtection)) { + reentryProtection = reentryKey; + Log.d(TAG, String.format("Activity Locale %s, Selected locale %s, restarting", currentActivityLocale, selectedLocale)); + activity.recreate(); + } else { + Log.d(TAG, String.format("Skipping recreate as looks like looping, Activity Locale %s, Selected locale %s", currentActivityLocale, selectedLocale)); + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/DynamicLanguageContextWrapper.java b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/DynamicLanguageContextWrapper.java new file mode 100644 index 0000000000..8350ee4f5f --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/DynamicLanguageContextWrapper.java @@ -0,0 +1,34 @@ +package org.session.libsession.utilities.dynamiclanguage; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; + +import java.util.Locale; + +/** + * Updates a context with an alternative language. + */ +public final class DynamicLanguageContextWrapper { + + public static Context updateContext(Context context, String language) { + final Locale newLocale = LocaleParser.findBestMatchingLocaleForLanguage(language); + + Locale.setDefault(newLocale); + + final Resources resources = context.getResources(); + final Configuration config = resources.getConfiguration(); + final Configuration newConfig = copyWithNewLocale(config, newLocale); + + resources.updateConfiguration(newConfig, resources.getDisplayMetrics()); + + return context; + } + + private static Configuration copyWithNewLocale(Configuration config, Locale locale) { + final Configuration copy = new Configuration(config); + copy.setLocale(locale); + return copy; + } + +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LanguageString.java b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LanguageString.java new file mode 100644 index 0000000000..86860c4a42 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LanguageString.java @@ -0,0 +1,48 @@ +package org.session.libsession.utilities.dynamiclanguage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Locale; + +public final class LanguageString { + + private LanguageString() { + } + + /** + * @param languageString String in format language_REGION, e.g. en_US + * @return Locale, or null if cannot parse + */ + @Nullable + public static Locale parseLocale(@Nullable String languageString) { + if (languageString == null || languageString.isEmpty()) { + return null; + } + + final Locale locale = createLocale(languageString); + + if (!isValid(locale)) { + return null; + } else { + return locale; + } + } + + private static Locale createLocale(@NonNull String languageString) { + final String language[] = languageString.split("_"); + if (language.length == 2) { + return new Locale(language[0], language[1]); + } else { + return new Locale(language[0]); + } + } + + private static boolean isValid(@NonNull Locale locale) { + try { + return locale.getISO3Language() != null && locale.getISO3Country() != null; + } catch (Exception ex) { + return false; + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LocaleParser.kt b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LocaleParser.kt new file mode 100644 index 0000000000..6653903c97 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LocaleParser.kt @@ -0,0 +1,28 @@ +package org.session.libsession.utilities.dynamiclanguage + +import java.util.* + +class LocaleParser(val helper: LocaleParserHelperProtocol) { + companion object { + lateinit var shared: LocaleParser + + fun configure(helper: LocaleParserHelperProtocol) { + if (Companion::shared.isInitialized) { return } + shared = LocaleParser(helper) + } + + /** + * Given a language, gets the best choice from the apps list of supported languages and the + * Systems set of languages. + */ + @JvmStatic + fun findBestMatchingLocaleForLanguage(language: String?): Locale? { + val locale = LanguageString.parseLocale(language) + return if (shared.helper.appSupportsTheExactLocale(locale)) { + locale + } else { + shared.helper.findBestSystemLocale() + } + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LocaleParserHelperProtocol.kt b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LocaleParserHelperProtocol.kt new file mode 100644 index 0000000000..8d13acf049 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/dynamiclanguage/LocaleParserHelperProtocol.kt @@ -0,0 +1,8 @@ +package org.session.libsession.utilities.dynamiclanguage + +import java.util.Locale + +interface LocaleParserHelperProtocol { + fun appSupportsTheExactLocale(locale: Locale?): Boolean + fun findBestSystemLocale(): Locale +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/views/Stub.java b/libsession/src/main/java/org/session/libsession/utilities/views/Stub.java new file mode 100644 index 0000000000..a8ab3866e1 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/views/Stub.java @@ -0,0 +1,30 @@ +package org.session.libsession.utilities.views; + + +import android.view.ViewStub; + +import androidx.annotation.NonNull; + +public class Stub { + + private ViewStub viewStub; + private T view; + + public Stub(@NonNull ViewStub viewStub) { + this.viewStub = viewStub; + } + + public T get() { + if (view == null) { + view = (T)viewStub.inflate(); + viewStub = null; + } + + return view; + } + + public boolean resolved() { + return view != null; + } + +} diff --git a/libsession/src/main/res/drawable/avatar_gradient_dark.xml b/libsession/src/main/res/drawable/avatar_gradient_dark.xml new file mode 100644 index 0000000000..468abd48a7 --- /dev/null +++ b/libsession/src/main/res/drawable/avatar_gradient_dark.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/libsession/src/main/res/drawable/avatar_gradient_light.xml b/libsession/src/main/res/drawable/avatar_gradient_light.xml new file mode 100644 index 0000000000..bb0a83db30 --- /dev/null +++ b/libsession/src/main/res/drawable/avatar_gradient_light.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/libsession/src/main/res/drawable/ic_person_large.xml b/libsession/src/main/res/drawable/ic_person_large.xml new file mode 100644 index 0000000000..c3324be1de --- /dev/null +++ b/libsession/src/main/res/drawable/ic_person_large.xml @@ -0,0 +1,4 @@ + + + diff --git a/libsession/src/main/res/values/arrays.xml b/libsession/src/main/res/values/arrays.xml new file mode 100644 index 0000000000..6e75269b4c --- /dev/null +++ b/libsession/src/main/res/values/arrays.xml @@ -0,0 +1,311 @@ + + + + + @string/preferences__default + English + Arabic العربية + Azərbaycan + Bulgarian български + Burmese ဗမာစကား + Català + Čeština + Chinese (Simplified) 中文 (简体) + Chinese (Traditional) 中文 (繁體) + Cymraeg + Dansk + Deutsch + Eesti + Español + Esperanto + Euskara + Français + Gaeilge + Galego + Greek ελληνικά + Hebrew עברית + Hindi हिंदी + Hrvatski + Indonesia + Italiano + Japanese 日本語 + Khmer ភាសាខ្មែរ + Kiswahili + Korean 한국어 + Kurdí + Lietuvių + Luganda + Magyar + Macedonian македонски јазик + Nederlands + Norsk (bokmål) + Norsk (nynorsk) + Persian فارسی + Polski + Português + Português do Brasil + Quechua + Română + Russian Pусский + Serbian српски + Shqip + Slovenščina + Slovenský + Suomi + Svenska + Telugu తెలుగు + Thai ภาษาไทย + Türkçe + Ukrainian Українська + Vietnamese Tiếng Việt + + + + zz + en + ar + az + bg + my + ca + cs + zh_CN + zh_TW + cy + da + de + et + es + eo + eu + fr + ga + gl + el + iw + hi + hr + in + it + ja + km + sw + ko + ku + lt + lg + hu + mk + nl + nb + nn + fa + pl + pt + pt_BR + qu_EC + ro + ru + sr + sq + sl + sk + fi + sv + te + th + tr + uk + vi + + + + @string/preferences__light_theme + @string/preferences__dark_theme + + + + light + dark + + + + @string/preferences__green + @string/preferences__red + @string/preferences__blue + @string/preferences__orange + @string/preferences__cyan + @string/preferences__magenta + @string/preferences__white + @string/preferences__none + + + + + green + red + blue + yellow + cyan + magenta + white + none + + + + @string/preferences__fast + @string/preferences__normal + @string/preferences__slow + + + + 300,300 + 500,2000 + 3000,3000 + + + + @string/preferences__never + @string/preferences__one_time + @string/preferences__two_times + @string/preferences__three_times + @string/preferences__five_times + @string/preferences__ten_times + + + + 0 + 1 + 2 + 3 + 5 + 10 + + + + default + custom + + + + @string/arrays__use_default + @string/arrays__use_custom + + + + @string/arrays__mute_for_one_hour + @string/arrays__mute_for_two_hours + @string/arrays__mute_for_one_day + @string/arrays__mute_for_seven_days + @string/arrays__mute_for_one_year + + + + @string/arrays__settings_default + @string/arrays__enabled + @string/arrays__disabled + + + + 0 + 1 + 2 + + + + @string/arrays__name_and_message + @string/arrays__name_only + @string/arrays__no_name_or_message + + + + all + contact + none + + + + + image + audio + video + documents + + + + @string/arrays__images + @string/arrays__audio + @string/arrays__video + @string/arrays__documents + + + + image + audio + + + + image + audio + video + documents + + + + + + + + 0 + 5 + 10 + 30 + 60 + 300 + 1800 + 3600 + 21600 + 43200 + 86400 + 604800 + + + + #ffffff + #ff0000 + #ff00ff + #0000ff + #00ffff + #00ff00 + #ffff00 + #ff5500 + #000000 + + + + @string/arrays__small + @string/arrays__normal + @string/arrays__large + @string/arrays__extra_large + + + + 13 + 16 + 20 + 30 + + + + @string/arrays__default + @string/arrays__high + @string/arrays__max + + + + 0 + 1 + 2 + + + diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..f21ca1c700 --- /dev/null +++ b/libsession/src/main/res/values/attrs.xml @@ -0,0 +1,301 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libsession/src/main/res/values/colors.xml b/libsession/src/main/res/values/colors.xml new file mode 100644 index 0000000000..ecbcd77ec7 --- /dev/null +++ b/libsession/src/main/res/values/colors.xml @@ -0,0 +1,120 @@ + + + + + #00F782 + #8000F782 + #FFFFFF + #FF453A + #D8D8D8 + #23FFFFFF + #353535 + #979797 + #1B1B1B + #0C0C0C + #171717 + #121212 + #36383C + #323232 + #101011 + #212121 + @color/unimportant_button_background + #3F4146 + #000000 + #333132 + #0AFFFFFF + #1B1B1B + #141414 + #80FFFFFF + #1F1F1F + #077C44 + #1B1B1B + #212121 + #FFCE3A + + + #5ff8b0 + #26cdb9 + #f3c615 + #fcac5a + + + + + #78be20 + #419B41 + #0a0a0a + + + @color/accent + @color/accent + #882090ea + + @color/signal_primary + @color/signal_primary_dark + + #ffffffff + #ff000000 + #ffeeeeee + #ffdddddd + #ffe0e0e0 + #ffababab + #ffcccccc + #ffbbbbbb + #ff808080 + #ff595959 + #ff4d4d4d + #ff383838 + #ff111111 + + #05000000 + #10000000 + #20000000 + #30000000 + #40000000 + #70000000 + #90000000 + + #05ffffff + #10ffffff + #20ffffff + #30ffffff + #40ffffff + #60ffffff + #70ffffff + #aaffffff + #bbffffff + #ddffffff + + #32000000 + + @color/gray65 + #22000000 + + #ffffffff + #ff333333 + + #ffeeeeee + #ff333333 + #ffd5d5d5 + #ff222222 + #400099cc + #40ffffff + + @color/conversation_crimson + @color/core_blue + + #99ffffff + #00FFFFFF + #00000000 + + #88000000 + + #44ff2d00 + + @color/transparent_black_90 + + #121212 + #171717 + + diff --git a/libsession/src/main/res/values/conversation_colors.xml b/libsession/src/main/res/values/conversation_colors.xml new file mode 100644 index 0000000000..57d250ebb8 --- /dev/null +++ b/libsession/src/main/res/values/conversation_colors.xml @@ -0,0 +1,54 @@ + + + #cc163d + #eda6ae + #8a0f29 + + #c73800 + #eba78e + #872600 + + #756c53 + #c4b997 + #58513c + + #3b7845 + #8fcc9a + #2b5934 + + #1c8260 + #9bcfbd + #36544a + + #067589 + #a5cad5 + #055968 + + #336ba3 + #adc8e1 + #285480 + + #5951c8 + #c2c1e8 + #4840a0 + + #862caf + #cdaddc + #6b248a + + #a23474 + #dcb2ca + #881b5b + + #895d66 + #cfb5bb + #6a4e54 + + #6b6b78 + #bebec6 + #5a5a63 + + @color/textsecure_primary + @color/textsecure_primary + @color/textsecure_primary_dark + \ No newline at end of file diff --git a/libsession/src/main/res/values/core_colors.xml b/libsession/src/main/res/values/core_colors.xml new file mode 100644 index 0000000000..9622208f1f --- /dev/null +++ b/libsession/src/main/res/values/core_colors.xml @@ -0,0 +1,21 @@ + + + #5bca5b + #4caf50 + #f44336 + #ef5350 + + #ffffff + #000000 + + #f8f9f9 + #eeefef + #d5d6d6 + #bbbdbe + #898a8c + #6b6d70 + #3d3e44 + #23252a + #17191d + #0f1012 + \ No newline at end of file diff --git a/libsession/src/main/res/values/crop_area_renderer.xml b/libsession/src/main/res/values/crop_area_renderer.xml new file mode 100644 index 0000000000..953c9c04ca --- /dev/null +++ b/libsession/src/main/res/values/crop_area_renderer.xml @@ -0,0 +1,10 @@ + + + + 32dp + 2dp + + #ffffffff + #7f000000 + + \ No newline at end of file diff --git a/libsession/src/main/res/values/dimens.xml b/libsession/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..ec41c48aec --- /dev/null +++ b/libsession/src/main/res/values/dimens.xml @@ -0,0 +1,174 @@ + + + + + + + 12sp + 15sp + 17sp + 22sp + 26sp + 50sp + + + 34dp + 38dp + 22dp + 4dp + 26dp + 36dp + 46dp + 76dp + 14dp + 1dp + 1dp + 60dp + 72dp + 36dp + 8dp + 224dp + 10dp + 250dp + 56dp + 8dp + 4dp + 8dp + 8dp + 56dp + 8dp + 16dp + + + 8dp + 16dp + 24dp + 35dp + 64dp + 56dp + 40dp + + + + 32sp + 50dp + 220dp + 110dp + 170dp + 50dp + 5dp + 1.5dp + 5dp + 48dp + 16sp + 12sp + 200sp + 2dp + 2dp + 64dp + 50dp + + 210dp + 105dp + 104dp + 140dp + 139dp + 69dp + 210dp + 104dp + 175dp + 104dp + 69dp + + 10dp + 4dp + 2dp + 1.5dp + @dimen/medium_spacing + @dimen/medium_spacing + @dimen/small_spacing + 32dp + @dimen/medium_spacing + 24dp + 24dp + 210dp + 150dp + 240dp + 100dp + 320dp + 128dp + + 175dp + 85dp + + 5dp + 4dp + + 120dp + + 4dp + + 40dp + @dimen/large_spacing + @dimen/large_spacing + 60dp + 8dp + 1dp + + 36dp + + 10dp + 2dp + 16dp + + 3 + 10dp + + 52dp + + 8dp + 88dp + 8dp + 96dp + + 16dp + + 8dp + + 150dp + 70dp + 16dp + 10dp + + 13sp + 26sp + + + + + + + + 34sp + 20sp + + 2dp + + 16dp + + 14dp + + -96dp + + 16dp + 24dp + 16sp + 16dp + 56dp + + diff --git a/libsession/src/main/res/values/emoji.xml b/libsession/src/main/res/values/emoji.xml new file mode 100644 index 0000000000..045e125f3d --- /dev/null +++ b/libsession/src/main/res/values/emoji.xml @@ -0,0 +1,3 @@ + + + diff --git a/libsession/src/main/res/values/google-playstore-strings.xml b/libsession/src/main/res/values/google-playstore-strings.xml new file mode 100644 index 0000000000..4057c0298d --- /dev/null +++ b/libsession/src/main/res/values/google-playstore-strings.xml @@ -0,0 +1,36 @@ + + + + + + + TextSecure Private Messenger + TextSecure is a messaging app that allows you to take back your privacy while easily communicating with friends. + + Using TextSecure, you can communicate instantly while avoiding SMS fees, create groups so that you can chat in real time with all your friends at once, and share media or attachments all with complete privacy. The server never has access to any of your communication and never stores any of your data. + + * Private. TextSecure uses an advanced end to end encryption protocol that provides privacy for every message every time. + * Open Source. TextSecure is Free and Open Source, enabling anyone to verify its security by auditing the code. TextSecure is the only private messenger that uses open source peer-reviewed cryptographic protocols to keep your messages safe. + * Group Chat. TextSecure allows you to create encrypted groups so you can have private conversations with all your friends at once. Not only are the messages encrypted, but the TextSecure server never has access to any group metadata such as the membership list, group title, or group icon. + * Fast. The TextSecure protocol is designed to operate in the most constrained environment possible. Using TextSecure, messages are instantly delivered to friends. + + Please file any bugs, issues, or feature requests at: + https://github.com/signalapp/textsecure/issues + + More details: + http://www.whispersystems.org/#encrypted_texts + + + + diff --git a/libsession/src/main/res/values/ic_launcher_background.xml b/libsession/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..8e21cacbc4 --- /dev/null +++ b/libsession/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #333132 + \ No newline at end of file diff --git a/libsession/src/main/res/values/ids.xml b/libsession/src/main/res/values/ids.xml new file mode 100644 index 0000000000..cb9392f697 --- /dev/null +++ b/libsession/src/main/res/values/ids.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/libsession/src/main/res/values/integers.xml b/libsession/src/main/res/values/integers.xml new file mode 100644 index 0000000000..bb4c483c99 --- /dev/null +++ b/libsession/src/main/res/values/integers.xml @@ -0,0 +1,4 @@ + + + 300 + \ No newline at end of file diff --git a/libsession/src/main/res/values/material_colors.xml b/libsession/src/main/res/values/material_colors.xml new file mode 100644 index 0000000000..9f02548771 --- /dev/null +++ b/libsession/src/main/res/values/material_colors.xml @@ -0,0 +1,278 @@ + + + #FFEBEE + #FFCDD2 + #EF9A9A + #E57373 + #EF5350 + #F44336 + #E53935 + #D32F2F + #C62828 + #B71C1C + #FF8A80 + #FF5252 + #FF1744 + #D50000 + + #EDE7F6 + #D1C4E9 + #B39DDB + #9575CD + #7E57C2 + #673AB7 + #5E35B1 + #512DA8 + #4527A0 + #311B92 + #B388FF + #7C4DFF + #651FFF + #6200EA + + #E1F5FE + #B3E5FC + #81D4FA + #4FC3F7 + #29B6F6 + #03A9F4 + #039BE5 + #0288D1 + #0277BD + #01579B + #80D8FF + #40C4FF + #00B0FF + #0091EA + + #E8F5E9 + #C8E6C9 + #A5D6A7 + #81C784 + #66BB6A + #4CAF50 + #43A047 + #388E3C + #2E7D32 + #1B5E20 + #B9F6CA + #69F0AE + #00E676 + #00C853 + + #FFFDE7 + #FFF9C4 + #FFF59D + #FFF176 + #FFEE58 + #FFEB3B + #FDD835 + #FBC02D + #F9A825 + #F57F17 + #FFFF8D + #FFFF00 + #FFEA00 + #FFD600 + + #FBE9E7 + #FFCCBC + #FFAB91 + #FF8A65 + #FF7043 + #FF5722 + #F4511E + #E64A19 + #D84315 + #BF360C + #FF9E80 + #FF6E40 + #FF3D00 + #DD2C00 + + #ECEFF1 + #CFD8DC + #B0BEC5 + #90A4AE + #78909C + #607D8B + #546E7A + #455A64 + #37474F + #263238 + + #FCE4EC + #F8BBD0 + #F48FB1 + #F06292 + #EC407A + #E91E63 + #D81B60 + #C2185B + #AD1457 + #880E4F + #FF80AB + #FF4081 + #F50057 + #C51162 + + #E8EAF6 + #C5CAE9 + #9FA8DA + #7986CB + #5C6BC0 + #3F51B5 + #3949AB + #303F9F + #283593 + #1A237E + #8C9EFF + #536DFE + #3D5AFE + #304FFE + + #E0F7FA + #B2EBF2 + #80DEEA + #4DD0E1 + #26C6DA + #00BCD4 + #00ACC1 + #0097A7 + #00838F + #006064 + #84FFFF + #18FFFF + #00E5FF + #00B8D4 + + #F1F8E9 + #DCEDC8 + #C5E1A5 + #AED581 + #9CCC65 + #8BC34A + #7CB342 + #689F38 + #558B2F + #33691E + #CCFF90 + #B2FF59 + #76FF03 + #64DD17 + + #FFF8E1 + #FFECB3 + #FFE082 + #FFD54F + #FFCA28 + #FFC107 + #FFB300 + #FFA000 + #FF8F00 + #FF6F00 + #FFE57F + #FFD740 + #FFC400 + #FFAB00 + + #EFEBE9 + #D7CCC8 + #BCAAA4 + #A1887F + #8D6E63 + #795548 + #6D4C41 + #5D4037 + #4E342E + #3E2723 + + #F3E5F5 + #E1BEE7 + #CE93D8 + #BA68C8 + #AB47BC + #9C27B0 + #8E24AA + #7B1FA2 + #6A1B9A + #4A148C + #EA80FC + #E040FB + #D500F9 + #AA00FF + + #E3F2FD + #BBDEFB + #90CAF9 + #64B5F6 + #42A5F5 + #2196F3 + #1E88E5 + #1976D2 + #1565C0 + #0D47A1 + #82B1FF + #448AFF + #2979FF + #2962FF + + #E0F2F1 + #B2DFDB + #80CBC4 + #4DB6AC + #26A69A + #009688 + #00897B + #00796B + #00695C + #004D40 + #A7FFEB + #64FFDA + #1DE9B6 + #00BFA5 + + #F9FBE7 + #F0F4C3 + #E6EE9C + #DCE775 + #D4E157 + #CDDC39 + #C0CA33 + #AFB42B + #9E9D24 + #827717 + #F4FF81 + #EEFF41 + #C6FF00 + #AEEA00 + + #FFF3E0 + #FFE0B2 + #FFCC80 + #FFB74D + #FFA726 + #FF9800 + #FB8C00 + #F57C00 + #EF6C00 + #E65100 + #FFD180 + #FFAB40 + #FF9100 + #FF6D00 + + #FAFAFA + #F5F5F5 + #EEEEEE + #E0E0E0 + #BDBDBD + #9E9E9E + #757575 + #616161 + #424242 + #212121 + + #44BDBDBD + + \ No newline at end of file diff --git a/libsession/src/main/res/values/strings.xml b/libsession/src/main/res/values/strings.xml new file mode 100644 index 0000000000..df0d4b07f3 --- /dev/null +++ b/libsession/src/main/res/values/strings.xml @@ -0,0 +1,1878 @@ + + + Session + https://getsession.org/ + Yes + No + Delete + Please wait... + Save + Note to Self + Version %s + + + New message + + + \+%d + + + Currently: %s + You haven\'t set a passphrase yet! + + %d message per conversation + %d messages per conversation + + Delete all old messages now? + + This will immediately trim all conversations to the most recent message. + This will immediately trim all conversations to the %d most recent messages. + + Delete + Disable passphrase? + This will permanently unlock Session and message notifications. + Disable + Unregistering + Unregistering from Session messages and calls... + Disable Session messages and calls? + Disable Session messages and calls by unregistering from the server. You will need to re-register your phone number to use them again in the future. + Error connecting to server! + SMS Enabled + Touch to change your default SMS app + SMS Disabled + Touch to make Session your default SMS app + on + On + off + Off + SMS %1$s, MMS %2$s + Screen lock %1$s, Registration lock %2$s + Theme %1$s, Language %2$s + + + + %d minute + %d minutes + + + + (image) + (audio) + (video) + (location) + (reply) + + + Can\'t find an app to select media. + Session requires the Storage permission in order to attach photos, videos, or audio, but it has been permanently denied. Please continue to the app settings menu, select \"Permissions\", and enable \"Storage\". + Session requires Contacts permission in order to attach contact information, but it has been permanently denied. Please continue to the app settings menu, select \"Permissions\", and enable \"Contacts\". + Session requires Location permission in order to attach a location, but it has been permanently denied. Please continue to the app settings menu, select \"Permissions\", and enable \"Location\". + Session requires the Camera permission in order to take photos, but it has been permanently denied. Please continue to the app settings menu, select \"Permissions\", and enable \"Camera\". + + + Error playing audio! + + + Blocked contacts + + + Today + Yesterday + This week + This month + + + Incoming call + + + Failed to save image. + + + Remove + Remove profile photo? + + + No web browser found. + A cellular call is already in progress. + + + Your safety number with %1$s has changed. This could either mean that someone is trying to intercept your communication, or that %2$s simply reinstalled Session. + You may wish to verify your safety number with this contact. + Accept + + + Recent chats + Contacts + Groups + + + Message %s + Session Call %s + + + Given name + Family name + Prefix + Suffix + Middle name + + + Home + Mobile + Work + Other + Selected contact was invalid + + + Send failed, tap for details + Received key exchange message, tap to process. + %1$s has left the group. + Send failed, tap for unsecured fallback + Fallback to unencrypted SMS? + Fallback to unencrypted MMS? + This message will not be encrypted because the recipient is no longer a Session user.\n\nSend unsecured message? + Can\'t find an app able to open this media. + Copied %s + from %s + to %s +   Read More +   Download More +   Pending + + + Reset secure session? + This may help if you\'re having encryption problems in this conversation. Your messages will be kept. + Reset + Add attachment + Select contact info + Compose message + Sorry, there was an error setting your attachment. + Recipient is not a valid SMS or email address! + Message is empty! + Group members + + Invalid recipient! + Added to home screen + Calls not supported + This device does not appear to support dial actions. + Leave group? + Are you sure you want to leave this group? + Insecure SMS + Insecure MMS + Session + Let\'s switch to Session %1$s + Error leaving group + Please choose a contact + Unblock this contact? + Unblock this group? + You will once again be able to receive messages and calls from this contact. + Existing members will be able to add you to the group again. + Unblock + Attachment exceeds size limits for the type of message you\'re sending. + Camera unavailable + Unable to record audio! + There is no app available to handle this link on your device. + + Session needs microphone access to send audio messages. + Session needs microphone access to send audio messages, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\". + Session needs microphone and camera access to make calls. + Session needs microphone and camera access to call %s, but they have been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\" and \"Camera\". + Session needs camera access to take photos and videos. + Session needs camera access to take photos and videos, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Camera\". + Session needs camera access to take photos or videos. + + %1$s %2$s + Session cannot send SMS/MMS messages because it is not your default SMS app. Would you like to change this in your Android settings? + Yes + No + %1$d of %2$d + No results + + Sticker pack installed + New! Say it with stickers + + + + %d unread message + %d unread messages + + + + + Delete selected message? + Delete selected messages? + + + This will permanently delete the selected message. + This will permanently delete all %1$d selected messages. + + Save to storage? + + Saving this media to storage will allow any other apps on your device to access it.\n\nContinue? + Saving all %1$d media to storage will allow any other apps on your device to access them.\n\nContinue? + + + Error while saving attachment to storage! + Error while saving attachments to storage! + + Unable to write to storage! + + Saving attachment + Saving %1$d attachments + + + Saving attachment to storage... + Saving %1$d attachments to storage... + + Pending... + Data (Session) + MMS + SMS + Deleting + Deleting messages... + Original message not found + Original message no longer available + + + There is no browser installed on your device. + + + No results found for \'%s\' + + + Delete selected conversation? + Delete selected conversations? + + + This will permanently delete the selected conversation. + This will permanently delete all %1$d selected conversations. + + Deleting + Deleting selected conversations... + + Conversation archived + %d conversations archived + + UNDO + + Moved conversation to inbox + Moved %d conversations to inbox + + + + Key exchange message + + + Archived conversations (%d) + + + Your profile info + Error setting profile photo + Problem setting profile + Profile photo + Too long + Profile Name + Set up your profile + Session profiles are end-to-end encrypted, and the Session service never has access to this information. + + + Using custom: %s + Using default: %s + None + + + Now + %d min + Today + Yesterday + + + Sending + Sent + Delivered + Read + + + Unlink \'%s\'? + This device will no longer be able to send or receive messages. + Network connection failed + Try again + Unlinking device... + Unlinking device + Network failed! + Successfully unlinked device + Edit device name + + + Unnamed device + Linked %s + Last active %s + Today + + + Unknown file + + + Optimize for missing Play Services + This device does not support Play Services. Tap to disable system battery optimizations that prevent Session from retrieving messages while inactive. + + + Share with + + + Welcome to Session. + TextSecure and RedPhone are now one private messenger, for every situation: Session. + Welcome to Session! + TextSecure is now Session. + TextSecure and RedPhone are now one app: Session. Tap to explore. + + Say hello to secure video calls. + Session now supports secure video calling. Just start a Session call like normal, tap the video button, and wave hello. + Session now supports secure video calling. + Session now supports secure video calling. Tap to explore. + + Ready for your closeup? + Now you can share a profile photo and name with friends on Session + Session profiles are here + + Introducing typing indicators. + Now you can optionally see and share when messages are being typed. + Would you like to enable them now? + Typing indicators are here + Enable typing indicators + Turn on typing indicators + No thanks + + Introducing link previews. + Optional link previews are now supported for some of the most popular sites on the Internet. + You can disable or enable this feature anytime in your Session settings (Privacy > Send link previews). + Got it + + + Retrieving a message... + + + Permanent Session communication failure! + Session was unable to register with Google Play Services. Session messages and calls have been disabled, please try re-registering in Settings > Advanced. + + + + Error while retrieving full resolution GIF + + + GIFs + Stickers + + + New group + Edit group + Group name + New MMS group + You have selected a contact that doesn\'t support Session groups, so this group will be MMS. + You\'re not registered for Session messages and calls, so Session groups are disabled. Please try registering in Settings > Advanced. + You need at least one person in your group! + One of the members of your group has a number that can\'t be read correctly. Please fix or remove that contact and try again. + Group avatar + Apply + Creating %1$s… + Updating %1$s... + Couldn\'t add %1$s because they\'re not a Session user. + Loading group details... + You\'re already in the group. + + + Share your profile name and photo with this group? + Do you want to make your profile name and photo visible to all current and future members of this group? + Make visible + + + Me + + + Group Photo + Photo + + + Tap and hold to record a voice message, release to send + + + Share + Choose contacts + Cancel + Sending... + Heart + Invitations sent! + Invite to Session + + SEND SMS TO %d FRIEND + SEND SMS TO %d FRIENDS + + + Send %d SMS invite? + Send %d SMS invites? + + Let\'s switch to Session: %1$s + It looks like you don\'t have any apps to share to. + Friends don\'t let friends chat unencrypted. + + + Working in the background... + + + Failed to send + New safety number + + + Unable to find message + Message from %1$s + Your message + + + Session + Background connection enabled + + + Error reading wireless provider MMS settings + + + Media + + Delete selected message? + Delete selected messages? + + + This will permanently delete the selected message. + This will permanently delete all %1$d selected messages. + + Deleting + Deleting messages... + Documents + Select all + Collecting attachments... + + + Session call in progress + Establishing Session call + Incoming Session call + Deny call + Answer call + End call + Cancel call + + + Multimedia message + Downloading MMS message + Error downloading MMS message, tap to retry + + + Send to %s + + + Tap to select + + + Add a caption... + An item was removed because it exceeded the size limit + Camera unavailable. + Message to %s + + You can\'t share more than %d item. + You can\'t share more than %d items. + + + + All media + + + Received a message encrypted using an old version of Session that is no longer supported. Please ask the sender to update to the most recent version and resend the message. + You have left the group. + You updated the group. + You called + Contact called + Missed call + %s updated the group. + %s called you + Called %s + Missed call from %s + %s is on Session! + You disabled disappearing messages. + %1$s disabled disappearing messages. + You set the disappearing message timer to %1$s + %1$s set the disappearing message timer to %2$s + Your safety number with %s has changed. + You marked your safety number with %s verified + You marked your safety number with %s verified from another device + You marked your safety number with %s unverified + You marked your safety number with %s unverified from another device + + + Passphrases don\'t match! + Incorrect old passphrase! + Enter new passphrase! + + + Link this device? + CANCEL + CONTINUE + + It will be able to + + • Read all your messages + \n• Send messages in your name + + Linking device + Linking new device... + Device approved! + No device found. + Network error. + Invalid QR code. + Sorry, you have too many devices linked already, try removing some + Sorry, this is not a valid device link QR code. + Link a Session device? + It looks like you\'re trying to link a Session device using a 3rd party scanner. For your protection, please scan the code again from within Session. + + Session needs the Camera permission in order to scan a QR code, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Camera\". + Unable to scan a QR code without the Camera permission + + + Disappearing messages + Your messages will not expire. + Messages sent and received in this conversation will disappear %s after they have been seen. + + + Enter passphrase + Session icon + Submit passphrase + Invalid passphrase! + + + The version of Google Play Services you have installed is not functioning correctly. Please reinstall Google Play Services and try again. + + + Rate this app + If you enjoy using this app, please take a moment to help us by rating it. + Rate now! + No thanks + Later + Whoops, the Play Store app does not appear to be installed on your device. + + + Block this contact? + You will no longer receive messages and calls from this contact. + Block and leave this group? + Block this group? + You will no longer receive messages or updates from this group. + Block + Unblock this contact? + You will once again be able to receive messages and calls from this contact. + Unblock this group? + Existing members will be able to add you to the group again. + Error leaving group + Unblock + Enabled + Disabled + Available once a message has been sent or received. + + + Unnamed group + + + Answering + Ending call + Dialing + Ringing + Busy + Connected + Recipient unavailable + Network failed! + Number not registered! + The number you dialed does not support secure voice! + Got it + + + Select your country + You must specify your + country code + + You must specify your + phone number + + Invalid number + The number you + specified (%s) is invalid. + + Missing Google Play Services + This device is missing Google Play Services. You can still use Session, but this configuration may result in reduced reliability or performance.\n\nIf you are not an advanced user, are not running an aftermarket Android ROM, or believe that you are seeing this in error, please contact support@signal.org for help troubleshooting. + I understand + Play Services Error + Google Play Services is updating or temporarily unavailable. Please try again. + Terms & Privacy Policy + Unable to open this link. No web browser found. + More information + Less information + Session needs access to your contacts and media in order to connect with friends, exchange messages, and make secure calls + Unable to connect to service. Please check network connection and try again. + To easily verify your phone number, Session can automatically detect your verification code if you allow Session to view SMS messages. + + You are now %d step away from submitting a debug log. + You are now %d steps away from submitting a debug log. + + We need to verify that you\'re human. + Failed to verify the CAPTCHA + Next + Continue + Take privacy with you.\nBe yourself in every message. + Enter your phone number to get started + You will receive a verification code. Carrier rates may apply. + Enter the code we sent to %s + Call + + + Failed to save image changes + + + No results found for \'%s\' + Conversations + Contacts + Messages + + + Add to Contacts + Invite to Session + Session Message + Session Call + + + Add to Contacts + Invite to Session + Session Message + + + Image + Sticker + Audio + Video + + + Received corrupted key + exchange message! + + + Received key exchange message for invalid protocol version. + + Received message with new safety number. Tap to process and display. + You reset the secure session. + %s reset the secure session. + Duplicate message. + + + Stickers + + + Installed Stickers + Stickers You Received + Session Artist Series + No stickers installed + Stickers from incoming messages will appear here + Untitled + Unknown + + + Untitled + Unknown + Install + Remove + Stickers + Failed to load sticker pack + + + Group updated + Left the group + Secure session reset. + Draft: + You called + Called you + Missed call + Media message + %s is on Session! + Disappearing messages disabled + Disappearing message time set to %s + Safety number changed + Your safety number with %s has changed. + You marked verified + You marked unverified + + + Session update + A new version of Session is available, tap to update + + + Block %s? + Blocked contacts will no longer be able to send you messages or call you. + Block + Share profile with %s? + The easiest way to share your profile information is to add the sender to your contacts. If you do not wish to, you can still share your profile information this way. + Share profile + + + Send message? + Send + + + Send message? + Send + + + Your contact is running an old version of Session. Please ask them to update before verifying your safety number. + Your contact is running a newer version of Session with an incompatible QR code format. Please update to compare. + The scanned QR code is not a correctly formatted safety number verification code. Please try scanning again. + Share safety number via... + Our Session safety number: + It looks like you don\'t have any apps to share to. + No safety number to compare was found in the clipboard + Session needs the Camera permission in order to scan a QR code, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Camera\". + Unable to scan QR code without Camera permission + + + Bad encrypted message + Message encrypted for non-existing session + You have sent a session restoration request to %s + + + Bad encrypted MMS message + MMS message encrypted for non-existing session + + + Mute notifications + + + No web browser installed! + + + Import in progress + Importing text messages + Import complete + System database import is complete. + + + Touch to open. + Touch to open, or touch the lock to close. + Session is unlocked + Lock Session + + + You + Unsupported media type + Draft + Session needs the Storage permission in order to save to external storage, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Storage\". + Unable to save to external storage without permissions + Delete message? + This will permanently delete this message. + + + %1$d new messages in %2$d conversations + Most recent from: %1$s + Locked message + Media message: %s + Message delivery failed. + Failed to deliver message. + Error delivering message. + Mark all as read + Mark read + Media message + Sticker + Reply + Session Message + Unsecured SMS + Pending Session messages + You have pending Session messages, tap to open and retrieve + %1$s %2$s + Contact + + + Default + Calls + Failures + Backups + Lock status + App updates + Other + Messages + Unknown + + + Quick response unavailable when Session is locked! + Problem sending message! + + + Saved to %s + Saved + + + Search + Search for conversations, contacts, and messages + + + Invalid shortcut + + + Session + New message + + + + %d Item + %d Items + + + + Device no longer registered + This is likely because you registered your phone number with Session on a different device. Tap to re-register. + + + Error playing video + + + To answer the call from %s, give Session access to your microphone. + Session requires Microphone and Camera permissions in order to make or receive calls, but they have been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\" and \"Camera\". + + + The safety number for your conversation with %1$s has changed. This could either mean that someone is trying to intercept your communication, or that %2$s simply re-installed Session. + You may wish to verify your safety number with this contact. + New safety number + Accept + End call + + + Tap to enable your video + + + Audio + Audio + Contact + Contact + Camera + Camera + Location + Location + GIF + Gif + Image or video + File + Gallery + File + + Toggle attachment drawer + + + Old passphrase + New passphrase + Repeat new passphrase + + + Enter name or number + + + No contacts. + Loading contacts… + + + Contact Photo + + + Session requires the Contacts permission in order to display your contacts, but it has been permanently denied. Please continue to the app settings menu, select \"Permissions\", and enable \"Contacts\". + Error retrieving contacts, check your network connection + + + No blocked contacts + + + Session needs access to your contacts in order to display them. + Show Contacts + + + Session message + Unsecured SMS + Unsecured MMS + From %1$s + SIM %1$d + Send + Message composition + Toggle emoji keyboard + Attachment Thumbnail + Toggle quick camera attachment drawer + Record and send audio attachment + Lock recording of audio attachment + Enable Session for SMS + + + Slide to cancel + Cancel + + + Media message + Secure message + + + Send Failed + Pending Approval + Delivered + Message read + + + Contact photo + + + Play + Pause + Download + + + Audio + Video + Photo + Sticker + Document + You + Original message not found + + + Scroll to the bottom + + + Loading countries... + Search + + + Scan the QR code displayed on the device to link + + + Link device + + + You don\'t have any linked devices yet + Link new device + + + continue + + Read receipts are here + Optionally see and share when messages have been read + Enable read receipts + + + Off + + + %d second + %d seconds + + + %ds + + + %d minute + %d minutes + + + %dm + + + %d hour + %d hours + + + %dh + + + %d day + %d days + + + %dd + + + %d week + %d weeks + + + %dw + + + Your safety number with %s has changed and is no longer verified + Your safety numbers with %1$s and %2$s are no longer verified + Your safety numbers with %1$s, %2$s, and %3$s are no longer verified + + Your safety number with %1$s has changed and is no longer verified. This could either mean that someone is trying to intercept your communication, or that %1$s simply reinstalled Session. + Your safety numbers with %1$s and %2$s are no longer verified. This could either mean that someone is trying to intercept your communication, or that they simply reinstalled Session. + Your safety numbers with %1$s, %2$s, and %3$s are no longer verified. This could either mean that someone is trying to intercept your communication, or that they simply reinstalled Session. + + Your safety number with %s just changed. + Your safety numbers with %1$s and %2$s just changed. + Your safety numbers with %1$s, %2$s, and %3$s just changed. + + + %d other + %d others + + + + Search GIFs and stickers + + + Nothing found + + + Could not read the log on your device. You can still use ADB to get a debug log instead. + Thanks for your help! + Submitting + No browser installed + Don\'t submit + Submit + Got it + Compose email + This log will be posted publicly online for contributors to view, you may examine and edit it before submitting. + Loading logs… + Uploading logs… + Success! + Copy this URL and add it to your issue report or support email:\n\n%1$s\n + Copied to clipboard + Choose email app + Please review this log from my app: %1$s + Network failure. Please try again. + + + Would you like to import your existing text messages into Session\'s encrypted database? + The default system database will not be modified or altered in any way. + Skip + Import + This could take a moment. Please be patient, we\'ll notify you when the import is complete. + IMPORTING + + + Updating database... + + Import system SMS database + Import the database from the default system messenger app + Import plaintext backup + Import a plaintext backup file. Compatible with \'SMS Backup & Restore.\' + + + See full conversation + Loading + + + No media + + + VIEW + RESEND + Resending... + + + + %1$s joined the group. + %1$s joined the group. + + + %1$s was removed from the group. + %1$s were removed from the group. + + Group name is now \'%1$s\'. + You were removed from the group. + + + Make your profile name and photo visible to this group? + + + Unlock + + + Session requires MMS settings to deliver media and group messages through your wireless carrier. Your device does not make this information available, which is occasionally true for locked devices and other restrictive configurations. + To send media and group messages, tap \'OK\' and complete the requested settings. The MMS settings for your carrier can generally be located by searching for \'your carrier APN\'. You will only need to do this once. + + + Set later + FINISH + Who can see this information? + Your name + + + Shared media + + + Mute conversation + Custom notifications + System notification settings + Notification sound + Vibrate + Block + Color + View safety number + Chat settings + Privacy + Call settings + Ringtone + + + Session Call + Mute + Switch Cameras + + + PHONE NUMBER + Session makes it easy to communicate by using your existing phone number and address book. Friends and contacts who already know how to contact you by phone will be able to easily get in touch by Session.\n\nRegistration transmits some contact information to the server. It is not stored. + Verify Your Number + Please enter your mobile number to receive a verification code. Carrier rates may apply. + + + Enter a name or number + Add members + + + The sender is not in your contact list + BLOCK + ADD TO CONTACTS + DON\'T ADD, BUT MAKE MY PROFILE VISIBLE + + + Learn more.]]> + Tap to scan + Loading... + Verified + + + Share safety number + + + Swipe up to answer + Swipe down to reject + + + Some issues need your attention. + Sent + Received + Disappears + Via + To: + From: + With: + + + Create passphrase + Select contacts + Change passphrase + Verify safety number + Submit debug log + Media preview + Message details + Linked Devices + Invite friends + Archived conversations + Remove photo + + + Import + Use default + Use custom + + Mute for 1 hour + Mute for 2 hours + Mute for 1 day + Mute for 7 days + Mute for 1 year + + Settings default + Enabled + Disabled + + Name and message + Name only + No name or message + + Images + Audio + Video + Documents + + Small + Normal + Large + Extra large + + Default + High + Max + + + + %d hour + %d hours + + + + SMS and MMS + Receive all SMS + Receive all MMS + Use Session for all incoming text messages + Use Session for all incoming multimedia messages + Enter key sends + Pressing the Enter key will send text messages + Send link previews + Previews are supported for Imgur, Instagram, Pinterest, Reddit, and YouTube links + Choose identity + Choose your contact entry from the contacts list. + Change passphrase + Change your passphrase + Enable passphrase screen lock + Lock screen and notifications with a passphrase + Screen security + Block screenshots in the recents list and inside the app + Auto-lock Session after a specified time interval of inactivity + Inactivity timeout passphrase + Inactivity timeout interval + Notifications + System notification settings + LED color + Unknown + LED blink pattern + Sound + Silent + Repeat alerts + Never + One time + Two times + Three times + Five times + Ten times + Vibrate + Green + Red + Blue + Orange + Cyan + Magenta + White + None + Fast + Normal + Slow + Advanced + Privacy + MMS User Agent + Manual MMS settings + MMSC URL + MMS Proxy Host + MMS Proxy Port + MMSC Username + MMSC Password + SMS delivery reports + Request a delivery report for each SMS message you send + Automatically delete older messages once a conversation exceeds a specified length + Delete old messages + Chats + Conversation length limit + Trim all conversations now + Scan through all conversations and enforce conversation length limits + Linked devices + Light + Dark + Appearance + Theme + Default + Language + Session messages and calls + Free private messages and calls to Session users + Submit debug log + \'WiFi Calling\' compatibility mode + Enable if your device uses SMS/MMS delivery over WiFi (only enable when \'WiFi Calling\' is enabled on your device) + Incognito keyboard + Read receipts + If read receipts are disabled, you won\'t be able to see read receipts from others. + Typing indicators + If typing indicators are disabled, you won\'t be able to see typing indicators from others. + Request keyboard to disable personalized learning + Blocked contacts + When using mobile data + When using Wi-Fi + When roaming + Media auto-download + Message Trimming + Use system emoji + Disable Session\'s built-in emoji support + Relay all calls through the Session server to avoid revealing your IP address to your contact. Enabling will reduce call quality. + Always relay calls + App Access + Communication + Chats + Messages + Events + In-chat sounds + Show + Calls + Ringtone + Show invitation prompts + Display invitation prompts for contacts without Session + Message font size + Contact joined Session + Priority + Sealed Sender + Display indicators + Show a status icon when you select "Message details" on messages that were delivered using sealed sender. + Allow from anyone + Enable sealed sender for incoming messages from non-contacts and people with whom you have not shared your profile. + Learn more + + + + + + + New message to... + + + Call + + + Session call + + + Message details + Copy text + Delete message + Forward message + Resend message + Reply to message + + + Save attachment + + + Disappearing messages + + + Messages expiring + + + Invite + + + Delete selected + Select all + Archive selected + Unarchive selected + + + + + Contact Photo Image + Archived + + Inbox zeeerrro + Zip. Zilch. Zero. Nada.\nYou\'re all caught up! + + + New conversation + Give your inbox something to write home about. Get started by messaging a friend. + + + Reset secure session + + + Unmute + + + Mute notifications + + + Add attachment + Edit group + Leave group + All media + Conversation settings + Add to home screen + + + Expand popup + + + Add to contacts + + + Recipients list + Delivery + Conversation + Broadcast + + + New group + Settings + Lock + Mark all read + Invite Contacts + Help + + + Copy to clipboard + Compare with clipboard + + + Your version of Session is outdated + + Your version of Session will expire in %d day. Tap to update to the most recent version. + Your version of Session will expire in %d days. Tap to update to the most recent version. + + Your version of Session will expire today. Tap to update to the most recent version. + Your version of Session has expired! + Messages will no longer send successfully. Tap to update to the most recent version. + Use as default SMS app + Tap to make Session your default SMS app. + Import system SMS + Tap to copy your phone\'s SMS messages into Session\'s encrypted database. + Enable Session messages and calls + Upgrade your communication experience. + Invite to Session + Take your conversation with %1$s to the next level. + Invite your friends! + The more friends use Session, the better it gets. + Session is experiencing technical difficulties. We are working hard to restore service as quickly as possible. + The latest Session features won\'t work on this version of Android. Please upgrade this device to receive future Session updates. + + + Save + Forward + All media + + + No documents + + + Media preview + + + Refresh + + + + Deleting + Deleting old messages... + Old messages successfully deleted + + + Transport icon + Loading... + Connecting... + Permission required + Session needs SMS permission in order to send an SMS, but it has been permanently denied. Please continue to app settings, select \"Permissions\" and enable \"SMS\". + Continue + Not now + Session needs Contacts permission in order to search your contacts, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Contacts\". + ENABLE SIGNAL MESSAGES + Migrating Session database + New locked message + Unlock to view pending messages + Unlock to complete update + Please unlock Session to complete update + Backup passphrase + Backups will be saved to external storage and encrypted with the passphrase below. You must have this passphrase in order to restore a backup. + I have written down this passphrase. Without it, I will be unable to restore a backup. + Restore backup + Skip + Register + Chat backups + Backup chats to external storage + Create backup + Enter backup passphrase + Restore + Cannot import backups from newer versions of Session + Incorrect backup passphrase + Checking... + %d messages so far... + Restore from backup? + Restore your messages and media from a local backup. If you don\'t restore now, you won\'t be able to restore later. + Backup size: %s + Backup timestamp: %s + Enable local backups? + Enable backups + Please acknowledge your understanding by marking the confirmation check box. + Delete backups? + Disable and delete all local backups? + Delete backups + Copied to clipboard + Session requires external storage permission in order to create backups, but it has been permanently denied. Please continue to app settings, select \"Permissions\" and enable \"Storage\". + Last backup: %s + In progress + Creating backup... + %d messages so far + Please enter the verification code sent to %s. + Wrong number + Call me instead \n (Available in %1$02d:%2$02d) + Never + Unknown + Screen lock + Lock Session access with Android screen lock or fingerprint + Screen lock inactivity timeout + None + The Registration Lock PIN is not the same as the SMS verification code you just received. Please enter the PIN you previously configured in the application. + Registration Lock PIN + Forgot PIN? + The PIN can consist of four or more digits. If you forget your PIN, you could be locked out of your account for up to seven days. + Enter PIN + Confirm PIN + Enter your Registration Lock PIN + Enter PIN + Enable a Registration Lock PIN that will be required to register this phone number with Session again. + Registration Lock PIN + Registration Lock + You must enter your Registration Lock PIN + Incorrect Registration Lock PIN + Too many attempts + You\'ve made too many incorrect Registration Lock PIN attempts. Please try again in a day. + Error connecting to service + Oh no! + Registration of this phone number will be possible without your Registration Lock PIN after 7 days have passed since this phone number was last active on Session. You have %d days remaining. + Registration lock PIN + This phone number has Registration Lock enabled. Please enter the Registration Lock PIN. + Registration Lock is enabled for your phone number. To help you memorize your Registration Lock PIN, Session will periodically ask you to confirm it. + I forgot my PIN. + Forgotten PIN? + Registration Lock helps protect your phone number from unauthorized registration attempts. This feature can be disabled at any time in your Session privacy settings + Registration Lock + Enable + The Registration Lock PIN must be at least 4 digits. + The two PINs you entered do not match. + Error connecting to the service + Disable Registration Lock PIN? + Disable + Backups + An error occurred during exporting a backup. Please try again later. + Session is Locked + TAP TO UNLOCK + Reminder: + About + + + + + + + + + Session + Some features of Session (such as automatic message backup) require storage access to work. + "Session is currently in beta. For development purposes the beta version collects basic usage statistics and crash logs. In addition, the beta version doesn't provide full privacy and shouldn\'t be used to transmit sensitive information." + Privacy Policy + + Create Your Session Account + Enter a name to be shown to your contacts + Display Name + Next + + Create Your Session Account + Please save the seed below in a safe location. It can be used to restore your account if you lose access, or to migrate to a new device. + Restore your account by entering your seed below + Link to an existing device by going into its in-app settings and clicking "Link Device". + Copy + Your Seed + Restore Using Seed + Register a New Account + Link Device + Copied to clipboard + Register + Restore + Link + Your Public Key + + Looks like you don\'t have any conversations yet. Get started by messaging a friend. + + Linked device (%s) + Copied to clipboard + Share Public Key + Show QR Code + Linked Devices + Show Seed + Your Seed + Copy + OK + Copied to clipboard + + Set Your Display Name + Your Display Name + Display Name + + Search by name or public key + + New Conversation + Public Key + Enter the public key of the person you\'d like to securely message. They can share their public key with you by going into Session\'s in-app settings and clicking \"Share Public Key\". + Scan a QR Code Instead + Next + Invalid public key + Please enter the public key of the person you\'d like to message + + Add Public Chat + URL + Enter the URL of the public chat you\'d like to join. The Loki Public Chat URL is https://chat.lokinet.org. + Add + Adding Server... + Invalid URL + Couldn\'t Connect + + Pending Friend Request… + New Message + + Your QR Code + This is your personal QR code. Other people can scan it to start a secure conversation with you. + Cancel + + Waiting for Device + Waiting for Authorization + Linking Request Received + Device Link Authorized + Create a new account on your other device and click \"Link Device\" when you\'re at the \"Create Your Session Account\" step to start the linking process + Please check that the words below match the ones shown on your other device + Your device has been linked successfully + Authorize + Cancel + + Scan QR Code + Scan the QR code of the person you\'d like to securely message. They can find their QR code by going into Session\'s in-app settings and clicking \"Show QR Code\". + Link to an existing device by going into its in-app settings and clicking \"Link Device\". + Session needs camera access to scan QR codes. + + Copy public key + + Add Public Chat + + Edit device name + Unlink device + + Device unlinked + This device has been successfully unlinked + + + + + + + + Continue + Copy + Invalid URL + Copied to clipboard + Couldn\'t link device. + Next + Share + Invalid Session ID + Cancel + Your Session ID + + Your Session begins here... + Create Session ID + Continue Your Session + Restore Backup + Link to an existing account + Your device was unlinked successfully + + What\'s Session? + It\'s a decentralized, encrypted messaging app + So it doesn\'t collect my personal information or my conversation metadata? How does it work? + Using a combination of advanced anonymous routing and end-to-end encryption technologies. + Friends don\'t let friends use compromised messengers. You\'re welcome. + + Say hello to your Session ID + Your Session ID is the unique address people can use to contact you on Session. With no connection to your real identity, your Session ID is totally anonymous and private by design. + Copied to clipboard + + Restore your account + Enter the recovery phrase that was given to you when you signed up to restore your account. + Enter your recovery phrase + + Link Device + Enter Session ID + Scan QR Code + Navigate to "Settings" > "Devices" > "Link a Device" on your other device and then scan the QR code that comes up to start the linking process. + + Link your device + Navigate to "Settings" > "Devices" > "Link a Device" on your other device and then enter your Session ID here to start the linking process. + Enter your Session ID + + Pick your display name + This will be your name when you use Session. It can be your real name, an alias, or anything else you like. + Enter a display name + Please pick a display name + Please pick a display name that consists of only a-z, A-Z, 0-9 and _ characters + Please pick a shorter display name + + Recommended + Please Pick an Option + + You don\'t have any contacts yet + Start a Session + Are you sure you want to leave this group? + "Couldn\'t leave group" + Are you sure you want to delete this conversation? + Conversation deleted + + Your Recovery Phrase + Meet your recovery phrase + Your recovery phrase is the master key to your Session ID — you can use it to restore your Session ID if you lose access to your device. Store your recovery phrase in a safe place, and don\'t give it to anyone. + Hold to reveal + + Secure your account by saving your recovery phrase + Tap and hold the redacted words to reveal your recovery phrase, then store it safely to secure your Session ID. + Make sure to store your recovery phrase in a safe place + + Path + Session hides your IP by bouncing your messages through several Service Nodes in Session\'s decentralized network. These are the countries your connection is currently being bounced through: + You + Entry Node + Service Node + Destination + Learn More + + New Session + Enter Session ID + Scan QR Code + Scan a user\'s QR code to start a session. QR codes can be found by tapping the QR code icon in account settings. + + Enter Session ID of recipient + Users can share their Session ID by going into their account settings and tapping "Share Session ID", or by sharing their QR code. + + Session needs camera access to scan QR codes + Grant Camera Access + + New Closed Group + Enter a group name + Closed groups support up to 10 members and provide the same privacy protections as one-on-one sessions. + You don\'t have any contacts yet + Start a Session + Please enter a group name + Please enter a shorter group name + Please pick at least 1 group member + A closed group cannot have more than 20 members + One of the members of your group has an invalid Session ID + + Join Open Group + Couldn\'t join group + Open Group URL + Scan QR Code + Scan the QR code of the open group you\'d like to join + + Enter an open group URL + + Settings + Enter a display name + Please pick a display name + Please pick a display name that consists of only a-z, A-Z, 0-9 and _ characters + Please pick a shorter display name + Privacy + Notifications + Chats + Devices + Recovery Phrase + Clear Data + + Notifications + Notification Style + Notification Content + + Privacy + + Chats + + Devices + Device Limit Reached + It\'s currently not allowed to link more than one device. + Couldn\'t unlink device. + Your device was unlinked successfully + Couldn\'t link device. + You haven\'t linked any devices yet + Link a Device (Beta) + + Notification Strategy + + Waiting for Authorization + Device Link Authorized + Please check that the words below match those shown on your other device. + Your device has been linked successfully + + Waiting for Device + Linking Request Received + Authorizing Device Link + Download Session on your other device and tap "Link to an existing account" at the bottom of the landing screen. If you have an existing account on your other device already you will have to delete that account first. + Please check that the words below match those shown on your other device. + Please wait while the device link is created. This can take up to a minute. + Authorize + + Change name + Unlink device + + Enter a name + + Your Recovery Phrase + This is your recovery phrase. With it, you can restore or migrate your Session ID to a new device. + + Clear All Data + This will permanently delete your messages, sessions, and contacts. + + QR Code + View My QR Code + Scan QR Code + Scan someone\'s QR code to start a conversation with them + + This is your QR code. Other users can scan it to start a session with you. + Share QR Code + + Would you like to restore your session with %s? + Dismiss + Restore + + Contacts + Closed Groups + Open Groups + + + + Apply + Done + + Edit Group + Enter a new group name + Members + Add members + Group name can\'t be empty + Please enter a shorter group name + Groups must have at least 1 group member + A closed group cannot have more than 10 members + One of the members of your group has an invalid Session ID + Are you sure you want to remove this user? + User removed from group + + Remove user from group + + Select Contacts + + It is not possible to use the same Session ID on multiple devices simultaneously + + Secure session reset done + + Theme + Day + Night + System default + + Copy Session ID + + Attachment + Voice Message + Details + + Failed to activate backups. Please try again or contact support. + + Restore backup + Select a file + Select a backup file and enter the passphrase it was created with. + 30-digit passphrase + + diff --git a/libsession/src/main/res/values/styles.xml b/libsession/src/main/res/values/styles.xml new file mode 100644 index 0000000000..db1af0eab2 --- /dev/null +++ b/libsession/src/main/res/values/styles.xml @@ -0,0 +1,441 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libsession/src/main/res/values/text_styles.xml b/libsession/src/main/res/values/text_styles.xml new file mode 100644 index 0000000000..544cd22c75 --- /dev/null +++ b/libsession/src/main/res/values/text_styles.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/libsession/src/main/res/values/themes.xml b/libsession/src/main/res/values/themes.xml new file mode 100644 index 0000000000..4239aebee4 --- /dev/null +++ b/libsession/src/main/res/values/themes.xml @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libsession/src/main/res/values/values.xml b/libsession/src/main/res/values/values.xml new file mode 100644 index 0000000000..369e27339a --- /dev/null +++ b/libsession/src/main/res/values/values.xml @@ -0,0 +1,5 @@ + + + true + false + diff --git a/libsession/src/main/res/values/vector_paths.xml b/libsession/src/main/res/values/vector_paths.xml new file mode 100644 index 0000000000..d4f9bb2ecb --- /dev/null +++ b/libsession/src/main/res/values/vector_paths.xml @@ -0,0 +1,20 @@ + + + + M 44 32 L 44 64 L 100 64 L 100 64 Z + M 44 96 L 44 64 L 100 64 L 100 64 Z + + + M 32 40 L 32 56 L 96 56 L 96 40 Z + M 32 88 L 32 72 L 96 72 L 96 88 Z + + + upperpart + bottompart + parts + + + upper + bottom + + \ No newline at end of file