From 4dab5156e1fabf42c597260bea921355ddbd90f0 Mon Sep 17 00:00:00 2001 From: kosyak Date: Sun, 1 Sep 2024 22:57:59 +0200 Subject: [PATCH 01/30] small muc subject improvements --- .../eu/siacs/conversations/ui/ConversationFragment.java | 2 +- src/main/res/drawable/ic_subject_black.xml | 9 +++++++++ src/main/res/drawable/ic_subject_white.xml | 9 +++++++++ src/main/res/layout/fragment_conversation.xml | 2 +- src/main/res/values/attrs.xml | 1 + src/main/res/values/themes.xml | 2 ++ 6 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/main/res/drawable/ic_subject_black.xml create mode 100644 src/main/res/drawable/ic_subject_white.xml diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 457ff8807..ebea2bc31 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -3610,7 +3610,7 @@ public class ConversationFragment extends XmppFragment conversation.refreshSessions(); - if (conversation != null && conversation.getMode() == Conversational.MODE_MULTI) { + if (conversation != null && conversation.getMode() == Conversational.MODE_MULTI && conversation.getNextCounterpart() == null) { String subject = conversation.getMucOptions().getSubject(); Boolean hidden = conversation.getMucOptions().subjectHidden(); diff --git a/src/main/res/drawable/ic_subject_black.xml b/src/main/res/drawable/ic_subject_black.xml new file mode 100644 index 000000000..6edbae1ae --- /dev/null +++ b/src/main/res/drawable/ic_subject_black.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_subject_white.xml b/src/main/res/drawable/ic_subject_white.xml new file mode 100644 index 000000000..8c6262226 --- /dev/null +++ b/src/main/res/drawable/ic_subject_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/layout/fragment_conversation.xml b/src/main/res/layout/fragment_conversation.xml index 870d2fb85..0da157c37 100644 --- a/src/main/res/layout/fragment_conversation.xml +++ b/src/main/res/layout/fragment_conversation.xml @@ -54,7 +54,7 @@ diff --git a/src/main/res/values/attrs.xml b/src/main/res/values/attrs.xml index 19d4166c8..6f9eccd42 100644 --- a/src/main/res/values/attrs.xml +++ b/src/main/res/values/attrs.xml @@ -40,6 +40,7 @@ + diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index 8eb113795..74462aa2f 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -59,6 +59,7 @@ @drawable/ic_attach_videocam @drawable/ic_attach_document @drawable/ic_pin_black + @drawable/ic_subject_black @drawable/ic_close_24dp_black @drawable/ic_attach_location @drawable/ic_attach_photo @@ -239,6 +240,7 @@ @drawable/ic_attach_videocam_white @drawable/ic_attach_document_white @drawable/ic_pin_white + @drawable/ic_subject_white @drawable/ic_close_24dp @drawable/ic_attach_location_white @drawable/ic_attach_photo_white -- 2.39.2 From 00817b79bea00f872b9621d8dbe7bcd053a25f1e Mon Sep 17 00:00:00 2001 From: kosyak Date: Mon, 2 Sep 2024 00:12:42 +0200 Subject: [PATCH 02/30] account color selector --- build.gradle | 1 + .../ui/ManageAccountActivity.java | 36 +++++++++++++++++- .../ui/adapter/AccountAdapter.java | 32 ++++++++++++++-- .../ui/adapter/ConversationAdapter.java | 2 +- .../ui/adapter/ListItemAdapter.java | 2 +- .../siacs/conversations/utils/UIHelper.java | 18 +++++++++ src/main/res/layout/account_row.xml | 38 ++++++++++++------- 7 files changed, 107 insertions(+), 22 deletions(-) diff --git a/build.gradle b/build.gradle index c6e5f252d..c9c8eabb7 100644 --- a/build.gradle +++ b/build.gradle @@ -83,6 +83,7 @@ dependencies { implementation 'com.github.singpolyma:TokenAutoComplete:bfa93780e0' + implementation 'com.github.kizitonwose.colorpreference:core:1.1.0' implementation 'com.github.kizitonwose.colorpreference:support:1.1.0' implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.github.singpolyma:Better-Link-Movement-Method:4df081e1e4' diff --git a/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java b/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java index aab32220d..67803b387 100644 --- a/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java +++ b/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java @@ -36,6 +36,7 @@ import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.services.XmppConnectionService.OnAccountUpdate; import eu.siacs.conversations.ui.adapter.AccountAdapter; import eu.siacs.conversations.ui.util.MenuDoubleTabUtil; +import eu.siacs.conversations.utils.UIHelper; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.XmppConnection; @@ -44,8 +45,14 @@ import static eu.siacs.conversations.utils.PermissionUtils.writeGranted; import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.navigation.NavigationBarView; +import com.kizitonwose.colorpreference.ColorDialog; +import com.kizitonwose.colorpreference.ColorShape; -public class ManageAccountActivity extends XmppActivity implements OnAccountUpdate, KeyChainAliasCallback, XmppConnectionService.OnAccountCreated, AccountAdapter.OnTglAccountState { +public class ManageAccountActivity extends XmppActivity implements OnAccountUpdate, + KeyChainAliasCallback, + XmppConnectionService.OnAccountCreated, + AccountAdapter.OnTglAccountState, + ColorDialog.OnColorSelectedListener { private final String STATE_SELECTED_ACCOUNT = "selected_account"; @@ -61,6 +68,18 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda protected Pair mPostponedActivityResult = null; + private AccountAdapter.ColorSelectorListener colorSelectorListener = new AccountAdapter.ColorSelectorListener() { + @Override + public void onColorPickerRequested(Jid accountJid, int currentColor) { + new ColorDialog.Builder(ManageAccountActivity.this) + .setColorShape(ColorShape.CIRCLE) + .setColorChoices(R.array.themeColorsOverride) + .setSelectedColor(currentColor) + .setTag(accountJid.asBareJid().toEscapedString()) + .show(); + } + }; + @Override public void onAccountUpdate() { refreshUi(); @@ -102,7 +121,7 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda } accountListView = findViewById(R.id.account_list); - this.mAccountAdapter = new AccountAdapter(this, accountList); + this.mAccountAdapter = new AccountAdapter(this, accountList, colorSelectorListener); accountListView.setAdapter(this.mAccountAdapter); accountListView.setOnItemClickListener((arg0, view, position, arg3) -> switchToAccount(accountList.get(position))); registerForContextMenu(accountListView); @@ -158,6 +177,13 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda super.onSaveInstanceState(savedInstanceState); } + @Override + protected void onDestroy() { + super.onDestroy(); + colorSelectorListener = null; + mAccountAdapter.colorSelectorListener = null; + } + @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); @@ -349,6 +375,12 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda } } + @Override + public void onColorSelected(int newColor, String tag) { + UIHelper.overrideAccountColor(this, tag, newColor); + refreshUiReal(); + } + private void addAccountFromKey() { try { KeyChain.choosePrivateKeyAlias(this, this, null, null, null, -1, null); diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java index a074fd6c8..6a23b2cf8 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java @@ -9,6 +9,11 @@ import android.widget.ArrayAdapter; import androidx.annotation.NonNull; import androidx.databinding.DataBindingUtil; +import com.kizitonwose.colorpreference.ColorDialog; +import com.kizitonwose.colorpreference.ColorPreference; +import com.kizitonwose.colorpreference.ColorShape; +import com.kizitonwose.colorpreference.ColorUtils; + import java.util.List; import eu.siacs.conversations.Config; @@ -19,22 +24,29 @@ import eu.siacs.conversations.ui.XmppActivity; import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.ui.util.StyledAttributes; import eu.siacs.conversations.utils.UIHelper; +import eu.siacs.conversations.xmpp.Jid; public class AccountAdapter extends ArrayAdapter { private final XmppActivity activity; private final boolean showStateButton; + private final boolean showColorSelector; + + public ColorSelectorListener colorSelectorListener = null; public AccountAdapter(XmppActivity activity, List objects, boolean showStateButton) { super(activity, 0, objects); this.activity = activity; this.showStateButton = showStateButton; + this.showColorSelector = false; } - public AccountAdapter(XmppActivity activity, List objects) { + public AccountAdapter(XmppActivity activity, List objects, ColorSelectorListener listener) { super(activity, 0, objects); this.activity = activity; this.showStateButton = true; + this.showColorSelector = true; + colorSelectorListener = listener; } @Override @@ -77,18 +89,27 @@ public class AccountAdapter extends ArrayAdapter { } else { viewHolder.binding.tglAccountStatus.setVisibility(View.GONE); } + viewHolder.binding.tglAccountStatus.setOnCheckedChangeListener((compoundButton, b) -> { if (b == isDisabled && activity instanceof OnTglAccountState) { ((OnTglAccountState) activity).onClickTglAccountState(account, b); } }); - if (activity.xmppConnectionService.getAccounts().size() > 1) { - viewHolder.binding.accountIndicator.setBackgroundColor(UIHelper.getColorForName(account.getJid().asBareJid().getEscapedLocal())); + if (this.showColorSelector && activity.xmppConnectionService.getAccounts().size() > 1) { + int color = UIHelper.getAccountColor(activity, account.getJid()); + viewHolder.binding.colorView.setVisibility(View.VISIBLE); + ColorUtils.setColorViewValue(viewHolder.binding.colorView, color, false, ColorShape.CIRCLE); + viewHolder.binding.colorView.setOnClickListener(v -> { + if (colorSelectorListener != null) { + colorSelectorListener.onColorPickerRequested(account.getJid(), color); + } + }); } else { - viewHolder.binding.accountIndicator.setBackgroundColor(Color.TRANSPARENT); + viewHolder.binding.colorView.setVisibility(View.GONE); } + return view; } @@ -106,4 +127,7 @@ public class AccountAdapter extends ArrayAdapter { void onClickTglAccountState(Account account, boolean state); } + public interface ColorSelectorListener { + void onColorPickerRequested(Jid accountJid, int currentColor); + } } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java index 69d4c7df4..22a41b59b 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java @@ -401,7 +401,7 @@ public class ConversationAdapter Account account = conversation.getAccount(); if (account != null && activity.xmppConnectionService.getAccounts().size() > 1) { - viewHolder.binding.accountIndicator.setBackgroundColor(UIHelper.getColorForName(account.getJid().asBareJid().getEscapedLocal())); + viewHolder.binding.accountIndicator.setBackgroundColor(UIHelper.getAccountColor(activity, account.getJid())); } else { viewHolder.binding.accountIndicator.setBackgroundColor(Color.TRANSPARENT); } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java index b342ccf0d..940d5cb2b 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java @@ -108,7 +108,7 @@ public class ListItemAdapter extends ArrayAdapter { } if (account != null && activity.xmppConnectionService.getAccounts().size() > 1) { - viewHolder.accountIndicator.setBackgroundColor(UIHelper.getColorForName(account.getJid().asBareJid().getEscapedLocal())); + viewHolder.accountIndicator.setBackgroundColor(UIHelper.getAccountColor(activity, account.getJid())); } else { viewHolder.accountIndicator.setBackgroundColor(Color.TRANSPARENT); } diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index 5680a5e60..ea674c893 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -1,6 +1,7 @@ package eu.siacs.conversations.utils; import android.content.Context; +import android.content.SharedPreferences; import android.text.SpannableStringBuilder; import android.text.format.DateFormat; import android.text.format.DateUtils; @@ -231,6 +232,23 @@ public class UIHelper { return getColorForName(name, false); } + public static int getAccountColor(Context context, Jid accountJid) { + SharedPreferences prefs = context.getSharedPreferences("accountColorsPrefs", Context.MODE_PRIVATE); + String name = accountJid.asBareJid().toEscapedString(); + int overrideColor = prefs.getInt(name, -1); + + if (overrideColor != -1) { + return overrideColor; + } else { + return getColorForName(name); + } + } + + public static void overrideAccountColor(Context context, String accountName, int color) { + SharedPreferences prefs = context.getSharedPreferences("accountColorsPrefs", Context.MODE_PRIVATE); + prefs.edit().putInt(accountName, color).apply(); + } + public static int getColorForName(String name, boolean safe) { if (Config.XEP_0392) { return XEP0392Helper.rgbFromNick(name); diff --git a/src/main/res/layout/account_row.xml b/src/main/res/layout/account_row.xml index ee3f7c138..93d6bd8d7 100644 --- a/src/main/res/layout/account_row.xml +++ b/src/main/res/layout/account_row.xml @@ -6,17 +6,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?selectableItemBackground" - android:clipToPadding="false" android:paddingLeft="8dp" android:paddingBottom="8dp" android:paddingTop="8dp"> - - + android:layout_toLeftOf="@+id/controls" + android:layout_toStartOf="@+id/controls"> - + android:layout_alignParentRight="true" + android:layout_centerVertical="true"> + + + + + + \ No newline at end of file -- 2.39.2 From ffbdad7503e5d70d461adb65d7841856ea60c2fd Mon Sep 17 00:00:00 2001 From: kosyak Date: Mon, 2 Sep 2024 01:00:39 +0200 Subject: [PATCH 03/30] show participants count/last seen in toolbar indicator --- .../ui/ContactDetailsActivity.java | 2 +- .../ui/ConversationsActivity.java | 41 ++++++++++++++ .../siacs/conversations/utils/UIHelper.java | 56 +++++++++++++++---- src/main/res/values/strings.xml | 13 +++++ 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java index cc7773ad2..8daffdbbc 100644 --- a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java @@ -504,7 +504,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp && contact.getLastseen() > 0 && contact.getPresences().allOrNonSupport(Namespace.IDLE)) { binding.detailsLastseen.setVisibility(View.VISIBLE); - binding.detailsLastseen.setText(UIHelper.lastseen(getApplicationContext(), contact.isActive(), contact.getLastseen())); + binding.detailsLastseen.setText(UIHelper.lastseen(getApplicationContext(), contact.isActive(), contact.getLastseen(), false)); } else { binding.detailsLastseen.setVisibility(View.GONE); } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index b225d8404..5d7762488 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -42,10 +42,14 @@ import android.app.FragmentTransaction; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.preference.PreferenceManager; import android.provider.Settings; import android.util.Log; import android.view.KeyEvent; @@ -95,7 +99,9 @@ import eu.siacs.conversations.ui.util.PendingItem; import eu.siacs.conversations.utils.ExceptionHelper; import eu.siacs.conversations.utils.PhoneNumberUtilWrapper; import eu.siacs.conversations.utils.SignupUtils; +import eu.siacs.conversations.utils.UIHelper; import eu.siacs.conversations.utils.XmppUri; +import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.OnUpdateBlocklist; import io.michaelrocks.libphonenumber.android.NumberParseException; @@ -135,6 +141,10 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio private boolean mActivityPaused = true; private final AtomicBoolean mRedirectInProcess = new AtomicBoolean(false); + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable refreshTitleRunnable = this::invalidateActionBarTitle; + private boolean showLastSeen = false; + private static boolean isViewOrShareIntent(Intent i) { Log.d(Config.LOGTAG, "action: " + (i == null ? null : i.getAction())); return i != null && VIEW_AND_SHARE_ACTIONS.contains(i.getAction()) && i.hasExtra(EXTRA_CONVERSATION); @@ -660,6 +670,8 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio this.mSkipBackgroundBinding = false; } mRedirectInProcess.set(false); + final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + this.showLastSeen = preferences.getBoolean("last_activity", false); BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_navigation); bottomNavigationView.setSelectedItemId(R.id.chats); @@ -735,6 +747,7 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio if (actionBar == null) { return; } + final FragmentManager fragmentManager = getFragmentManager(); final Fragment mainFragment = fragmentManager.findFragmentById(R.id.main_fragment); if (mainFragment instanceof ConversationFragment) { @@ -750,10 +763,38 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio binding.toolbar, (v) -> openConversationDetails(conversation) ); + + if (conversation.getMode() == Conversation.MODE_MULTI && conversation.getNextCounterpart() == null) { + int usersCount = conversation.getMucOptions().getUserCount(); + if (usersCount > 0) { + actionBar.setSubtitle(getResources().getQuantityString(R.plurals.x_participants, conversation.getMucOptions().getUserCount(), conversation.getMucOptions().getUserCount())); + } else { + actionBar.setSubtitle(""); + } + + handler.postDelayed(refreshTitleRunnable, 5000L); + } else if (conversation.getMode() == Conversation.MODE_SINGLE) { + Contact contact = conversation.getContact(); + if (showLastSeen + && contact.getLastseen() > 0 + && contact.getPresences().allOrNonSupport(Namespace.IDLE)) { + actionBar.setSubtitle(UIHelper.lastseen(getApplicationContext(), contact.isActive(), contact.getLastseen(), true)); + } else { + actionBar.setSubtitle(""); + handler.removeCallbacks(refreshTitleRunnable); + } + } else { + actionBar.setSubtitle(""); + handler.removeCallbacks(refreshTitleRunnable); + } + return; } } + + handler.removeCallbacks(refreshTitleRunnable); actionBar.setTitle(R.string.app_name); + actionBar.setSubtitle(""); actionBar.setDisplayHomeAsUpEnabled(false); ActionBarUtil.resetActionBarOnClickListeners(binding.toolbar); } diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index ea674c893..f684c5f46 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -205,26 +205,60 @@ public class UIHelper { .get(Calendar.DAY_OF_YEAR); } - public static String lastseen(Context context, boolean active, long time) { + public static String lastseen(Context context, boolean active, long time, boolean shortText) { long difference = (System.currentTimeMillis() - time) / 1000; if (active) { - return context.getString(R.string.online_right_now); + if (shortText) { + return context.getString(R.string.online_right_now_short); + } else { + return context.getString(R.string.online_right_now); + } } else if (difference < 60) { - return context.getString(R.string.last_seen_now); + if (shortText) { + return context.getString(R.string.last_seen_now_short); + } else { + return context.getString(R.string.last_seen_now); + } } else if (difference < 60 * 2) { - return context.getString(R.string.last_seen_min); + if (shortText) { + return context.getString(R.string.last_seen_min_short); + } else { + return context.getString(R.string.last_seen_min); + } } else if (difference < 60 * 60) { - return context.getString(R.string.last_seen_mins, Math.round(difference / 60.0)); + if (shortText) { + return context.getString(R.string.last_seen_mins_short, Math.round(difference / 60.0)); + } else { + return context.getString(R.string.last_seen_mins, Math.round(difference / 60.0)); + } } else if (difference < 60 * 60 * 2) { - return context.getString(R.string.last_seen_hour); + if (shortText) { + return context.getString(R.string.last_seen_hour_short); + } else { + return context.getString(R.string.last_seen_hour); + } } else if (difference < 60 * 60 * 24) { - return context.getString(R.string.last_seen_hours, - Math.round(difference / (60.0 * 60.0))); + if (shortText) { + return context.getString(R.string.last_seen_hours_short, + Math.round(difference / (60.0 * 60.0))); + } else { + return context.getString(R.string.last_seen_hours, + Math.round(difference / (60.0 * 60.0))); + } } else if (difference < 60 * 60 * 48) { - return context.getString(R.string.last_seen_day); + if (shortText) { + return context.getString(R.string.last_seen_day_short); + } else { + return context.getString(R.string.last_seen_day); + } } else { - return context.getString(R.string.last_seen_days, - Math.round(difference / (60.0 * 60.0 * 24.0))); + if (shortText) { + return context.getString(R.string.last_seen_days_short, + Math.round(difference / (60.0 * 60.0 * 24.0))); + } else { + return context.getString(R.string.last_seen_days, + Math.round(difference / (60.0 * 60.0 * 24.0))); + } } } diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index a6c021455..92ce2eac0 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1108,6 +1108,19 @@ Rounded Square Square + + %1$d participant + %1$d participants + + + online + just now + one minute ago + %d minutes ago + one hour ago + %d hours ago + one day ago + %d days ago Clarendon OldMan -- 2.39.2 From e3542ccf723b43ab260c1923c2633f0efd30b4c5 Mon Sep 17 00:00:00 2001 From: kosyak Date: Mon, 2 Sep 2024 01:08:27 +0200 Subject: [PATCH 04/30] take last seen into account during status indicator calculation --- .../ui/ContactDetailsActivity.java | 2 +- .../ui/adapter/ConversationAdapter.java | 2 +- .../ui/adapter/ListItemAdapter.java | 2 +- .../conversations/ui/adapter/UserAdapter.java | 2 +- .../ui/adapter/UserPreviewAdapter.java | 2 +- .../ui/widget/PresenceIndicator.kt | 22 +++++++++++++------ 6 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java index 8daffdbbc..37dd2e88d 100644 --- a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java @@ -523,7 +523,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp AvatarWorkerTask.loadAvatar(contact, binding.detailsContactBadge, R.dimen.avatar_on_details_screen_size); binding.detailsContactBadge.setOnClickListener(this::onBadgeClick); - binding.presenceIndicator.setStatus(contact.getShownStatus()); + binding.presenceIndicator.setStatus(contact); binding.detailsContactKeys.removeAllViews(); boolean hasKeys = false; diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java index 22a41b59b..bac12514b 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java @@ -393,7 +393,7 @@ public class ConversationAdapter Contact contact = conversation.getContact(); if (contact != null) { - viewHolder.binding.presenceIndicator.setStatus(contact.getShownStatus()); + viewHolder.binding.presenceIndicator.setStatus(contact); } else { viewHolder.binding.presenceIndicator.setStatus(null); } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java index 940d5cb2b..f9444a06c 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java @@ -94,7 +94,7 @@ public class ListItemAdapter extends ArrayAdapter { AvatarWorkerTask.loadAvatar(item, viewHolder.avatar, R.dimen.avatar); if (item instanceof Contact) { - viewHolder.presenceIndicator.setStatus(((Contact) item).getShownStatus()); + viewHolder.presenceIndicator.setStatus(((Contact) item)); } else { viewHolder.presenceIndicator.setStatus(null); } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java index 525fec6f6..e1fbfa283 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java @@ -91,7 +91,7 @@ public class UserAdapter extends ListAdapter 60)) { + this.status = null + } else { + this.status = status + } + + invalidate() + } + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) -- 2.39.2 From 021552b1d479b9b39714087324c9580e1d5cda9d Mon Sep 17 00:00:00 2001 From: kosyak Date: Mon, 2 Sep 2024 01:10:32 +0200 Subject: [PATCH 05/30] try to fix wired jump to bottom button behavior --- .../java/eu/siacs/conversations/ui/ConversationFragment.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index ebea2bc31..1b6eb44a4 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -590,7 +590,7 @@ public class ConversationFragment extends XmppFragment public void onClick(View v) { stopScrolling(); - if (previousClickedReply != null) { + /*if (previousClickedReply != null) { int lastVisiblePosition = binding.messagesView.getLastVisiblePosition(); Message lastVisibleMessage = messageListAdapter.getItem(lastVisiblePosition); Message jump = previousClickedReply; @@ -602,7 +602,7 @@ public class ConversationFragment extends XmppFragment return; } } - } + }*/ if (conversation.isInHistoryPart()) { conversation.jumpToLatest(); -- 2.39.2 From 95ee8459b8d6b9a6d62576c0f439c070999ab72c Mon Sep 17 00:00:00 2001 From: kosyak Date: Mon, 2 Sep 2024 22:16:05 +0200 Subject: [PATCH 06/30] don't use jabber last seen in status calculations --- .../eu/siacs/conversations/ui/ConversationsActivity.java | 8 ++++---- .../eu/siacs/conversations/ui/widget/PresenceIndicator.kt | 8 ++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index 5d7762488..79c70d46b 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -775,10 +775,10 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio handler.postDelayed(refreshTitleRunnable, 5000L); } else if (conversation.getMode() == Conversation.MODE_SINGLE) { Contact contact = conversation.getContact(); - if (showLastSeen - && contact.getLastseen() > 0 - && contact.getPresences().allOrNonSupport(Namespace.IDLE)) { - actionBar.setSubtitle(UIHelper.lastseen(getApplicationContext(), contact.isActive(), contact.getLastseen(), true)); + List statuses = contact.getPresences().getStatusMessages(); + if (!statuses.isEmpty() && !statuses.get(0).isBlank()) { + actionBar.setSubtitle(statuses.get(0)); + handler.postDelayed(refreshTitleRunnable, 5000L); } else { actionBar.setSubtitle(""); handler.removeCallbacks(refreshTitleRunnable); diff --git a/src/main/java/eu/siacs/conversations/ui/widget/PresenceIndicator.kt b/src/main/java/eu/siacs/conversations/ui/widget/PresenceIndicator.kt index 55ab27809..f4ecaf21e 100644 --- a/src/main/java/eu/siacs/conversations/ui/widget/PresenceIndicator.kt +++ b/src/main/java/eu/siacs/conversations/ui/widget/PresenceIndicator.kt @@ -49,14 +49,10 @@ class PresenceIndicator : View { fun setStatus(contact: Contact?) { val status = contact?.shownStatus - if ((status == Presence.Status.ONLINE || status == Presence.Status.CHAT) && - (contact.lastseen <= 0 || !contact.presences.allOrNonSupport(Namespace.IDLE) || ((System.currentTimeMillis() - contact.lastseen) / 1000) > 60)) { - this.status = null - } else { + if (status != this.status) { this.status = status + invalidate() } - - invalidate() } override fun onDraw(canvas: Canvas) { -- 2.39.2 From c64e0925f4027d91b53c4aa09bd6b1688984dd73 Mon Sep 17 00:00:00 2001 From: kosyak Date: Fri, 6 Sep 2024 01:02:52 +0200 Subject: [PATCH 07/30] fallback on message uuid to fix missed reply id for single mode conversations --- src/main/java/eu/siacs/conversations/entities/Message.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index ba7dc1bdb..4150d20ac 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/src/main/java/eu/siacs/conversations/entities/Message.java @@ -352,7 +352,10 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable } public String replyId() { - return conversation.getMode() == Conversation.MODE_MULTI || getRemoteMsgId() == null ? getServerMsgId() : getRemoteMsgId(); + if (conversation.getMode() == Conversation.MODE_MULTI) return getServerMsgId(); + final String remote = getRemoteMsgId(); + if (remote == null && getStatus() > STATUS_RECEIVED) return getUuid(); + return remote; } public Message reply() { -- 2.39.2 From c32809b9630efde84b698d7fe7ec176bc844d44b Mon Sep 17 00:00:00 2001 From: kosyak Date: Sun, 8 Sep 2024 23:51:05 +0200 Subject: [PATCH 08/30] allow to disable presence and account indicators --- .../ui/adapter/AccountAdapter.java | 3 +- .../ui/widget/AccountIndicator.kt | 37 +++++++++++++++++++ .../ui/widget/PresenceIndicator.kt | 15 ++++++++ src/main/res/layout/contact.xml | 2 +- src/main/res/layout/conversation_list_row.xml | 2 +- src/main/res/values/defaults.xml | 2 + src/main/res/values/strings.xml | 4 ++ src/main/res/xml/preferences.xml | 10 +++++ 8 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/ui/widget/AccountIndicator.kt diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java index 6a23b2cf8..b4109bfdb 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java @@ -96,7 +96,8 @@ public class AccountAdapter extends ArrayAdapter { } }); - if (this.showColorSelector && activity.xmppConnectionService.getAccounts().size() > 1) { + if (this.showColorSelector && activity.xmppConnectionService.getAccounts().size() > 1 && + activity.xmppConnectionService.getPreferences().getBoolean("show_account_indicator", activity.getResources().getBoolean(R.bool.show_account_indicator))) { int color = UIHelper.getAccountColor(activity, account.getJid()); viewHolder.binding.colorView.setVisibility(View.VISIBLE); ColorUtils.setColorViewValue(viewHolder.binding.colorView, color, false, ColorShape.CIRCLE); diff --git a/src/main/java/eu/siacs/conversations/ui/widget/AccountIndicator.kt b/src/main/java/eu/siacs/conversations/ui/widget/AccountIndicator.kt new file mode 100644 index 000000000..f7343aeca --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/widget/AccountIndicator.kt @@ -0,0 +1,37 @@ +package eu.siacs.conversations.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import eu.siacs.conversations.R +import eu.siacs.conversations.ui.XmppActivity + +class AccountIndicator : View { + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + constructor( + context: Context?, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val enabled = (context as? XmppActivity) + ?.xmppConnectionService?.preferences + ?.getBoolean("show_account_indicator", context.resources.getBoolean(R.bool.show_account_indicator)) ?: false + + visibility = if (enabled) { + VISIBLE + } else { + INVISIBLE + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/ui/widget/PresenceIndicator.kt b/src/main/java/eu/siacs/conversations/ui/widget/PresenceIndicator.kt index f4ecaf21e..12b12f60b 100644 --- a/src/main/java/eu/siacs/conversations/ui/widget/PresenceIndicator.kt +++ b/src/main/java/eu/siacs/conversations/ui/widget/PresenceIndicator.kt @@ -8,8 +8,10 @@ import android.graphics.Paint import android.util.AttributeSet import android.view.View import android.view.ViewOutlineProvider +import eu.siacs.conversations.R import eu.siacs.conversations.entities.Contact import eu.siacs.conversations.entities.Presence +import eu.siacs.conversations.ui.XmppActivity import eu.siacs.conversations.ui.util.StyledAttributes import eu.siacs.conversations.utils.UIHelper import eu.siacs.conversations.xml.Namespace @@ -23,6 +25,8 @@ class PresenceIndicator : View { private var status: Presence.Status? = null + private var enabled = false + constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( @@ -55,7 +59,18 @@ class PresenceIndicator : View { } } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + enabled = (context as? XmppActivity) + ?.xmppConnectionService?.preferences + ?.getBoolean("show_contact_status", context.resources.getBoolean(R.bool.show_contact_status)) ?: false + } + override fun onDraw(canvas: Canvas) { + if (!enabled) { + return + } + super.onDraw(canvas) val color: Int? = UIHelper.getColorForStatus(status); diff --git a/src/main/res/layout/contact.xml b/src/main/res/layout/contact.xml index db9374f1c..923a80368 100644 --- a/src/main/res/layout/contact.xml +++ b/src/main/res/layout/contact.xml @@ -9,7 +9,7 @@ android:clipToPadding="false" android:padding="@dimen/list_padding"> - - false false rounded_square + true + true diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 92ce2eac0..d56415813 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -612,6 +612,10 @@ Don’t open image editor screen automatically for single image attachments Could not connect to OpenKeychain This device is no longer in use + Contact presence + Show contact presence nearby contact avatar + Account indicator + Mark conversations with different colors in case of several account Computer Mobile phone Tablet diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml index 7cbd5fedf..602b57d59 100644 --- a/src/main/res/xml/preferences.xml +++ b/src/main/res/xml/preferences.xml @@ -64,6 +64,16 @@ android:key="always_full_timestamps" android:summary="@string/pref_always_show_full_timestamps_summary" android:title="@string/pref_always_show_full_timestamps" /> + + -- 2.39.2 From 1a751b8a80ba50cf8058111d9569b1a265ccece6 Mon Sep 17 00:00:00 2001 From: kosyak Date: Mon, 9 Sep 2024 00:03:31 +0200 Subject: [PATCH 09/30] don't check if muc pm was archived in case of new message --- .../java/eu/siacs/conversations/parser/MessageParser.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 2ec1bacf2..2706a55e4 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -508,10 +508,6 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } - if (nextCounterpart != null && mXmppConnectionService.checkIsArchived(account, counterpart.asBareJid(), nextCounterpart)) { - return; - } - if ((body != null || pgpEncrypted != null || (axolotlEncrypted != null && axolotlEncrypted.hasChild("payload")) || oobUrl != null) && !isMucStatusMessage) { final Conversation conversation = mXmppConnectionService.findOrCreateConversation(account, counterpart.asBareJid(), null, conversationIsProbablyMuc, nextCounterpart != null, false, nextCounterpart); final boolean conversationMultiMode = conversation.getMode() == Conversation.MODE_MULTI; -- 2.39.2 From 389074e8027fde6be616f7452177173aea7c2a79 Mon Sep 17 00:00:00 2001 From: kosyak Date: Sun, 29 Sep 2024 23:36:50 +0200 Subject: [PATCH 10/30] Reapply "OTR implementation" This reverts commit c145a6b8e5fc7d7ff262e61cabc11344a9eb88e2. # Conflicts: # src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java # src/main/res/values/strings.xml --- build.gradle | 1 + src/main/AndroidManifest.xml | 5 + .../java/eu/siacs/conversations/Config.java | 7 +- .../conversations/crypto/OtrService.java | 312 ++++++++++++ .../siacs/conversations/entities/Account.java | 39 ++ .../siacs/conversations/entities/Contact.java | 41 ++ .../conversations/entities/Conversation.java | 164 ++++++- .../conversations/entities/Transferable.java | 2 +- .../generator/AbstractGenerator.java | 6 + .../generator/MessageGenerator.java | 47 ++ .../conversations/parser/MessageParser.java | 137 +++++- .../services/XmppConnectionService.java | 110 ++++- .../ui/ConversationFragment.java | 50 ++ .../ui/ConversationsActivity.java | 31 ++ .../conversations/ui/SendLogActivity.java | 1 - .../conversations/ui/VerifyOTRActivity.java | 450 ++++++++++++++++++ .../siacs/conversations/ui/XmppActivity.java | 15 +- .../conversations/utils/CryptoHelper.java | 1 + .../siacs/conversations/utils/UIHelper.java | 2 + .../eu/siacs/conversations/utils/XmppUri.java | 10 +- .../conversations/xmpp/jid/OtrJidHelper.java | 17 + src/main/res/layout/activity_verify_otr.xml | 148 ++++++ src/main/res/menu/verification_choices.xml | 12 + src/main/res/menu/verify_otr.xml | 15 + src/main/res/values/about.xml | 1 + src/main/res/values/strings.xml | 34 ++ 26 files changed, 1648 insertions(+), 10 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/crypto/OtrService.java create mode 100644 src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java create mode 100644 src/main/res/layout/activity_verify_otr.xml create mode 100644 src/main/res/menu/verification_choices.xml create mode 100644 src/main/res/menu/verify_otr.xml diff --git a/build.gradle b/build.gradle index c9c8eabb7..169129082 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,7 @@ dependencies { implementation 'com.google.guava:guava:32.1.3-android' implementation 'io.michaelrocks:libphonenumber-android:8.13.17' implementation 'im.conversations.webrtc:webrtc-android:119.0.0' + implementation 'org.jitsi:org.otr4j:0.23' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation "androidx.recyclerview:recyclerview:1.2.1" diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index d848e4027..8c9a0b9b6 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -299,6 +299,11 @@ + bookmarks = new HashMap<>(); private Presence.Status presenceStatus; private String presenceStatusMessage; @@ -535,6 +544,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable } public void initAccountServices(final XmppConnectionService context) { + this.mOtrService = new OtrService(context, this); this.axolotlService = new AxolotlService(this, context); this.pgpDecryptionService = new PgpDecryptionService(context); if (xmppConnection != null) { @@ -542,6 +552,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable } } + public OtrService getOtrService() { + return this.mOtrService; + } + public PgpDecryptionService getPgpDecryptionService() { return this.pgpDecryptionService; } @@ -554,6 +568,27 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable this.xmppConnection = connection; } + public String getOtrFingerprint() { + if (this.otrFingerprint == null) { + try { + if (this.mOtrService == null) { + return null; + } + final PublicKey publicKey = this.mOtrService.getPublicKey(); + if (publicKey == null || !(publicKey instanceof DSAPublicKey)) { + return null; + } + this.otrFingerprint = new OtrCryptoEngineImpl().getFingerprint(publicKey).toLowerCase(Locale.US); + return this.otrFingerprint; + } catch (final OtrCryptoException ignored) { + return null; + } + } else { + return this.otrFingerprint; + } + } + + public String getRosterVersion() { if (this.rosterVersion == null) { return ""; @@ -721,6 +756,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable private List getFingerprints() { ArrayList fingerprints = new ArrayList<>(); + final String otr = this.getOtrFingerprint(); + if (otr != null) { + fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OTR, otr)); + } if (axolotlService == null) { return fingerprints; } diff --git a/src/main/java/eu/siacs/conversations/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java index 6b0554240..53f0134bf 100644 --- a/src/main/java/eu/siacs/conversations/entities/Contact.java +++ b/src/main/java/eu/siacs/conversations/entities/Contact.java @@ -348,6 +348,47 @@ public class Contact implements ListItem, Blockable { return groups; } + public ArrayList getOtrFingerprints() { + synchronized (this.keys) { + final ArrayList fingerprints = new ArrayList(); + try { + if (this.keys.has("otr_fingerprints")) { + final JSONArray prints = this.keys.getJSONArray("otr_fingerprints"); + for (int i = 0; i < prints.length(); ++i) { + final String print = prints.isNull(i) ? null : prints.getString(i); + if (print != null && !print.isEmpty()) { + fingerprints.add(prints.getString(i).toLowerCase(Locale.US)); + } + } + } + } catch (final JSONException ignored) { + + } + return fingerprints; + } + } + + public boolean addOtrFingerprint(String print) { + synchronized (this.keys) { + if (getOtrFingerprints().contains(print)) { + return false; + } + try { + JSONArray fingerprints; + if (!this.keys.has("otr_fingerprints")) { + fingerprints = new JSONArray(); + } else { + fingerprints = this.keys.getJSONArray("otr_fingerprints"); + } + fingerprints.put(print); + this.keys.put("otr_fingerprints", fingerprints); + return true; + } catch (final JSONException ignored) { + return false; + } + } + } + public long getPgpKeyId() { synchronized (this.keys) { if (this.keys.has("pgp_keyid")) { diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index e3c147320..da60498d3 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -71,6 +71,7 @@ import org.json.JSONException; import org.json.JSONObject; import java.lang.ref.WeakReference; +import java.security.interfaces.DSAPublicKey; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -81,6 +82,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.ListIterator; +import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.Timer; @@ -132,6 +134,12 @@ import me.saket.bettermovementmethod.BetterLinkMovementMethod; import static eu.siacs.conversations.entities.Bookmark.printableValue; +import net.java.otr4j.OtrException; +import net.java.otr4j.crypto.OtrCryptoException; +import net.java.otr4j.session.SessionID; +import net.java.otr4j.session.SessionImpl; +import net.java.otr4j.session.SessionStatus; + public class Conversation extends AbstractEntity implements Blockable, Comparable, Conversational, AvatarService.Avatarable { public static final String TABLENAME = "conversations"; @@ -180,10 +188,15 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl private int mode; private JSONObject attributes; private Jid nextCounterpart; + private transient SessionImpl otrSession; + private transient String otrFingerprint = null; + private Smp mSmp = new Smp(); private transient MucOptions mucOptions = null; + private byte[] symmetricKey; private boolean messagesLeftOnServer = true; private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE; private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE; + private String mLastReceivedOtrMessageId = null; private String mFirstMamReference = null; protected Message replyTo = null; protected int mCurrentTab = -1; @@ -490,6 +503,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } } + public void findUnsentMessagesWithEncryption(int encryptionType, OnMessageFound onMessageFound) { + synchronized (this.messages) { + for (Message message : this.messages) { + if ((message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_WAITING) + && (message.getEncryption() == encryptionType)) { + onMessageFound.onMessageFound(message); + } + } + } + } + public void findUnsentTextMessages(OnMessageFound onMessageFound) { final ArrayList results = new ArrayList<>(); synchronized (this.messages) { @@ -662,6 +686,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return getContact().getBlockedJid(); } + public String getLastReceivedOtrMessageId() { + return this.mLastReceivedOtrMessageId; + } + + public void setLastReceivedOtrMessageId(String id) { + this.mLastReceivedOtrMessageId = id; + } + public int countMessages() { synchronized (this.messages) { return this.messages.size(); @@ -923,6 +955,124 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl this.mode = mode; } + public SessionImpl startOtrSession(String presence, boolean sendStart) { + if (this.otrSession != null) { + return this.otrSession; + } else { + final SessionID sessionId = new SessionID(this.getJid().asBareJid().toString(), + presence, + "xmpp"); + this.otrSession = new SessionImpl(sessionId, getAccount().getOtrService()); + try { + if (sendStart) { + this.otrSession.startSession(); + return this.otrSession; + } + return this.otrSession; + } catch (OtrException e) { + return null; + } + } + + } + + public SessionImpl getOtrSession() { + return this.otrSession; + } + + public void resetOtrSession() { + this.otrFingerprint = null; + this.otrSession = null; + this.mSmp.hint = null; + this.mSmp.secret = null; + this.mSmp.status = Smp.STATUS_NONE; + } + + public Smp smp() { + return mSmp; + } + + public boolean startOtrIfNeeded() { + if (this.otrSession != null && this.otrSession.getSessionStatus() != SessionStatus.ENCRYPTED) { + try { + this.otrSession.startSession(); + return true; + } catch (OtrException e) { + this.resetOtrSession(); + return false; + } + } else { + return true; + } + } + + public boolean endOtrIfNeeded() { + if (this.otrSession != null) { + if (this.otrSession.getSessionStatus() == SessionStatus.ENCRYPTED) { + try { + this.otrSession.endSession(); + this.resetOtrSession(); + return true; + } catch (OtrException e) { + this.resetOtrSession(); + return false; + } + } else { + this.resetOtrSession(); + return false; + } + } else { + return false; + } + } + + public boolean hasValidOtrSession() { + return this.otrSession != null; + } + + public synchronized String getOtrFingerprint() { + if (this.otrFingerprint == null) { + try { + if (getOtrSession() == null || getOtrSession().getSessionStatus() != SessionStatus.ENCRYPTED) { + return null; + } + DSAPublicKey remotePubKey = (DSAPublicKey) getOtrSession().getRemotePublicKey(); + this.otrFingerprint = getAccount().getOtrService().getFingerprint(remotePubKey).toLowerCase(Locale.US); + } catch (final OtrCryptoException ignored) { + return null; + } catch (final UnsupportedOperationException ignored) { + return null; + } + } + return this.otrFingerprint; + } + + public boolean verifyOtrFingerprint() { + final String fingerprint = getOtrFingerprint(); + if (fingerprint != null) { + getContact().addOtrFingerprint(fingerprint); + return true; + } else { + return false; + } + } + + public boolean isOtrFingerprintVerified() { + return getContact().getOtrFingerprints().contains(getOtrFingerprint()); + } + + public class Smp { + public static final int STATUS_NONE = 0; + public static final int STATUS_CONTACT_REQUESTED = 1; + public static final int STATUS_WE_REQUESTED = 2; + public static final int STATUS_FAILED = 3; + public static final int STATUS_VERIFIED = 4; + + public String secret = null; + public String hint = null; + public int status = 0; + } + /** * short for is Private and Non-anonymous */ @@ -964,7 +1114,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } public int getNextEncryption() { - if (!Config.supportOmemo() && !Config.supportOpenPgp()) { + if (!Config.supportOmemo() && !Config.supportOpenPgp() && !Config.supportOtr()) { return Message.ENCRYPTION_NONE; } if (OmemoSetting.isAlways()) { @@ -993,6 +1143,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return nextMessage == null ? "" : nextMessage; } + public boolean smpRequested() { + return smp().status == Smp.STATUS_CONTACT_REQUESTED; + } + public @Nullable Draft getDraft() { long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0); @@ -1015,6 +1169,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return changed; } + public void setSymmetricKey(byte[] key) { + this.symmetricKey = key; + } + + public byte[] getSymmetricKey() { + return this.symmetricKey; + } + public Bookmark getBookmark() { return this.account.getBookmark(this.contactJid); } diff --git a/src/main/java/eu/siacs/conversations/entities/Transferable.java b/src/main/java/eu/siacs/conversations/entities/Transferable.java index 5c833f603..58297d26a 100644 --- a/src/main/java/eu/siacs/conversations/entities/Transferable.java +++ b/src/main/java/eu/siacs/conversations/entities/Transferable.java @@ -6,7 +6,7 @@ import java.util.List; public interface Transferable { List VALID_IMAGE_EXTENSIONS = Arrays.asList("webp", "jpeg", "jpg", "png", "jpe"); - List VALID_CRYPTO_EXTENSIONS = Arrays.asList("pgp", "gpg"); + List VALID_CRYPTO_EXTENSIONS = Arrays.asList("pgp", "gpg", "otr"); int STATUS_UNKNOWN = 0x200; int STATUS_CHECKING = 0x201; diff --git a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java index 706b50043..1088427fb 100644 --- a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java @@ -57,6 +57,9 @@ public abstract class AbstractGenerator { private final String[] PRIVACY_SENSITIVE = { "urn:xmpp:time" //XEP-0202: Entity Time leaks time zone }; + private final String[] OTR = { + "urn:xmpp:otr:0" + }; private final String[] VOIP_NAMESPACES = { Namespace.JINGLE_TRANSPORT_ICE_UDP, Namespace.JINGLE_FEATURE_AUDIO, @@ -125,6 +128,9 @@ public abstract class AbstractGenerator { features.addAll(Arrays.asList(PRIVACY_SENSITIVE)); features.addAll(Arrays.asList(VOIP_NAMESPACES)); } + if (Config.supportOtr()) { + features.addAll(Arrays.asList(OTR)); + } if (mXmppConnectionService.broadcastLastActivity()) { features.add(Namespace.IDLE); } diff --git a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java index 944e36d15..ddbb6d71c 100644 --- a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java @@ -1,5 +1,8 @@ package eu.siacs.conversations.generator; +import net.java.otr4j.OtrException; +import net.java.otr4j.session.Session; + import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -24,6 +27,7 @@ import eu.siacs.conversations.xmpp.jingle.Media; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; public class MessageGenerator extends AbstractGenerator { + public static final String OTR_FALLBACK_MESSAGE = "I would like to start a private (OTR encrypted) conversation but your client doesn’t seem to support that"; private static final String OMEMO_FALLBACK_MESSAGE = "I sent you an OMEMO encrypted message but your client doesn’t seem to support that. Find more information on https://conversations.im/omemo"; private static final String PGP_FALLBACK_MESSAGE = "I sent you a PGP encrypted message but your client doesn’t seem to support that."; @@ -102,6 +106,36 @@ public class MessageGenerator extends AbstractGenerator { return packet; } + public static void addMessageHints(MessagePacket packet) { + packet.addChild("private", "urn:xmpp:carbons:2"); + packet.addChild("no-copy", "urn:xmpp:hints"); + packet.addChild("no-permanent-store", "urn:xmpp:hints"); + packet.addChild("no-permanent-storage", "urn:xmpp:hints"); //do not copy this. this is wrong. it is *store* + } + + public MessagePacket generateOtrChat(Message message) { + Conversation conversation = (Conversation) message.getConversation(); + Session otrSession = conversation.getOtrSession(); + if (otrSession == null) { + return null; + } + MessagePacket packet = preparePacket(message); + addMessageHints(packet); + try { + String content; + if (message.hasFileOnRemoteHost()) { + content = message.getFileParams().url.toString(); + } else { + content = message.getBody(); + } + packet.setBody(otrSession.transformSending(content)[0]); + packet.addChild("encryption", "urn:xmpp:eme:0").setAttribute("namespace", "urn:xmpp:otr:0"); + return packet; + } catch (OtrException e) { + return null; + } + } + public MessagePacket generateChat(Message message) { MessagePacket packet = preparePacket(message); String content; @@ -233,6 +267,19 @@ public class MessageGenerator extends AbstractGenerator { return packet; } + public MessagePacket generateOtrError(Jid to, String id, String errorText) { + MessagePacket packet = new MessagePacket(); + packet.setType(MessagePacket.TYPE_ERROR); + packet.setAttribute("id", id); + packet.setTo(to); + Element error = packet.addChild("error"); + error.setAttribute("code", "406"); + error.setAttribute("type", "modify"); + error.addChild("not-acceptable", "urn:ietf:params:xml:ns:xmpp-stanzas"); + error.addChild("text").setContent("?OTR Error:" + errorText); + return packet; + } + public MessagePacket sessionProposal(final JingleConnectionManager.RtpSessionProposal proposal) { final MessagePacket packet = new MessagePacket(); packet.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 2706a55e4..0c9dafcba 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -1,8 +1,13 @@ package eu.siacs.conversations.parser; +import android.os.Build; +import android.text.Html; import android.util.Log; import android.util.Pair; +import net.java.otr4j.session.Session; +import net.java.otr4j.session.SessionStatus; + import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; @@ -16,6 +21,7 @@ import java.util.UUID; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.OtrService; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.BrokenSessionException; import eu.siacs.conversations.crypto.axolotl.NotEncryptedForThisDeviceException; @@ -28,9 +34,11 @@ import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.entities.MucOptions; +import eu.siacs.conversations.entities.Presence; import eu.siacs.conversations.entities.ReadByMarker; import eu.siacs.conversations.entities.ReceiptRequest; import eu.siacs.conversations.entities.RtpSessionStatus; +import eu.siacs.conversations.entities.ServiceDiscoveryResult; import eu.siacs.conversations.http.HttpConnectionManager; import eu.siacs.conversations.services.MessageArchiveService; import eu.siacs.conversations.services.QuickConversationsService; @@ -49,6 +57,7 @@ import eu.siacs.conversations.xmpp.pep.Avatar; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; public class MessageParser extends AbstractParser implements OnMessagePacketReceived { + private static final List CLIENTS_SENDING_HTML_IN_OTR = Arrays.asList("Pidgin", "Adium", "Trillian"); private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm:ss", Locale.ENGLISH); @@ -95,6 +104,30 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece return result != null ? result : fallback; } + private static boolean clientMightSendHtml(Account account, Jid from) { + String resource = from.getResource(); + if (resource == null) { + return false; + } + Presence presence = account.getRoster().getContact(from).getPresences().getPresencesMap().get(resource); + ServiceDiscoveryResult disco = presence == null ? null : presence.getServiceDiscoveryResult(); + if (disco == null) { + return false; + } + return hasIdentityKnowForSendingHtml(disco.getIdentities()); + } + + private static boolean hasIdentityKnowForSendingHtml(List identities) { + for (ServiceDiscoveryResult.Identity identity : identities) { + if (identity.getName() != null) { + if (CLIENTS_SENDING_HTML_IN_OTR.contains(identity.getName())) { + return true; + } + } + } + return false; + } + private boolean extractChatState(Conversation c, final boolean isTypeGroupChat, final MessagePacket packet) { ChatState state = ChatState.parse(packet); if (state != null && c != null) { @@ -126,6 +159,66 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece return false; } + private Message parseOtrChat(String body, Jid from, String id, Conversation conversation) { + String presence; + if (from.isBareJid()) { + presence = ""; + } else { + presence = from.getResource(); + } + if (body.matches("^\\?OTRv\\d{1,2}\\?.*")) { + conversation.endOtrIfNeeded(); + } + if (!conversation.hasValidOtrSession()) { + conversation.startOtrSession(presence, false); + } else { + String foreignPresence = conversation.getOtrSession().getSessionID().getUserID(); + if (!foreignPresence.equals(presence)) { + conversation.endOtrIfNeeded(); + conversation.startOtrSession(presence, false); + } + } + try { + conversation.setLastReceivedOtrMessageId(id); + Session otrSession = conversation.getOtrSession(); + body = otrSession.transformReceiving(body); + SessionStatus status = otrSession.getSessionStatus(); + if (body == null && status == SessionStatus.ENCRYPTED) { + mXmppConnectionService.onOtrSessionEstablished(conversation); + return null; + } else if (body == null && status == SessionStatus.FINISHED) { + conversation.resetOtrSession(); + mXmppConnectionService.updateConversationUi(); + return null; + } else if (body == null || (body.isEmpty())) { + return null; + } + if (body.startsWith(CryptoHelper.FILETRANSFER)) { + String key = body.substring(CryptoHelper.FILETRANSFER.length()); + conversation.setSymmetricKey(CryptoHelper.hexToBytes(key)); + return null; + } + if (clientMightSendHtml(conversation.getAccount(), from)) { + Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": received OTR message from bad behaving client. escaping HTML…"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + body = Html.fromHtml(body, Html.FROM_HTML_MODE_LEGACY).toString(); + } else { + body = Html.fromHtml(body).toString(); + } + } + + final OtrService otrService = conversation.getAccount().getOtrService(); + Message finishedMessage = new Message(conversation, body, Message.ENCRYPTION_OTR, Message.STATUS_RECEIVED); + finishedMessage.setFingerprint(otrService.getFingerprint(otrSession.getRemotePublicKey())); + conversation.setLastReceivedOtrMessageId(null); + + return finishedMessage; + } catch (Exception e) { + conversation.resetOtrSession(); + return null; + } + } + private Message parseAxolotlChat(Element axolotlMessage, Jid from, Conversation conversation, int status, final boolean checkedForDuplicates, boolean postpone) { final AxolotlService service = conversation.getAccount().getAxolotlService(); final XmppAxolotlMessage xmppAxolotlMessage; @@ -327,6 +420,11 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece final Jid from = packet.getFrom(); final String id = packet.getId(); if (from != null && id != null) { + final Message message = mXmppConnectionService.markMessage(account, + from.asBareJid(), + packet.getId(), + Message.STATUS_SEND_FAILED, + extractErrorMessage(packet)); if (id.startsWith(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX)) { final String sessionId = id.substring(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX.length()); mXmppConnectionService.getJingleConnectionManager() @@ -335,8 +433,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } if (id.startsWith(JingleRtpConnection.JINGLE_MESSAGE_PROCEED_ID_PREFIX)) { final String sessionId = id.substring(JingleRtpConnection.JINGLE_MESSAGE_PROCEED_ID_PREFIX.length()); - final String message = extractErrorMessage(packet); - mXmppConnectionService.getJingleConnectionManager().failProceed(account, from, sessionId, message); + final String errorMessage = extractErrorMessage(packet); + mXmppConnectionService.getJingleConnectionManager().failProceed(account, from, sessionId, errorMessage); return true; } mXmppConnectionService.markMessage(account, @@ -355,6 +453,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } } + + if (message != null) { + if (message.getEncryption() == Message.ENCRYPTION_OTR) { + Conversation conversation = (Conversation) message.getConversation(); + conversation.endOtrIfNeeded(); + } + } } return true; } @@ -368,6 +473,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } final MessagePacket packet; Long timestamp = null; + final boolean isForwarded; boolean isCarbon = false; String serverMsgId = null; final Element fin = original.findChild("fin", MessageArchiveService.Version.MAM_0.namespace); @@ -385,7 +491,9 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } timestamp = f.second; packet = f.first; + isForwarded = true; serverMsgId = result.getAttribute("id"); + query.incrementMessageCount(); if (handleErrorMessage(account, packet)) { return; @@ -403,8 +511,10 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } timestamp = f != null ? f.second : null; isCarbon = f != null; + isForwarded = isCarbon; } else { packet = original; + isForwarded = false; } if (timestamp == null) { @@ -449,6 +559,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece Log.e(Config.LOGTAG, account.getJid().asBareJid() + ": received groupchat (" + from + ") message on regular MAM request. skipping"); return; } + boolean isProperlyAddressed = (to != null) && (!to.isBareJid() || account.countPresences() == 0); boolean isMucStatusMessage = InvalidJid.hasValidFrom(packet) && from.isBareJid() && mucUserElement != null && mucUserElement.hasChild("status"); boolean selfAddressed; if (packet.fromAccount(account)) { @@ -547,7 +658,20 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } final Message message; - if (pgpEncrypted != null && Config.supportOpenPgp()) { + if (body != null && body.content.startsWith("?OTR") && Config.supportOtr()) { + if (!isForwarded && !isTypeGroupChat && isProperlyAddressed && !conversationMultiMode) { + message = parseOtrChat(body.content, from, remoteMsgId, conversation); + if (message == null) { + return; + } + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring OTR message from " + from + " isForwarded=" + Boolean.toString(isForwarded) + ", isProperlyAddressed=" + Boolean.valueOf(isProperlyAddressed)); + message = new Message(conversation, body.content, Message.ENCRYPTION_NONE, status); + if (body.count > 1) { + message.setBodyLanguage(body.language); + } + } + } else if (pgpEncrypted != null && Config.supportOpenPgp()) { message = new Message(conversation, pgpEncrypted, Message.ENCRYPTION_PGP, status); } else if (axolotlEncrypted != null && Config.supportOmemo()) { Jid origin; @@ -796,6 +920,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece processMessageReceipts(account, packet, remoteMsgId, query); } + if (message.getStatus() == Message.STATUS_RECEIVED + && conversation.getOtrSession() != null + && !conversation.getOtrSession().getSessionID().getUserID() + .equals(message.getCounterpart().getResource())) { + conversation.endOtrIfNeeded(); + } + mXmppConnectionService.databaseBackend.createMessage(message); final HttpConnectionManager manager = this.mXmppConnectionService.getHttpConnectionManager(); if (message.trusted() && message.treatAsDownloadable() && manager.getAutoAcceptFileSize() > 0) { diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 0c16df6af..bf3f07a35 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -61,6 +61,11 @@ import com.google.common.base.Objects; import com.google.common.base.Optional; import com.google.common.base.Strings; +import net.java.otr4j.session.Session; +import net.java.otr4j.session.SessionID; +import net.java.otr4j.session.SessionImpl; +import net.java.otr4j.session.SessionStatus; + import org.conscrypt.Conscrypt; import org.jxmpp.stringprep.libidn.LibIdnXmppStringprep; import org.openintents.openpgp.IOpenPgpService2; @@ -171,6 +176,7 @@ import eu.siacs.conversations.xmpp.OnUpdateBlocklist; import eu.siacs.conversations.xmpp.XmppConnection; import eu.siacs.conversations.xmpp.chatstate.ChatState; import eu.siacs.conversations.xmpp.forms.Data; +import eu.siacs.conversations.xmpp.jid.OtrJidHelper; import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection; import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager; import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection; @@ -255,9 +261,18 @@ public class XmppConnectionService extends Service { Conversation conversation = find(getConversations(), contact); if (conversation != null) { if (online) { + conversation.endOtrIfNeeded(); if (contact.getPresences().size() == 1) { sendUnsentMessages(conversation); } + } else { + //check if the resource we are haveing a conversation with is still online + if (conversation.hasValidOtrSession()) { + String otrResource = conversation.getOtrSession().getSessionID().getUserID(); + if (!(Arrays.asList(contact.getPresences().toResourceArray()).contains(otrResource))) { + conversation.endOtrIfNeeded(); + } + } } } }; @@ -447,6 +462,9 @@ public class XmppConnectionService extends Service { if (conversation.getAccount() == account && !pendingJoin && !inProgressJoin) { + if (!conversation.startOtrIfNeeded()) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": couldn't start OTR with " + conversation.getContact().getJid() + " when needed"); + } sendUnsentMessages(conversation); } } @@ -1704,6 +1722,12 @@ public class XmppConnectionService extends Service { } } + if (!resend && message.getEncryption() != Message.ENCRYPTION_OTR) { + conversation.endOtrIfNeeded(); + conversation.findUnsentMessagesWithEncryption(Message.ENCRYPTION_OTR, + message1 -> markMessage(message1, Message.STATUS_SEND_FAILED)); + } + final boolean inProgressJoin = isJoinInProgress(conversation); @@ -1736,6 +1760,30 @@ public class XmppConnectionService extends Service { packet = mMessageGenerator.generatePgpChat(message); } break; + case Message.ENCRYPTION_OTR: + SessionImpl otrSession = conversation.getOtrSession(); + if (otrSession != null && otrSession.getSessionStatus() == SessionStatus.ENCRYPTED) { + try { + message.setCounterpart(OtrJidHelper.fromSessionID(otrSession.getSessionID())); + } catch (IllegalArgumentException e) { + break; + } + if (message.needsUploading()) { + mJingleConnectionManager.startJingleFileTransfer(message); + } else { + packet = mMessageGenerator.generateOtrChat(message); + } + } else if (otrSession == null) { + if (message.fixCounterpart()) { + conversation.startOtrSession(message.getCounterpart().getResource(), true); + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could not fix counterpart for OTR message to contact " + message.getCounterpart()); + break; + } + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + " OTR session with " + message.getContact() + " is in wrong state: " + otrSession.getSessionStatus().toString()); + } + break; case Message.ENCRYPTION_AXOLOTL: message.setFingerprint(account.getAxolotlService().getOwnFingerprint()); if (message.needsUploading()) { @@ -1789,6 +1837,12 @@ public class XmppConnectionService extends Service { } } break; + case Message.ENCRYPTION_OTR: + if (!conversation.hasValidOtrSession() && message.getCounterpart() != null) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": create otr session without starting for " + message.getContact().getJid()); + conversation.startOtrSession(message.getCounterpart().getResource(), false); + } + break; case Message.ENCRYPTION_AXOLOTL: message.setFingerprint(account.getAxolotlService().getOwnFingerprint()); break; @@ -3922,6 +3976,12 @@ public class XmppConnectionService extends Service { if (conversation.getAccount() == account) { if (conversation.getMode() == Conversation.MODE_MULTI) { leaveMuc(conversation, true); + } else { + if (conversation.endOtrIfNeeded()) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + + ": ended otr session with " + + conversation.getJid()); + } } } } @@ -3978,6 +4038,39 @@ public class XmppConnectionService extends Service { pushContactToServer(contact, preAuth); } + public void onOtrSessionEstablished(Conversation conversation) { + final Account account = conversation.getAccount(); + final Session otrSession = conversation.getOtrSession(); + Log.d(Config.LOGTAG, + account.getJid().asBareJid() + " otr session established with " + + conversation.getJid() + "/" + + otrSession.getSessionID().getUserID()); + conversation.findUnsentMessagesWithEncryption(Message.ENCRYPTION_OTR, new Conversation.OnMessageFound() { + + @Override + public void onMessageFound(Message message) { + SessionID id = otrSession.getSessionID(); + try { + message.setCounterpart(Jid.of(id.getAccountID() + "/" + id.getUserID())); + } catch (IllegalArgumentException e) { + return; + } + if (message.needsUploading()) { + mJingleConnectionManager.startJingleFileTransfer(message); + } else { + MessagePacket outPacket = mMessageGenerator.generateOtrChat(message); + if (outPacket != null) { + mMessageGenerator.addDelay(outPacket, message.getTimeSent()); + message.setStatus(Message.STATUS_SEND); + databaseBackend.updateMessage(message, false); + sendMessagePacket(account, outPacket); + } + } + updateConversationUi(); + } + }); + } + public void pushContactToServer(final Contact contact) { pushContactToServer(contact, null); } @@ -4503,6 +4596,7 @@ public class XmppConnectionService extends Service { return false; } else { final Message message = conversation.findSentMessageWithUuid(uuid); + if (message != null) { if (message.getServerMsgId() == null) { message.setServerMsgId(serverMessageId); @@ -4805,6 +4899,11 @@ public class XmppConnectionService extends Service { setMemorizingTrustManager(tm); } + public void syncRosterToDisk(final Account account) { + Runnable runnable = () -> databaseBackend.writeRoster(account.getRoster()); + mDatabaseWriterExecutor.execute(runnable); + } + public LruCache getBitmapCache() { return this.mBitmapCache; } @@ -5272,10 +5371,14 @@ public class XmppConnectionService extends Service { } public boolean verifyFingerprints(Contact contact, List fingerprints) { + boolean needsRosterWrite = false; boolean performedVerification = false; final AxolotlService axolotlService = contact.getAccount().getAxolotlService(); for (XmppUri.Fingerprint fp : fingerprints) { - if (fp.type == XmppUri.FingerprintType.OMEMO) { + if (fp.type == XmppUri.FingerprintType.OTR) { + performedVerification |= contact.addOtrFingerprint(fp.fingerprint); + needsRosterWrite |= performedVerification; + } else if (fp.type == XmppUri.FingerprintType.OMEMO) { String fingerprint = "05" + fp.fingerprint.replaceAll("\\s", ""); FingerprintStatus fingerprintStatus = axolotlService.getFingerprintTrust(fingerprint); if (fingerprintStatus != null) { @@ -5288,6 +5391,11 @@ public class XmppConnectionService extends Service { } } } + + if (needsRosterWrite) { + syncRosterToDisk(contact.getAccount()); + } + return performedVerification; } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 1b6eb44a4..50c870489 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -79,6 +79,8 @@ import androidx.viewpager.widget.PagerAdapter; import com.google.common.base.Optional; import com.google.common.collect.ImmutableList; +import net.java.otr4j.session.SessionStatus; + import org.jetbrains.annotations.NotNull; import java.io.File; @@ -223,6 +225,14 @@ public class ConversationFragment extends XmppFragment private ConversationsActivity activity; private Vibrator vibrator; private boolean reInitRequiredOnStart = true; + + protected OnClickListener clickToVerify = new OnClickListener() { + @Override + public void onClick(View v) { + activity.verifyOtrSessionDialog(conversation, v); + } + }; + @ColorInt private int primaryColor = -1; @@ -534,6 +544,20 @@ public class ConversationFragment extends XmppFragment } } }; + + private OnClickListener mAnswerSmpClickListener = new OnClickListener() { + @Override + public void onClick(View view) { + Intent intent = new Intent(activity, VerifyOTRActivity.class); + intent.setAction(VerifyOTRActivity.ACTION_VERIFY_CONTACT); + intent.putExtra(EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString()); + intent.putExtra(VerifyOTRActivity.EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString()); + intent.putExtra("mode", VerifyOTRActivity.MODE_ANSWER_QUESTION); + startActivity(intent); + activity.overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + } + }; + protected OnClickListener clickToDecryptListener = new OnClickListener() { @@ -1064,6 +1088,8 @@ public class ConversationFragment extends XmppFragment message.setUuid(UUID.randomUUID().toString()); } switch (conversation.getNextEncryption()) { + case Message.ENCRYPTION_OTR: + sendOtrMessage(message); case Message.ENCRYPTION_PGP: sendPgpMessage(message); break; @@ -3550,6 +3576,14 @@ public class ConversationFragment extends XmppFragment } } else if (account.hasPendingPgpIntent(conversation)) { showSnackbar(R.string.openpgp_messages_found, R.string.decrypt, clickToDecryptListener); + } else if (mode == Conversation.MODE_SINGLE + && conversation.smpRequested()) { + showSnackbar(R.string.smp_requested, R.string.verify, this.mAnswerSmpClickListener); + } else if (mode == Conversation.MODE_SINGLE + && conversation.hasValidOtrSession() + && (conversation.getOtrSession().getSessionStatus() == SessionStatus.ENCRYPTED) + && (!conversation.isOtrFingerprintVerified())) { + showSnackbar(R.string.unknown_otr_fingerprint, R.string.verify, clickToVerify); } else if (connection != null && connection.getFeatures().blocking() && conversation.countMessages() != 0 @@ -3650,6 +3684,11 @@ public class ConversationFragment extends XmppFragment new Handler() .post( () -> { + if (conversation.isInHistoryPart()) { + conversation.jumpToLatest(); + refresh(false); + } + int size = messageList.size(); this.binding.messagesView.setSelection(size - 1); }); @@ -3937,6 +3976,17 @@ public class ConversationFragment extends XmppFragment messageSent(); } + protected void sendOtrMessage(final Message message) { + final ConversationsActivity activity = (ConversationsActivity) getActivity(); + final XmppConnectionService xmppService = activity.xmppConnectionService; + activity.selectPresence(conversation, + () -> { + message.setCounterpart(conversation.getNextCounterpart()); + xmppService.sendMessage(message); + messageSent(); + }); + } + protected void sendPgpMessage(final Message message) { final XmppConnectionService xmppService = activity.xmppConnectionService; final Contact contact = message.getConversation().getContact(); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index 79c70d46b..e98fb8f4e 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -63,9 +63,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.PopupMenu; import androidx.core.app.ActivityCompat; import androidx.databinding.DataBindingUtil; +import net.java.otr4j.session.SessionStatus; + import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.navigation.NavigationBarView; @@ -105,6 +108,7 @@ import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.OnUpdateBlocklist; import io.michaelrocks.libphonenumber.android.NumberParseException; +import me.drakeet.support.toast.ToastCompat; public class ConversationsActivity extends XmppActivity implements OnConversationSelected, OnConversationArchived, OnConversationsListItemUpdated, OnConversationRead, XmppConnectionService.OnAccountUpdate, XmppConnectionService.OnConversationUpdate, XmppConnectionService.OnRosterUpdate, OnUpdateBlocklist, XmppConnectionService.OnShowErrorToast, XmppConnectionService.OnAffiliationChanged { @@ -812,6 +816,33 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio } } + public void verifyOtrSessionDialog(final Conversation conversation, View view) { + if (!conversation.hasValidOtrSession() || conversation.getOtrSession().getSessionStatus() != SessionStatus.ENCRYPTED) { + ToastCompat.makeText(this, R.string.otr_session_not_started, Toast.LENGTH_LONG).show(); + return; + } + if (view == null) { + return; + } + PopupMenu popup = new PopupMenu(this, view); + popup.inflate(R.menu.verification_choices); + popup.setOnMenuItemClickListener(menuItem -> { + Intent intent = new Intent(ConversationsActivity.this, VerifyOTRActivity.class); + intent.setAction(VerifyOTRActivity.ACTION_VERIFY_CONTACT); + intent.putExtra("contact", conversation.getContact().getJid().asBareJid().toString()); + intent.putExtra(EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString()); + switch (menuItem.getItemId()) { + case R.id.ask_question: + intent.putExtra("mode", VerifyOTRActivity.MODE_ASK_QUESTION); + break; + } + startActivity(intent); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + }); + popup.show(); + } + @Override public void onConversationArchived(Conversation conversation) { if (performRedirectIfNecessary(conversation, false)) { diff --git a/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java b/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java index 8dabc77cd..ec537d703 100644 --- a/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java @@ -265,7 +265,6 @@ public class SendLogActivity extends ActionBarActivity { log.insert(0, mAdditonalInfo); } - android.util.Log.e("35fd", log.toString()); writer.write(log.toString()); } catch (IOException e){ diff --git a/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java b/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java new file mode 100644 index 000000000..2eb69e5e2 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java @@ -0,0 +1,450 @@ +package eu.siacs.conversations.ui; + +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; + +import net.java.otr4j.OtrException; +import net.java.otr4j.session.Session; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.utils.CryptoHelper; +import eu.siacs.conversations.utils.XmppUri; +import eu.siacs.conversations.xmpp.Jid; +import me.drakeet.support.toast.ToastCompat; + +public class VerifyOTRActivity extends XmppActivity implements XmppConnectionService.OnConversationUpdate { + + public static final String ACTION_VERIFY_CONTACT = "verify_contact"; + public static final int MODE_SCAN_FINGERPRINT = -0x0502; + public static final int MODE_ASK_QUESTION = 0x0503; + public static final int MODE_ANSWER_QUESTION = 0x0504; + public static final int MODE_MANUAL_VERIFICATION = 0x0505; + + private LinearLayout mManualVerificationArea; + private LinearLayout mSmpVerificationArea; + private TextView mRemoteFingerprint; + private TextView mYourFingerprint; + private TextView mVerificationExplain; + private TextView mStatusMessage; + private TextView mSharedSecretHint; + private EditText mSharedSecretHintEditable; + private EditText mSharedSecretSecret; + private Button mLeftButton; + private Button mRightButton; + private Account mAccount; + private Conversation mConversation; + private int mode = MODE_MANUAL_VERIFICATION; + private XmppUri mPendingUri = null; + + private DialogInterface.OnClickListener mVerifyFingerprintListener = new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialogInterface, int click) { + mConversation.verifyOtrFingerprint(); + xmppConnectionService.syncRosterToDisk(mConversation.getAccount()); + ToastCompat.makeText(VerifyOTRActivity.this, R.string.verified, Toast.LENGTH_SHORT).show(); + finish(); + } + }; + + private View.OnClickListener mCreateSharedSecretListener = new View.OnClickListener() { + @Override + public void onClick(final View view) { + if (isAccountOnline()) { + final String question = mSharedSecretHintEditable.getText().toString(); + final String secret = mSharedSecretSecret.getText().toString(); + if (question.trim().isEmpty()) { + mSharedSecretHintEditable.requestFocus(); + mSharedSecretHintEditable.setError(getString(R.string.shared_secret_hint_should_not_be_empty)); + } else if (secret.trim().isEmpty()) { + mSharedSecretSecret.requestFocus(); + mSharedSecretSecret.setError(getString(R.string.shared_secret_can_not_be_empty)); + } else { + mSharedSecretSecret.setError(null); + mSharedSecretHintEditable.setError(null); + initSmp(question, secret); + updateView(); + } + } + } + }; + private View.OnClickListener mCancelSharedSecretListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + if (isAccountOnline()) { + abortSmp(); + updateView(); + } + } + }; + private View.OnClickListener mRespondSharedSecretListener = new View.OnClickListener() { + + @Override + public void onClick(View view) { + if (isAccountOnline()) { + final String question = mSharedSecretHintEditable.getText().toString(); + final String secret = mSharedSecretSecret.getText().toString(); + respondSmp(question, secret); + updateView(); + } + } + }; + private View.OnClickListener mRetrySharedSecretListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + mConversation.smp().status = Conversation.Smp.STATUS_NONE; + mConversation.smp().hint = null; + mConversation.smp().secret = null; + updateView(); + } + }; + private View.OnClickListener mFinishListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + mConversation.smp().status = Conversation.Smp.STATUS_NONE; + finish(); + } + }; + + protected boolean initSmp(final String question, final String secret) { + final Session session = mConversation.getOtrSession(); + if (session != null) { + try { + session.initSmp(question, secret); + mConversation.smp().status = Conversation.Smp.STATUS_WE_REQUESTED; + mConversation.smp().secret = secret; + mConversation.smp().hint = question; + return true; + } catch (OtrException e) { + return false; + } + } else { + return false; + } + } + + protected boolean abortSmp() { + final Session session = mConversation.getOtrSession(); + if (session != null) { + try { + session.abortSmp(); + mConversation.smp().status = Conversation.Smp.STATUS_NONE; + mConversation.smp().hint = null; + mConversation.smp().secret = null; + return true; + } catch (OtrException e) { + return false; + } + } else { + return false; + } + } + + protected boolean respondSmp(final String question, final String secret) { + final Session session = mConversation.getOtrSession(); + if (session != null) { + try { + session.respondSmp(question, secret); + return true; + } catch (OtrException e) { + return false; + } + } else { + return false; + } + } + + protected boolean verifyWithUri(XmppUri uri) { + Contact contact = mConversation.getContact(); + if (this.mConversation.getContact().getJid().equals(uri.getJid()) && uri.hasFingerprints()) { + xmppConnectionService.verifyFingerprints(contact, uri.getFingerprints()); + ToastCompat.makeText(this, R.string.verified, Toast.LENGTH_SHORT).show(); + updateView(); + return true; + } else { + ToastCompat.makeText(this, R.string.could_not_verify_fingerprint, Toast.LENGTH_SHORT).show(); + return false; + } + } + + protected boolean isAccountOnline() { + if (this.mAccount.getStatus() != Account.State.ONLINE) { + ToastCompat.makeText(this, R.string.not_connected_try_again, Toast.LENGTH_SHORT).show(); + return false; + } else { + return true; + } + } + + protected boolean handleIntent(Intent intent) { + if (intent != null && intent.getAction().equals(ACTION_VERIFY_CONTACT)) { + this.mAccount = extractAccount(intent); + if (this.mAccount == null) { + return false; + } + try { + this.mConversation = this.xmppConnectionService.find(this.mAccount, Jid.of(intent.getExtras().getString("contact")), null); + if (this.mConversation == null) { + return false; + } + } catch (final IllegalArgumentException ignored) { + ignored.printStackTrace(); + return false; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + this.mode = intent.getIntExtra("mode", MODE_MANUAL_VERIFICATION); + // todo scan OTR fingerprint + if (this.mode == MODE_SCAN_FINGERPRINT) { + Log.d(Config.LOGTAG, "Scan OTR fingerprint is not implemented in this version"); + //new IntentIntegrator(this).initiateScan(); + return false; + } + return true; + } else { + return false; + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + // todo onActivityResult for OTR scan + Log.d(Config.LOGTAG, "Scan OTR fingerprint result is not implemented in this version"); + /*if ((requestCode & 0xFFFF) == IntentIntegrator.REQUEST_CODE) { + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent); + if (scanResult != null && scanResult.getFormatName() != null) { + String data = scanResult.getContents(); + XmppUri uri = new XmppUri(data); + if (xmppConnectionServiceBound) { + verifyWithUri(uri); + finish(); + } else { + this.mPendingUri = uri; + } + } else { + finish(); + } + }*/ + super.onActivityResult(requestCode, requestCode, intent); + } + + @Override + protected void onBackendConnected() { + if (handleIntent(getIntent())) { + updateView(); + } else if (mPendingUri != null) { + verifyWithUri(mPendingUri); + finish(); + mPendingUri = null; + } + setIntent(null); + } + + protected void updateView() { + if (this.mConversation != null && this.mConversation.hasValidOtrSession()) { + final ActionBar actionBar = getSupportActionBar(); + this.mVerificationExplain.setText(R.string.no_otr_session_found); + invalidateOptionsMenu(); + switch (this.mode) { + case MODE_ASK_QUESTION: + if (actionBar != null) { + actionBar.setTitle(R.string.ask_question); + } + this.updateViewAskQuestion(); + break; + case MODE_ANSWER_QUESTION: + if (actionBar != null) { + actionBar.setTitle(R.string.smp_requested); + } + this.updateViewAnswerQuestion(); + break; + case MODE_MANUAL_VERIFICATION: + default: + if (actionBar != null) { + actionBar.setTitle(R.string.manually_verify); + } + this.updateViewManualVerification(); + break; + } + } else { + this.mManualVerificationArea.setVisibility(View.GONE); + this.mSmpVerificationArea.setVisibility(View.GONE); + } + } + + protected void updateViewManualVerification() { + this.mVerificationExplain.setText(R.string.manual_verification_explanation); + this.mManualVerificationArea.setVisibility(View.VISIBLE); + this.mSmpVerificationArea.setVisibility(View.GONE); + this.mYourFingerprint.setText(CryptoHelper.prettifyFingerprint(this.mAccount.getOtrFingerprint())); + this.mRemoteFingerprint.setText(CryptoHelper.prettifyFingerprint(this.mConversation.getOtrFingerprint())); + if (this.mConversation.isOtrFingerprintVerified()) { + deactivateButton(this.mRightButton, R.string.verified); + activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener); + } else { + activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener); + activateButton(this.mRightButton, R.string.verify, new View.OnClickListener() { + @Override + public void onClick(View view) { + showManuallyVerifyDialog(); + } + }); + } + } + + protected void updateViewAskQuestion() { + this.mManualVerificationArea.setVisibility(View.GONE); + this.mSmpVerificationArea.setVisibility(View.VISIBLE); + this.mVerificationExplain.setText(R.string.smp_explain_question); + final int smpStatus = this.mConversation.smp().status; + switch (smpStatus) { + case Conversation.Smp.STATUS_WE_REQUESTED: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHintEditable.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.setVisibility(View.VISIBLE); + this.mSharedSecretHintEditable.setText(this.mConversation.smp().hint); + this.mSharedSecretSecret.setText(this.mConversation.smp().secret); + this.activateButton(this.mLeftButton, R.string.cancel, this.mCancelSharedSecretListener); + this.deactivateButton(this.mRightButton, R.string.in_progress); + break; + case Conversation.Smp.STATUS_FAILED: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHintEditable.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.requestFocus(); + this.mSharedSecretSecret.setError(getString(R.string.secrets_do_not_match)); + this.deactivateButton(this.mLeftButton, R.string.cancel); + this.activateButton(this.mRightButton, R.string.try_again, this.mRetrySharedSecretListener); + break; + case Conversation.Smp.STATUS_VERIFIED: + this.mSharedSecretHintEditable.setText(""); + this.mSharedSecretHintEditable.setVisibility(View.GONE); + this.mSharedSecretSecret.setText(""); + this.mSharedSecretSecret.setVisibility(View.GONE); + this.mStatusMessage.setVisibility(View.VISIBLE); + this.deactivateButton(this.mLeftButton, R.string.cancel); + this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener); + break; + default: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHintEditable.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.setVisibility(View.VISIBLE); + this.activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener); + this.activateButton(this.mRightButton, R.string.ask_question, this.mCreateSharedSecretListener); + break; + } + } + + protected void updateViewAnswerQuestion() { + this.mManualVerificationArea.setVisibility(View.GONE); + this.mSmpVerificationArea.setVisibility(View.VISIBLE); + this.mVerificationExplain.setText(R.string.smp_explain_answer); + this.mSharedSecretHintEditable.setVisibility(View.GONE); + this.mSharedSecretHint.setVisibility(View.VISIBLE); + this.deactivateButton(this.mLeftButton, R.string.cancel); + final int smpStatus = this.mConversation.smp().status; + switch (smpStatus) { + case Conversation.Smp.STATUS_CONTACT_REQUESTED: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHint.setText(this.mConversation.smp().hint); + this.activateButton(this.mRightButton, R.string.respond, this.mRespondSharedSecretListener); + break; + case Conversation.Smp.STATUS_VERIFIED: + this.mSharedSecretHintEditable.setText(""); + this.mSharedSecretHintEditable.setVisibility(View.GONE); + this.mSharedSecretHint.setVisibility(View.GONE); + this.mSharedSecretSecret.setText(""); + this.mSharedSecretSecret.setVisibility(View.GONE); + this.mStatusMessage.setVisibility(View.VISIBLE); + this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener); + break; + case Conversation.Smp.STATUS_FAILED: + default: + this.mSharedSecretSecret.requestFocus(); + this.mSharedSecretSecret.setError(getString(R.string.secrets_do_not_match)); + this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener); + break; + } + } + + protected void activateButton(Button button, int text, View.OnClickListener listener) { + button.setEnabled(true); + button.setText(text); + button.setOnClickListener(listener); + } + + protected void deactivateButton(Button button, int text) { + button.setEnabled(false); + button.setText(text); + button.setOnClickListener(null); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_verify_otr); + this.mRemoteFingerprint = findViewById(R.id.remote_fingerprint); + this.mYourFingerprint = findViewById(R.id.your_fingerprint); + this.mLeftButton = findViewById(R.id.left_button); + this.mRightButton = findViewById(R.id.right_button); + this.mVerificationExplain = findViewById(R.id.verification_explanation); + this.mStatusMessage = findViewById(R.id.status_message); + this.mSharedSecretSecret = findViewById(R.id.shared_secret_secret); + this.mSharedSecretHintEditable = findViewById(R.id.shared_secret_hint_editable); + this.mSharedSecretHint = findViewById(R.id.shared_secret_hint); + this.mManualVerificationArea = findViewById(R.id.manual_verification_area); + this.mSmpVerificationArea = findViewById(R.id.smp_verification_area); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.verify_otr, menu); + return true; + } + + private void showManuallyVerifyDialog() { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.manually_verify); + builder.setMessage(R.string.are_you_sure_verify_fingerprint); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.verify, mVerifyFingerprintListener); + builder.create().show(); + } + + @Override + protected String getShareableUri() { + if (mAccount != null) { + return mAccount.getShareableUri(); + } else { + return ""; + } + } + + public void onConversationUpdate() { + refreshUi(); + } + + @Override + protected void refreshUiReal() { + updateView(); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java index 47ce3be01..3e1f878a9 100644 --- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java @@ -56,6 +56,8 @@ import androidx.databinding.DataBindingUtil; import com.google.common.base.Strings; +import net.java.otr4j.session.SessionID; + import java.io.IOException; import java.lang.ref.WeakReference; import java.util.ArrayList; @@ -446,7 +448,18 @@ public abstract class XmppActivity extends ActionBarActivity { public void selectPresence(final Conversation conversation, final PresenceSelector.OnPresenceSelected listener) { final Contact contact = conversation.getContact(); - if (contact.showInRoster() || contact.isSelf()) { + + if (conversation.hasValidOtrSession()) { + SessionID id = conversation.getOtrSession().getSessionID(); + Jid jid; + try { + jid = Jid.of(id.getAccountID() + "/" + id.getUserID()); + } catch (IllegalArgumentException e) { + jid = null; + } + conversation.setNextCounterpart(jid); + listener.onPresenceSelected(); + } else if (contact.showInRoster() || contact.isSelf()) { final Presences presences = contact.getPresences(); if (presences.size() == 0) { if (contact.isSelf()) { diff --git a/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java b/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java index 85ea2bb86..59399fbca 100644 --- a/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java @@ -41,6 +41,7 @@ public final class CryptoHelper { private static final int PW_LENGTH = 12; private static final char[] VOWELS = "aeiou".toCharArray(); private static final char[] CONSONANTS = "bcfghjklmnpqrstvwxyz".toCharArray(); + public static final String FILETRANSFER = "?FILETRANSFERv1:"; private final static char[] hexArray = "0123456789abcdef".toCharArray(); public static String bytesToHex(byte[] bytes) { diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index f684c5f46..d1b863082 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -609,6 +609,8 @@ public class UIHelper { } else { return context.getString(R.string.send_message_to_x, conversation.getName()); } + case Message.ENCRYPTION_OTR: + return context.getString(R.string.send_otr_message); case Message.ENCRYPTION_AXOLOTL: AxolotlService axolotlService = conversation.getAccount().getAxolotlService(); if (axolotlService != null && axolotlService.trustedSessionVerified(conversation)) { diff --git a/src/main/java/eu/siacs/conversations/utils/XmppUri.java b/src/main/java/eu/siacs/conversations/utils/XmppUri.java index 6c3075be9..6f7b8481c 100644 --- a/src/main/java/eu/siacs/conversations/utils/XmppUri.java +++ b/src/main/java/eu/siacs/conversations/utils/XmppUri.java @@ -29,6 +29,7 @@ public class XmppUri { public static final String PARAMETER_PRE_AUTH = "preauth"; public static final String PARAMETER_IBR = "ibr"; private static final String OMEMO_URI_PARAM = "omemo-sid-"; + private static final String OTR_URI_PARAM = "otr-fingerprint"; protected Uri uri; protected String jid; private List fingerprints = new ArrayList<>(); @@ -111,6 +112,8 @@ public class XmppUri { if (type == XmppUri.FingerprintType.OMEMO) { builder.append(XmppUri.OMEMO_URI_PARAM); builder.append(fingerprints.get(i).deviceId); + } else if (type == XmppUri.FingerprintType.OTR) { + builder.append(XmppUri.OTR_URI_PARAM); } builder.append('='); builder.append(fingerprints.get(i).fingerprint); @@ -241,7 +244,8 @@ public class XmppUri { } public enum FingerprintType { - OMEMO + OMEMO, + OTR } public static class Fingerprint { @@ -249,6 +253,10 @@ public class XmppUri { public final String fingerprint; final int deviceId; + public Fingerprint(FingerprintType type, String fingerprint) { + this(type, fingerprint, 0); + } + public Fingerprint(FingerprintType type, String fingerprint, int deviceId) { this.type = type; this.fingerprint = fingerprint; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java b/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java new file mode 100644 index 000000000..09c4dec53 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java @@ -0,0 +1,17 @@ + +package eu.siacs.conversations.xmpp.jid; + +import net.java.otr4j.session.SessionID; + +import eu.siacs.conversations.xmpp.Jid; + +public final class OtrJidHelper { + + public static Jid fromSessionID(final SessionID id) throws IllegalArgumentException { + if (id.getUserID().isEmpty()) { + return Jid.of(id.getAccountID()); + } else { + return Jid.of(id.getAccountID() + "/" + id.getUserID()); + } + } +} \ No newline at end of file diff --git a/src/main/res/layout/activity_verify_otr.xml b/src/main/res/layout/activity_verify_otr.xml new file mode 100644 index 000000000..5c15dd3e6 --- /dev/null +++ b/src/main/res/layout/activity_verify_otr.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +