Threema 3 роки тому
батько
коміт
f21878ee08
45 змінених файлів з 2358 додано та 1650 видалено
  1. 7 8
      app/build.gradle
  2. 146 0
      app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt
  3. 5 5
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  4. 70 57
      app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java
  5. 7 23
      app/src/main/java/ch/threema/app/adapters/decorators/LocationChatAdapterDecorator.java
  6. 2 1
      app/src/main/java/ch/threema/app/asynctasks/EmptyChatAsyncTask.java
  7. 11 5
      app/src/main/java/ch/threema/app/camera/CameraFragment.kt
  8. 4 3
      app/src/main/java/ch/threema/app/camera/VideoEditView.java
  9. 24 30
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  10. 152 22
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  11. 3 3
      app/src/main/java/ch/threema/app/fragments/mediaviews/AudioViewFragment.java
  12. 4 3
      app/src/main/java/ch/threema/app/fragments/mediaviews/VideoViewFragment.java
  13. 4 3
      app/src/main/java/ch/threema/app/mediaattacher/VideoPreviewFragment.java
  14. 2 0
      app/src/main/java/ch/threema/app/services/ContactService.java
  15. 25 0
      app/src/main/java/ch/threema/app/services/ContactServiceImpl.java
  16. 7 3
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java
  17. 3 1
      app/src/main/java/ch/threema/app/ui/AvatarEditView.java
  18. 1305 1298
      app/src/main/java/ch/threema/app/ui/ZoomableExoPlayerView.java
  19. 1 1
      app/src/main/java/ch/threema/app/utils/AnimationUtil.java
  20. 8 0
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  21. 130 0
      app/src/main/java/ch/threema/app/utils/GeoLocationUtil.java
  22. 25 2
      app/src/main/java/ch/threema/app/utils/LinkifyUtil.java
  23. 15 0
      app/src/main/java/ch/threema/app/utils/VideoUtil.java
  24. 1 1
      app/src/main/java/ch/threema/app/webclient/converter/Utils.java
  25. 0 14
      app/src/main/res/drawable/ic_new_badge_filled.xml
  26. 7 0
      app/src/main/res/drawable/ic_outline_report_24.xml
  27. 7 0
      app/src/main/res/drawable/shape_recently_added_contacts_overlay.xml
  28. 6 5
      app/src/main/res/layout/item_contact_list.xml
  29. 114 135
      app/src/main/res/layout/item_contact_list_recently_added.xml
  30. 2 0
      app/src/main/res/values-de/strings.xml
  31. 1 0
      app/src/main/res/values/attrs.xml
  32. 2 1
      app/src/main/res/values/colors.xml
  33. 2 0
      app/src/main/res/values/strings.xml
  34. 14 1
      app/src/main/res/values/styles.xml
  35. 5 1
      app/src/main/res/values/themes.xml
  36. 4 0
      app/src/onprem/res/values/colors.xml
  37. 4 0
      app/src/red/res/values/colors.xml
  38. 4 0
      app/src/store_google_work/res/values/colors.xml
  39. 1 0
      domain/src/main/proto/.gitignore
  40. 2 0
      domain/src/main/proto/call-signaling.proto
  41. 17 5
      domain/src/main/proto/common.proto
  42. 186 0
      domain/src/main/proto/csp-e2e-fs.proto
  43. 9 8
      domain/src/main/proto/csp-e2e.proto
  44. 9 11
      domain/src/main/proto/url-payloads.proto
  45. 1 0
      domain/src/main/proto/version.txt

+ 7 - 8
app/build.gradle

@@ -13,7 +13,7 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 
 // version codes
-def app_version = "4.811"
+def app_version = "4.82"
 def beta_suffix = "" // with leading dash
 
 /**
@@ -93,7 +93,7 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 748
+        versionCode 751
         versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter - needs to match mime types below
@@ -676,18 +676,17 @@ dependencies {
     implementation 'commons-io:commons-io:2.6'
     implementation "org.slf4j:slf4j-api:$slf4j_version"
     implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.24'
-    implementation 'com.github.CanHub:Android-Image-Cropper:4.1.0'
+    implementation 'com.github.CanHub:Android-Image-Cropper:4.3.0'
     implementation 'com.datatheorem.android.trustkit:trustkit:1.1.5'
     implementation 'me.zhanghai.android.fastscroll:library:1.1.7'
     implementation 'com.googlecode.ez-vcard:ez-vcard:0.11.3'
 
     // AndroidX / Jetpack support libraries
-    implementation "androidx.core:core-ktx:1.7.0"
     implementation "androidx.preference:preference-ktx:1.2.0"
     implementation 'androidx.recyclerview:recyclerview:1.2.1'
     implementation 'androidx.palette:palette-ktx:1.0.0'
     implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
-    implementation 'androidx.appcompat:appcompat:1.4.1'
+    implementation 'androidx.appcompat:appcompat:1.4.2'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.biometric:biometric:1.1.0'
     implementation "androidx.work:work-runtime:2.7.1"
@@ -712,9 +711,9 @@ dependencies {
     implementation 'androidx.room:room-runtime:2.4.2'
     kapt 'androidx.room:room-compiler:2.4.2'
 
-    implementation 'com.google.android.material:material:1.6.0'
-    implementation 'com.google.android.exoplayer:exoplayer-core:2.17.1'
-    implementation 'com.google.android.exoplayer:exoplayer-ui:2.17.1'
+    implementation 'com.google.android.material:material:1.6.1'
+    implementation 'com.google.android.exoplayer:exoplayer-core:2.18.0'
+    implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.0'
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
     implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.45' // make sure to update this in domain's build.gradle as well
 

+ 146 - 0
app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt

@@ -0,0 +1,146 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2022 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.utils
+
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.style.URLSpan
+import android.widget.TextView
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class LinkifyUtilTest {
+
+    /**
+     * Get the spannable and a list of the URL spans as a pair. If there is no spannable, a pair
+     * containing of null and an empty list is returned.
+     */
+    private fun getSpanPair(text: String, includePhoneNumbers: Boolean = true): Pair<Spannable?, List<URLSpan>> {
+        val textView = TextView(InstrumentationRegistry.getInstrumentation().context)
+        textView.text = text
+        InstrumentationRegistry.getInstrumentation().runOnMainSync{
+            LinkifyUtil.getInstance().linkifyText(textView, includePhoneNumbers)
+        }
+        val spannableText = textView.text
+        if (spannableText !is SpannableString) {
+            return null to listOf()
+        }
+        val spans = spannableText.getSpans(0, text.length + 1, URLSpan::class.java).toList()
+        return spannableText to spans
+    }
+
+    /**
+     * Expects that there are the spans as defined by the given set of span starts and ends.
+     */
+    private fun assertSpans(text: String, spanPoints: Set<Pair<Int, Int>>, includePhoneNumbers: Boolean = true) {
+        val (spannable, spans) = getSpanPair(text, includePhoneNumbers)
+        assert(spannable != null || spans.isEmpty())
+        val actualSpanPoints = spans.map { spannable!!.getSpanStart(it) to spannable.getSpanEnd(it) }.toSet()
+        assertEquals(spanPoints, actualSpanPoints)
+    }
+
+    /**
+     * Expects that there is one single span over the entire string.
+     */
+    private fun assertSingleSpan(text: String, includePhoneNumbers: Boolean = true) {
+        assertSpans(text, setOf(0 to text.length), includePhoneNumbers)
+    }
+
+    /**
+     * Expects that there is one single span over the entire string except the first and last
+     * 'boundary' character.
+     */
+    private fun assertSingleBoundSpan(text: String, includePhoneNumbers: Boolean = true) {
+        assertSpans(text, setOf(1 to text.length - 1), includePhoneNumbers)
+    }
+
+    /**
+     * Expects that there are no spans in the given string.
+     */
+    private fun assertNoSpan(text: String, includePhoneNumbers: Boolean = true) {
+        assertSpans(text, setOf(), includePhoneNumbers)
+    }
+
+    @Test
+    fun testSimpleUrls() {
+        assertSingleSpan("www.threema.ch")
+        assertSingleSpan("a.b.c.d.e.f.threema.ch")
+        assertSingleSpan("https://www.threema.ch")
+    }
+
+    @Test
+    fun testInvalidUrls() {
+        assertNoSpan("www. threema .ch")
+        assertNoSpan("www.threema .ch")
+        assertNoSpan("www,threema,ch")
+    }
+
+    @Test
+    fun testSimpleGeoUris() {
+        assertSingleSpan("geo:12.21334534521,19.50")
+        assertSingleSpan("geo:15.4,-19.50")
+        assertSingleSpan("geo:-1.4,19.50")
+        assertSingleSpan("geo:-30.4,-22.5057;l=hallo!;b=23.1")
+        assertSingleSpan("geo:12.2,12")
+        assertSingleSpan("geo:1,2")
+        assertSingleSpan("geo:-3,4")
+        assertSingleSpan("geo:5,-6")
+        assertSingleSpan("geo:-7,-8")
+        assertSingleSpan("geo:-7,-8,-9")
+        assertSingleSpan("geo:-7,-8,-9;parameter=12+2")
+        assertSingleSpan("geo:12.2,12;u=23.234;label=1234;otherLabel=5678.9")
+    }
+
+    @Test
+    fun testInvalidGeoUris() {
+        assertNoSpan("geo:198.05,190.1")
+        assertNoSpan("geo:181.01,30.5")
+        assertNoSpan("geom:10.01,30.5")
+        assertNoSpan("geo:10.01")
+        assertNoSpan("geo:10.01,")
+        assertNoSpan("geo:10.01,;")
+        assertNoSpan("geo:10.01,.;")
+        assertNoSpan("geo:10.01,a")
+        assertNoSpan("geo:10.01,-a")
+        assertNoSpan("ge:10.01;30.5")
+        assertNoSpan("geo:10.01.1,30.5")
+        assertNoSpan("geo:10.01,30e.5")
+        assertNoSpan("geo:a10.01,30.5")
+    }
+
+    @Test
+    fun testBoundaryGeoUris() {
+        assertSingleBoundSpan(",geo:12.21334534521,19.50,")
+        assertSingleBoundSpan("(geo:15.4,-19.50)")
+        assertSingleBoundSpan(":geo:-1.4,19.50:")
+        assertSingleBoundSpan(" geo:-30.4,-22.5057;l=hallo!;b=23.1 ")
+    }
+
+    @Test
+    fun testMixedGeoUris() {
+        assertSpans("geo:1,2 geo:1,2", setOf(0 to 7, 8 to 15))
+        assertSpans("geo:1,2\ngeo:1,2", setOf(0 to 7, 8 to 15))
+        assertSpans("geo:1,2\nthreema.ch", setOf(0 to 7, 8 to 18))
+    }
+
+}

+ 5 - 5
app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java

@@ -573,11 +573,6 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					}
 				} else if (action.equals(Intent.ACTION_VIEW)) {
 					// called from action URL
-					if (lockAppService != null && lockAppService.isLocked()) {
-						finish();
-						return;
-					}
-
 					Uri dataUri = intent.getData();
 
 					if (TestUtil.required(dataUri)) {
@@ -591,6 +586,11 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 								("https".equals(scheme) && (BuildConfig.actionUrl.equals(host) || BuildConfig.contactActionUrl.equals(host)) && "/compose".equals(dataUri.getPath()))
 							)
 							{
+								if (lockAppService != null && lockAppService.isLocked()) {
+									finish();
+									return;
+								}
+
 								String text = dataUri.getQueryParameter("text");
 								if (!TestUtil.empty(text)) {
 									mediaItems.add(new MediaItem(dataUri, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, text));

+ 70 - 57
app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java

@@ -28,11 +28,14 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.widget.Filter;
 import android.widget.ImageView;
-import android.widget.ListView;
 import android.widget.SectionIndexer;
 import android.widget.TextView;
 
+import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
+
 import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.imageview.ShapeableImageView;
 import com.google.android.material.shape.ShapeAppearanceModel;
 
 import org.jetbrains.annotations.NotNull;
@@ -47,11 +50,10 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 
-import androidx.annotation.NonNull;
-import androidx.constraintlayout.widget.ConstraintLayout;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.emojis.EmojiTextView;
+import ch.threema.app.glide.AvatarOptions;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.PreferenceService;
@@ -68,7 +70,7 @@ import ch.threema.storage.models.ContactModel;
 
 public class ContactListAdapter extends FilterableListAdapter implements SectionIndexer {
 
-	private static final int MAX_RECENTLY_ADDED_CONTACTS = 3;
+	private static final int MAX_RECENTLY_ADDED_CONTACTS = 1;
 
 	private final ContactService contactService;
 	private final PreferenceService preferenceService;
@@ -97,6 +99,7 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 	public interface AvatarListener {
 		void onAvatarClick(View view, int position);
 		boolean onAvatarLongClick(View view, int position);
+		void onRecentlyAddedClick(ContactModel contactModel);
 	}
 
 	public ContactListAdapter(@NonNull Context context, @NonNull List<ContactModel> values, ContactService contactService, PreferenceService preferenceService, IdListService blackListIdentityService, AvatarListener avatarListener) {
@@ -126,7 +129,7 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 			}
 		}
 
-		if (recents.size() > 0) {
+		if (recents.size() > 0 && recents.size() < 10) {
 			// filter latest
 			Collections.sort(recents, (o1, o2) -> o2.getDateCreated().compareTo(o1.getDateCreated()));
 			this.recentlyAdded = recents.subList(0, Math.min(recents.size() , MAX_RECENTLY_ADDED_CONTACTS));
@@ -266,6 +269,8 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 		EmojiTextView initialView;
 		ImageView initialImageView;
 		int originalPosition;
+		int viewType;
+		ShapeableImageView shapeableAvatarView;
 	}
 
 	@NonNull
@@ -290,6 +295,7 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 			holder.nickTextView = itemView.findViewById(R.id.nick);
 			holder.verificationLevelView = itemView.findViewById(R.id.verification_level);
 			holder.avatarView = itemView.findViewById(R.id.avatar_view);
+			holder.shapeableAvatarView = itemView.findViewById(R.id.shapeable_avatar_view);
 			holder.blockedContactView = itemView.findViewById(R.id.blocked_contact);
 			holder.initialView = itemView.findViewById(R.id.initial);
 			holder.initialImageView = itemView.findViewById(R.id.initial_image);
@@ -317,25 +323,14 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 
 				MaterialCardView cardView = itemView.findViewById(R.id.recently_added_background);
 				cardView.setShapeAppearanceModel(shapeAppearanceModel);
+				cardView.setOnClickListener(v -> {
+					avatarListener.onRecentlyAddedClick(values.get(position));
+				});
 			}
 		} else {
 			holder = (ContactListHolder) itemView.getTag();
 		}
 
-		holder.avatarView.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				avatarListener.onAvatarClick(v, position);
-			}
-		});
-
-		holder.avatarView.setOnLongClickListener(new View.OnLongClickListener() {
-			@Override
-			public boolean onLongClick(View v) {
-				return avatarListener.onAvatarLongClick(v, position);
-			}
-		});
-
 		final ContactModel contactModel = values.get(position);
 		holder.originalPosition = ovalues.indexOf(contactModel);
 
@@ -350,11 +345,6 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 				holder.nameTextView,
 				highlightMatches(displayName, filterString, true));
 
-		holder.avatarView.setContentDescription(
-				ThreemaApplication.getAppContext().getString(R.string.edit_type_content_description,
-						ThreemaApplication.getAppContext().getString(R.string.mime_contact),
-						displayName));
-
 		AdapterUtil.styleContact(holder.nameTextView, contactModel);
 
 		ViewUtil.showAndSet(
@@ -363,56 +353,72 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 
 		AdapterUtil.styleContact(holder.idTextView, contactModel);
 
-		holder.verificationLevelView.setContactModel(contactModel);
+		if (holder.verificationLevelView != null) {
+			holder.verificationLevelView.setContactModel(contactModel);
+		}
 
 		ViewUtil.show(
 				holder.blockedContactView,
 				blackListIdentityService != null && blackListIdentityService.has(contactModel.getIdentity()));
 
-		if (displayName.length() > 1 && displayName.startsWith("~") && displayName.substring(1).equals(contactModel.getPublicNickName())) {
-			holder.nickTextView.setText("");
-		} else {
-			NameUtil.showNicknameInView(holder.nickTextView, contactModel, filterString, this);
+		if (holder.nickTextView != null) {
+			if (displayName.length() > 1 && displayName.startsWith("~") && displayName.substring(1).equals(contactModel.getPublicNickName())) {
+				holder.nickTextView.setText("");
+			} else {
+				NameUtil.showNicknameInView(holder.nickTextView, contactModel, filterString, this);
+			}
 		}
 
-		AvatarListItemUtil.loadAvatar(
+		if (viewType == VIEW_TYPE_RECENTLY_ADDED) {
+			contactService.loadAvatarIntoImage(contactModel, holder.shapeableAvatarView,
+				new AvatarOptions.Builder()
+					.setHighRes(true)
+					.toOptions()
+			);
+			holder.viewType = VIEW_TYPE_RECENTLY_ADDED;
+		} else {
+			AvatarListItemUtil.loadAvatar(
 				contactModel,
 				this.contactService,
 				holder);
+			holder.avatarView.setContentDescription(
+				ThreemaApplication.getAppContext().getString(R.string.edit_type_content_description,
+					ThreemaApplication.getAppContext().getString(R.string.mime_contact),
+					displayName));
+			holder.avatarView.setOnClickListener(new View.OnClickListener() {
+				@Override
+				public void onClick(View v) {
+					avatarListener.onAvatarClick(v, position);
+				}
+			});
+			holder.avatarView.setOnLongClickListener(new View.OnLongClickListener() {
+				@Override
+				public boolean onLongClick(View v) {
+					return avatarListener.onAvatarLongClick(v, position);
+				}
+			});
+			holder.viewType = VIEW_TYPE_NORMAL;
+		}
 
 		String previousInitial = PLACEHOLDER_CHANNELS;
 		String currentInitial = getInitial(contactModel, true, position);
 		if (position > 0) {
 			previousInitial = getInitial(values.get(position - 1), true, position - 1);
 		}
-		if (previousInitial != null && !previousInitial.equals(currentInitial)) {
-			if (RECENTLY_ADDED_SIGN.equals(currentInitial)) {
-				holder.initialView.setVisibility(View.GONE);
-				holder.initialImageView.setVisibility(View.VISIBLE);
-			} else {
-				holder.initialView.setText(currentInitial);
-				holder.initialView.setVisibility(View.VISIBLE);
-				holder.initialImageView.setVisibility(View.GONE);
-			}
-		} else {
-			holder.initialView.setVisibility(View.GONE);
-			holder.initialImageView.setVisibility(View.GONE);
-		}
-
-		holder.avatarView.setBadgeVisible(contactService.showBadge(contactModel));
 
-		//itemView.setEnabled(viewType == VIEW_TYPE_NORMAL);
-		if (viewType == VIEW_TYPE_RECENTLY_ADDED) {
-			itemView.setOnLongClickListener(v -> true);
-			itemView.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					ListView listView = (ListView) parent;
-					if (listView.getCheckedItemCount() == 0) {
-						listView.getOnItemClickListener().onItemClick(null, v, position, 0L);
-					}
+		if (holder.initialView != null) {
+			if (previousInitial != null && !previousInitial.equals(currentInitial)) {
+				if (!RECENTLY_ADDED_SIGN.equals(currentInitial)) {
+					holder.initialView.setText(currentInitial);
+					holder.initialView.setVisibility(View.VISIBLE);
+					holder.initialImageView.setVisibility(View.GONE);
 				}
-			});
+			} else {
+				if (!RECENTLY_ADDED_SIGN.equals(currentInitial)) {
+					holder.initialView.setVisibility(View.GONE);
+					holder.initialImageView.setVisibility(View.GONE);
+				}
+			}
 		}
 
 		return itemView;
@@ -559,11 +565,18 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 
 	public int getClickedItemPosition(View v) {
 		if (v != null && v.getTag() != null) {
-			return ((ContactListAdapter.ContactListHolder) v.getTag()).originalPosition;
+			return ((ContactListHolder) v.getTag()).originalPosition;
 		}
 		return 0;
 	}
 
+	public int getViewTypeFromView(View v) {
+		if (v != null && v.getTag() != null) {
+			return ((ContactListHolder) v.getTag()).viewType;
+		}
+		return VIEW_TYPE_NORMAL;
+	}
+
 	public String getInitial(int position) {
 		if (position < values.size() && position > 0) {
 			return getInitial(values.get(position), true, position);

+ 7 - 23
app/src/main/java/ch/threema/app/adapters/decorators/LocationChatAdapterDecorator.java

@@ -23,7 +23,6 @@ package ch.threema.app.adapters.decorators;
 
 import android.annotation.SuppressLint;
 import android.content.Context;
-import android.content.Intent;
 import android.graphics.Bitmap;
 import android.location.Location;
 import android.os.AsyncTask;
@@ -31,19 +30,14 @@ import android.view.View;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import com.mapbox.mapboxsdk.geometry.LatLng;
-
 import org.slf4j.Logger;
 
 import androidx.core.graphics.drawable.RoundedBitmapDrawable;
 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
 import ch.threema.app.R;
-import ch.threema.app.activities.MapActivity;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.BitmapUtil;
-import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.GeoLocationUtil;
-import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
@@ -70,7 +64,7 @@ public class LocationChatAdapterDecorator extends ChatAdapterDecorator {
 			@Override
 			public void onClick(View v) {
 				if(!isInChoiceMode()) {
-					viewLocation(getMessageModel(), v);
+					viewLocation(getMessageModel());
 				}
 			}
 		}, holder.messageBlockView);
@@ -148,22 +142,12 @@ public class LocationChatAdapterDecorator extends ChatAdapterDecorator {
 		}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, holder);
 	}
 
-	private void viewLocation(AbstractMessageModel messageModel, final View v) {
-		if (!ConfigUtils.hasNoMapLibreSupport()) {
-			if (messageModel != null) {
-				LocationDataModel locationData = messageModel.getLocationData();
-				if (locationData != null) {
-					Intent intent = new Intent(getContext(), MapActivity.class);
-					IntentDataUtil.append(new LatLng(messageModel.getLocationData().getLatitude(),
-							messageModel.getLocationData().getLongitude()),
-						getContext().getString(R.string.app_name),
-						messageModel.getLocationData().getPoi(),
-						messageModel.getLocationData().getAddress(),
-						intent);
-					getContext().startActivity(intent);
-				}
-			}
-		} else {
+	private void viewLocation(AbstractMessageModel messageModel) {
+		if (messageModel == null) {
+			return;
+		}
+
+		if (!GeoLocationUtil.viewLocation(getContext(), messageModel.getLocationData())) {
 			RuntimeUtil.runOnUiThread(new Runnable() {
 				@Override
 				public void run() {

+ 2 - 1
app/src/main/java/ch/threema/app/asynctasks/EmptyChatAsyncTask.java

@@ -30,6 +30,7 @@ import org.slf4j.Logger;
 import java.util.ArrayList;
 import java.util.List;
 
+import androidx.annotation.Nullable;
 import androidx.fragment.app.FragmentManager;
 import ch.threema.app.R;
 import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
@@ -58,7 +59,7 @@ public class EmptyChatAsyncTask extends AsyncTask<Void, Integer, Integer> {
 	public EmptyChatAsyncTask(MessageReceiver messageReceiver,
 	                          MessageService messageService,
 	                          ConversationService conversationService,
-	                          FragmentManager fragmentManager,
+	                          @Nullable FragmentManager fragmentManager,
 	                          boolean quiet,
 	                          Runnable runOnCompletion) {
 

+ 11 - 5
app/src/main/java/ch/threema/app/camera/CameraFragment.kt

@@ -264,11 +264,13 @@ class CameraFragment : Fragment() {
             // Keep track of the display in which this view is attached
             displayId = previewView?.display?.displayId ?: -1
 
-            // Build UI controls
-            updateCameraUi()
+            if (displayId != -1) {
+                // Build UI controls
+                updateCameraUi()
 
-            // Set up the camera and its use cases
-            setUpCamera()
+                // Set up the camera and its use cases
+                setUpCamera()
+            }
         }
     }
 
@@ -601,6 +603,10 @@ class CameraFragment : Fragment() {
 
     /** Method used to re-draw the camera UI controls, called every time configuration changes. */
     private fun updateCameraUi() {
+        if (previewView == null) {
+            return
+        }
+
         // Remove previous UI if any
         val constraintLayout: ConstraintLayout? = container?.findViewById(R.id.camera_ui_container)
         constraintLayout.let {
@@ -608,7 +614,7 @@ class CameraFragment : Fragment() {
         }
 
         // Inflate a new view containing all UI for controlling the camera
-        val curOrientation = previewView!!.display.rotation
+        val curOrientation = previewView!!.display?.rotation ?: Surface.ROTATION_0
 
         controlsContainer = if (curOrientation == Surface.ROTATION_180 || curOrientation == Surface.ROTATION_270) {
             View.inflate(requireContext(), R.layout.camerax_ui_container_reverse, container)

+ 4 - 3
app/src/main/java/ch/threema/app/camera/VideoEditView.java

@@ -43,8 +43,8 @@ import android.widget.GridLayout;
 import android.widget.ImageView;
 import android.widget.TextView;
 
+import com.google.android.exoplayer2.ExoPlayer;
 import com.google.android.exoplayer2.Player;
-import com.google.android.exoplayer2.SimpleExoPlayer;
 import com.google.android.exoplayer2.source.ClippingMediaSource;
 import com.google.android.exoplayer2.source.MediaSource;
 import com.google.android.exoplayer2.source.ProgressiveMediaSource;
@@ -72,6 +72,7 @@ import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.RuntimeUtil;
+import ch.threema.app.utils.VideoUtil;
 import ch.threema.app.video.VideoTimelineCache;
 import ch.threema.base.utils.LoggingUtil;
 
@@ -95,7 +96,7 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	private int isMoving = MOVING_NONE;
 	private GridLayout timelineGridLayout;
 	private PlayerView videoView;
-	private SimpleExoPlayer videoPlayer;
+	private ExoPlayer videoPlayer;
 	private MediaSource videoSource;
 	private TextView startTimeTextView, endTimeTextView, sizeTextView;
 	private Thread thumbnailThread;
@@ -176,7 +177,7 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 
 	@SuppressLint("ClickableViewAccessibility")
 	private void initVideoView() {
-		this.videoPlayer = new SimpleExoPlayer.Builder(context).build();
+		this.videoPlayer = VideoUtil.getExoPlayer(context);
 		this.videoPlayer.setPlayWhenReady(false);
 		this.videoPlayer.addListener(new Player.Listener() {
 			@Override

+ 24 - 30
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -176,7 +176,6 @@ import ch.threema.app.services.WallpaperService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.services.messageplayer.MessagePlayerService;
-import ch.threema.app.stores.IdentityStore;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.ContentCommitComposeEditText;
 import ch.threema.app.ui.ConversationListView;
@@ -225,7 +224,6 @@ import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.models.IdentityType;
 import ch.threema.domain.models.VerificationLevel;
-import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -4446,39 +4444,35 @@ public class ComposeMessageFragment extends Fragment implements
 
 	@Override
 	public void onReportSpamClicked(@NonNull final ContactModel spammerContactModel, boolean block) {
-		final APIConnector connector = ThreemaApplication.requireServiceManager().getAPIConnector();
-		final IdentityStore identityStore = ThreemaApplication.requireServiceManager().getIdentityStore();
+		contactService.reportSpam(
+			spammerContactModel,
+			unused -> {
+				if (isAdded()) {
+					Toast.makeText(getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();
+				}
 
-		new Thread(() -> {
-			try {
-				connector.reportJunk(identityStore, spammerContactModel.getIdentity(), spammerContactModel.getPublicNickName());
 				if (block) {
 					blackListIdentityService.add(spammerContactModel.getIdentity());
-				}
-				contactModel.setIsHidden(true);
-				contactService.save(contactModel);
+					ThreemaApplication.requireServiceManager().getExcludedSyncIdentitiesService().add(spammerContactModel.getIdentity());
 
-				RuntimeUtil.runOnUiThread(() -> {
-					Toast.makeText(ComposeMessageFragment.this.getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();
-					if (block) {
-						new EmptyChatAsyncTask(messageReceiver, messageService, conversationService, getParentFragmentManager(), true, new Runnable() {
-							@Override
-							public void run() {
-								ListenerManager.conversationListeners.handle(ConversationListener::onModifiedAll);
-								ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
-								ComposeMessageFragment.this.finishActivity();
-							}
-						}).execute();
-					} else {
-						reportSpamView.hide();
-						ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
-					}
-				});
-			} catch (Exception e) {
-				logger.error("Error reporting spam", e);
-				RuntimeUtil.runOnUiThread(() -> Toast.makeText(ComposeMessageFragment.this.getContext(), getString(R.string.spam_error_reporting, e.getMessage()), Toast.LENGTH_LONG).show());
+					new EmptyChatAsyncTask(messageReceiver, messageService, conversationService, null, true, () -> {
+						ListenerManager.conversationListeners.handle(ConversationListener::onModifiedAll);
+						ListenerManager.contactListeners.handle(listener -> listener.onModified(spammerContactModel));
+						if (isAdded()) {
+							finishActivity();
+						}
+					}).execute();
+				} else {
+					reportSpamView.hide();
+					ListenerManager.contactListeners.handle(listener -> listener.onModified(spammerContactModel));
+				}
+			},
+			message -> {
+				if (isAdded()) {
+					Toast.makeText(getContext(), requireContext().getString(R.string.spam_error_reporting, message), Toast.LENGTH_LONG).show();
+				}
 			}
-		}).start();
+		);
 	}
 
 	private void finishActivity() {

+ 152 - 22
app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java

@@ -49,6 +49,16 @@ import android.widget.FrameLayout;
 import android.widget.ListView;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.SearchView;
+import androidx.core.util.Pair;
+import androidx.core.view.MenuItemCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkManager;
+
 import com.google.android.material.chip.Chip;
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
 import com.google.android.material.tabs.TabLayout;
@@ -56,19 +66,11 @@ import com.google.android.material.tabs.TabLayout;
 import org.slf4j.Logger;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.widget.SearchView;
-import androidx.core.util.Pair;
-import androidx.core.view.MenuItemCompat;
-import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
-import androidx.work.OneTimeWorkRequest;
-import androidx.work.WorkManager;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.AddContactActivity;
@@ -77,14 +79,18 @@ import ch.threema.app.activities.ContactDetailActivity;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.adapters.ContactListAdapter;
 import ch.threema.app.asynctasks.DeleteContactAsyncTask;
+import ch.threema.app.asynctasks.EmptyChatAsyncTask;
 import ch.threema.app.dialogs.BottomSheetAbstractDialog;
 import ch.threema.app.dialogs.BottomSheetGridDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
+import ch.threema.app.dialogs.SelectorDialog;
+import ch.threema.app.dialogs.TextWithCheckboxDialog;
 import ch.threema.app.emojis.EmojiTextView;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.jobs.WorkSyncService;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
+import ch.threema.app.listeners.ConversationListener;
 import ch.threema.app.listeners.PreferenceListener;
 import ch.threema.app.listeners.SynchronizeContactsListener;
 import ch.threema.app.managers.ListenerManager;
@@ -100,11 +106,13 @@ import ch.threema.app.ui.BottomSheetItem;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.ui.LockingSwipeRefreshLayout;
 import ch.threema.app.ui.ResumePauseHandler;
+import ch.threema.app.ui.SelectorDialogItem;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.MimeUtil;
+import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShareUtil;
 import ch.threema.app.utils.TestUtil;
@@ -124,13 +132,17 @@ public class ContactsSectionFragment
 		SwipeRefreshLayout.OnRefreshListener,
 		ListView.OnItemClickListener,
 		ContactListAdapter.AvatarListener,
+		SelectorDialog.SelectorDialogClickListener,
 		GenericAlertDialog.DialogClickListener,
-		BottomSheetAbstractDialog.BottomSheetDialogCallback {
+		BottomSheetAbstractDialog.BottomSheetDialogCallback,
+		TextWithCheckboxDialog.TextWithCheckboxDialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactsSectionFragment");
 
 	private static final int PERMISSION_REQUEST_REFRESH_CONTACTS = 1;
 	private static final String DIALOG_TAG_REALLY_DELETE_CONTACTS = "rdc";
+	private static final String DIALOG_TAG_REALLY_DELETE_CONTACT = "rd";
 	private static final String DIALOG_TAG_SHARE_WITH = "wsw";
+	private static final String DIALOG_TAG_RECENTLY_ADDED_SELECTOR = "ras";
 
 	private static final String RUN_ON_ACTIVE_SHOW_LOADING = "show_loading";
 	private static final String RUN_ON_ACTIVE_HIDE_LOADING = "hide_loading";
@@ -1057,7 +1069,7 @@ public class ContactsSectionFragment
 		}
 	}
 
-	private void openConversationForIdentity(View v, String identity) {
+	private void openConversationForIdentity(@Nullable View v, String identity) {
 		Intent intent = new Intent(getActivity(), ComposeMessageActivity.class);
 		intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
 		intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
@@ -1065,6 +1077,12 @@ public class ContactsSectionFragment
 		AnimationUtil.startActivityForResult(getActivity(), v, intent, ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
 	}
 
+	private void openContact(@Nullable View view, String identity) {
+		Intent intent = new Intent(getActivity(), ContactDetailActivity.class);
+		intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
+
+		AnimationUtil.startActivityForResult(getActivity(), view, intent, ThreemaActivity.ACTIVITY_ID_CONTACT_DETAIL);
+	}
 
 	@Override
 	public void onSaveInstanceState(Bundle outState) {
@@ -1095,9 +1113,9 @@ public class ContactsSectionFragment
 	@Override
 	public void onItemClick(AdapterView<?> parent, View v, int position, long id) {
 		ContactModel contactModel = contactListAdapter.getClickedItem(v);
+
 		if (contactModel != null) {
-			String identity;
-			identity = contactModel.getIdentity();
+			String identity = contactModel.getIdentity();
 			if (identity != null) {
 				openConversationForIdentity(v, identity);
 			}
@@ -1116,13 +1134,10 @@ public class ContactsSectionFragment
 			// forward click on avatar to relevant list item
 			position += listView.getHeaderViewsCount();
 			listView.setItemChecked(position, !listView.isItemChecked(position));
-
 			return;
 		}
 
-		Intent intent = new Intent(getActivity(), ContactDetailActivity.class);
-		intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, contactListAdapter.getClickedItem(listItemView).getIdentity());
-		AnimationUtil.startActivityForResult(getActivity(), view, intent, ThreemaActivity.ACTIVITY_ID_CONTACT_DETAIL);
+		openContact(view, contactListAdapter.getClickedItem(listItemView).getIdentity());
 	}
 
 	@Override
@@ -1130,6 +1145,29 @@ public class ContactsSectionFragment
 		return true;
 	}
 
+	@Override
+	public void onRecentlyAddedClick(ContactModel contactModel) {
+
+		String contactName = NameUtil.getDisplayNameOrNickname(contactModel, true);
+
+		ArrayList<SelectorDialogItem> items = new ArrayList<>();
+
+			items.add(new SelectorDialogItem(getString(R.string.chat_with, contactName), R.drawable.ic_chat_bubble));
+			items.add(new SelectorDialogItem(getString(R.string.show_contact), R.drawable.ic_person_outline));
+			items.add(new SelectorDialogItem(getString(R.string.spam_report), R.drawable.ic_outline_report_24));
+			if (serviceManager.getBlackListService().has(contactModel.getIdentity())) {
+				items.add(new SelectorDialogItem(getString(R.string.unblock_contact), R.drawable.ic_block));
+			} else {
+				items.add(new SelectorDialogItem(getString(R.string.block_contact), R.drawable.ic_block));
+			}
+			items.add(new SelectorDialogItem(getString(R.string.delete_contact_action), R.drawable.ic_delete_outline));
+
+			SelectorDialog selectorDialog = SelectorDialog.newInstance(getString(R.string.last_added_contact), items, getString(R.string.cancel));
+			selectorDialog.setData(contactModel);
+			selectorDialog.setTargetFragment(this, 0);
+			selectorDialog.show(getParentFragmentManager(), DIALOG_TAG_RECENTLY_ADDED_SELECTOR);
+	}
+
 	@Override
 	public void onRequestPermissionsResult(int requestCode,
 	                                       @NonNull String permissions[], @NonNull int[] grantResults) {
@@ -1177,7 +1215,10 @@ public class ContactsSectionFragment
 	public void onYes(String tag, Object data) {
 		switch(tag) {
 			case DIALOG_TAG_REALLY_DELETE_CONTACTS:
-				reallyDeleteContacts();
+				reallyDeleteContacts(contactListAdapter.getCheckedItems());
+				break;
+			case DIALOG_TAG_REALLY_DELETE_CONTACT:
+				reallyDeleteContacts(new HashSet<>(Arrays.asList((ContactModel) data)));
 				break;
 			default:
 				break;
@@ -1185,15 +1226,19 @@ public class ContactsSectionFragment
 	}
 
 	@SuppressLint("StaticFieldLeak")
-	private void reallyDeleteContacts() {
-		new DeleteContactAsyncTask(getFragmentManager(), contactListAdapter.getCheckedItems(), contactService, new DeleteContactAsyncTask.DeleteContactsPostRunnable() {
+	private void reallyDeleteContacts(HashSet<ContactModel> contactModels) {
+		new DeleteContactAsyncTask(getFragmentManager(), contactModels, contactService, new DeleteContactAsyncTask.DeleteContactsPostRunnable() {
 			@Override
 			public void run() {
 				if (isAdded()) {
 					if (failed > 0) {
 						Toast.makeText(getActivity(), String.format(getString(R.string.some_contacts_not_deleted), failed), Toast.LENGTH_LONG).show();
 					} else {
-						Toast.makeText(getActivity(), R.string.contacts_deleted, Toast.LENGTH_LONG).show();
+						if (contactModels.size() > 1) {
+							Toast.makeText(getActivity(), R.string.contacts_deleted, Toast.LENGTH_LONG).show();
+						} else {
+							Toast.makeText(getActivity(), R.string.contact_deleted, Toast.LENGTH_LONG).show();
+						}
 					}
 				}
 
@@ -1242,7 +1287,7 @@ public class ContactsSectionFragment
 
 			BottomSheetGridDialog dialog = BottomSheetGridDialog.newInstance(R.string.invite_via, items);
 			dialog.setTargetFragment(this, 0);
-			dialog.show(getFragmentManager(), DIALOG_TAG_SHARE_WITH);
+			dialog.show(getParentFragmentManager(), DIALOG_TAG_SHARE_WITH);
 		}
 	}
 
@@ -1286,4 +1331,89 @@ public class ContactsSectionFragment
 			this.listView.setSelection(0);
 		}
 	}
+
+	/* selector dialog callbacks */
+
+	@Override
+	public void onClick(String tag, int which, Object data) {
+		if (data == null) {
+			return;
+		}
+
+		ContactModel contactModel = (ContactModel) data;
+
+		switch (which) {
+			case 0:
+				openConversationForIdentity(null, contactModel.getIdentity());
+				break;
+			case 1:
+				openContact(null, contactModel.getIdentity());
+				break;
+			case 2:
+				TextWithCheckboxDialog sdialog = TextWithCheckboxDialog.newInstance(requireContext().getString(R.string.spam_report_dialog_title, NameUtil.getDisplayNameOrNickname(contactModel, true)), R.string.spam_report_dialog_explain,
+					R.string.spam_report_dialog_block_checkbox, R.string.spam_report_short, R.string.cancel);
+				sdialog.setData(contactModel);
+				sdialog.setTargetFragment(this, 0);
+				sdialog.show(getParentFragmentManager(), "");
+
+				break;
+			case 3:
+				serviceManager.getBlackListService().toggle(getActivity(), contactModel);
+				break;
+			case 4:
+				GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.delete_contact_action, R.string.really_delete_contact, R.string.delete, R.string.cancel);
+				dialog.setData(contactModel);
+				dialog.setTargetFragment(this, 0);
+				dialog.show(getParentFragmentManager(), DIALOG_TAG_REALLY_DELETE_CONTACT);
+				break;
+		}
+	}
+
+	@Override
+	public void onCancel(String tag) {}
+
+	@Override
+	public void onNo(String tag) {}
+
+	/* callback from TextWithCheckboxDialog */
+
+	@Override
+	public void onYes(String tag, Object data, boolean checked) {
+		ContactModel contactModel = (ContactModel) data;
+
+		contactService.reportSpam(contactModel,
+			unused -> {
+				if (isAdded()) {
+					Toast.makeText(getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();
+				}
+
+				if (checked) {
+					ThreemaApplication.requireServiceManager().getBlackListService().add(contactModel.getIdentity());
+					ThreemaApplication.requireServiceManager().getExcludedSyncIdentitiesService().add(contactModel.getIdentity());
+
+					try {
+						new EmptyChatAsyncTask(
+							contactService.createReceiver(contactModel),
+							ThreemaApplication.requireServiceManager().getMessageService(),
+							ThreemaApplication.requireServiceManager().getConversationService(),
+							null,
+							true,
+							() -> {
+								ListenerManager.conversationListeners.handle(ConversationListener::onModifiedAll);
+								ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
+							}).execute();
+					} catch (Exception e) {
+						logger.error("Unable to empty chat", e);
+					}
+				} else {
+					ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
+				}
+			},
+			message -> {
+				if (isAdded()) {
+					Toast.makeText(getContext(), requireContext().getString(R.string.spam_error_reporting, message), Toast.LENGTH_LONG).show();
+				}
+			}
+		);
+	}
 }

+ 3 - 3
app/src/main/java/ch/threema/app/fragments/mediaviews/AudioViewFragment.java

@@ -33,7 +33,6 @@ import android.widget.ProgressBar;
 import com.google.android.exoplayer2.ExoPlayer;
 import com.google.android.exoplayer2.MediaItem;
 import com.google.android.exoplayer2.Player;
-import com.google.android.exoplayer2.SimpleExoPlayer;
 import com.google.android.exoplayer2.source.MediaSource;
 import com.google.android.exoplayer2.source.ProgressiveMediaSource;
 import com.google.android.exoplayer2.ui.PlayerControlView;
@@ -54,6 +53,7 @@ import androidx.core.view.WindowInsetsCompat;
 import ch.threema.app.R;
 import ch.threema.app.activities.MediaViewerActivity;
 import ch.threema.app.mediaattacher.PreviewFragmentInterface;
+import ch.threema.app.utils.VideoUtil;
 import ch.threema.base.utils.LoggingUtil;
 
 public class AudioViewFragment extends AudioFocusSupportingMediaViewFragment implements Player.Listener, PreviewFragmentInterface {
@@ -61,7 +61,7 @@ public class AudioViewFragment extends AudioFocusSupportingMediaViewFragment imp
 
 	private WeakReference<ProgressBar> progressBarRef;
 	private WeakReference<PlayerView> audioView;
-	private SimpleExoPlayer audioPlayer;
+	private ExoPlayer audioPlayer;
 	private boolean isImmediatePlay, isPreparing;
 
 	public AudioViewFragment() {
@@ -73,7 +73,7 @@ public class AudioViewFragment extends AudioFocusSupportingMediaViewFragment imp
 		this.isImmediatePlay = getArguments().getBoolean(MediaViewerActivity.EXTRA_ID_IMMEDIATE_PLAY, false);
 
 		try {
-			this.audioPlayer = new SimpleExoPlayer.Builder(getContext()).build();
+			this.audioPlayer = VideoUtil.getExoPlayer(getContext());
 			this.audioPlayer.addListener(this);
 		} catch (OutOfMemoryError e) {
 			logger.error("Exception", e);

+ 4 - 3
app/src/main/java/ch/threema/app/fragments/mediaviews/VideoViewFragment.java

@@ -32,10 +32,10 @@ import android.widget.ImageView;
 import android.widget.ProgressBar;
 import android.widget.Toast;
 
+import com.google.android.exoplayer2.ExoPlayer;
 import com.google.android.exoplayer2.MediaItem;
 import com.google.android.exoplayer2.PlaybackException;
 import com.google.android.exoplayer2.Player;
-import com.google.android.exoplayer2.SimpleExoPlayer;
 import com.google.android.exoplayer2.source.MediaSource;
 import com.google.android.exoplayer2.source.ProgressiveMediaSource;
 import com.google.android.exoplayer2.ui.PlayerControlView;
@@ -54,6 +54,7 @@ import ch.threema.app.R;
 import ch.threema.app.activities.MediaViewerActivity;
 import ch.threema.app.ui.ZoomableExoPlayerView;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.utils.VideoUtil;
 import ch.threema.base.utils.LoggingUtil;
 
 public class VideoViewFragment extends AudioFocusSupportingMediaViewFragment implements Player.Listener {
@@ -62,7 +63,7 @@ public class VideoViewFragment extends AudioFocusSupportingMediaViewFragment imp
 	private WeakReference<ImageView> previewImageViewRef;
 	private WeakReference<ProgressBar> progressBarRef;
 	private WeakReference<ZoomableExoPlayerView> videoViewRef;
-	private SimpleExoPlayer videoPlayer;
+	private ExoPlayer videoPlayer;
 	private boolean isImmediatePlay, isPreparing;
 
 	public VideoViewFragment() {
@@ -77,7 +78,7 @@ public class VideoViewFragment extends AudioFocusSupportingMediaViewFragment imp
 		this.isImmediatePlay = getArguments().getBoolean(MediaViewerActivity.EXTRA_ID_IMMEDIATE_PLAY, false);
 
 		try {
-			this.videoPlayer = new SimpleExoPlayer.Builder(getContext()).build();
+			this.videoPlayer = VideoUtil.getExoPlayer(getContext());
 			this.videoPlayer.addListener(this);
 		} catch (OutOfMemoryError e) {
 			logger.error("Exception", e);

+ 4 - 3
app/src/main/java/ch/threema/app/mediaattacher/VideoPreviewFragment.java

@@ -28,10 +28,10 @@ import android.view.ViewGroup;
 import android.widget.ImageButton;
 import android.widget.Toast;
 
+import com.google.android.exoplayer2.ExoPlayer;
 import com.google.android.exoplayer2.MediaItem;
 import com.google.android.exoplayer2.PlaybackException;
 import com.google.android.exoplayer2.Player;
-import com.google.android.exoplayer2.SimpleExoPlayer;
 
 import org.slf4j.Logger;
 
@@ -42,13 +42,14 @@ import androidx.lifecycle.LifecycleOwner;
 import ch.threema.app.R;
 import ch.threema.app.ui.ZoomableExoPlayerView;
 import ch.threema.app.utils.RuntimeUtil;
+import ch.threema.app.utils.VideoUtil;
 import ch.threema.base.utils.LoggingUtil;
 
 public class VideoPreviewFragment extends PreviewFragment implements DefaultLifecycleObserver, Player.Listener, PreviewFragmentInterface {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("VideoPreviewFragment");
 
 	private ZoomableExoPlayerView videoView;
-	private SimpleExoPlayer videoPlayer;
+	private ExoPlayer videoPlayer;
 
 	VideoPreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel){
 		super(mediaItem, mediaAttachViewModel);
@@ -128,7 +129,7 @@ public class VideoPreviewFragment extends PreviewFragment implements DefaultLife
 
 	public void initializePlayer(boolean playWhenReady) {
 		try {
-			this.videoPlayer = new SimpleExoPlayer.Builder(getContext()).build();
+			this.videoPlayer = VideoUtil.getExoPlayer(getContext());
 			this.videoPlayer.addListener(this);
 
 			this.videoView.setPlayer(videoPlayer);

+ 2 - 0
app/src/main/java/ch/threema/app/services/ContactService.java

@@ -43,6 +43,7 @@ import ch.threema.domain.protocol.csp.messages.ContactRequestPhotoMessage;
 import ch.threema.domain.protocol.csp.messages.ContactSetPhotoMessage;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.access.AccessModel;
+import java8.util.function.Consumer;
 
 public interface ContactService extends AvatarService<ContactModel> {
 
@@ -215,4 +216,5 @@ public interface ContactService extends AvatarService<ContactModel> {
 
 	@WorkerThread
 	boolean resetReceiptsSettings();
+	void reportSpam(@NonNull ContactModel spammerContactModel, @Nullable Consumer<Void> onSuccess, @Nullable Consumer<String> onFailure);
 }

+ 25 - 0
app/src/main/java/ch/threema/app/services/ContactServiceImpl.java

@@ -57,6 +57,7 @@ import androidx.annotation.AnyThread;
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
 import androidx.annotation.WorkerThread;
 import androidx.core.content.ContextCompat;
 import ch.threema.app.BuildConfig;
@@ -84,6 +85,7 @@ import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.ColorUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
+import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
@@ -110,6 +112,7 @@ import ch.threema.storage.factories.ContactModelFactory;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ValidationMessage;
 import ch.threema.storage.models.access.AccessModel;
+import java8.util.function.Consumer;
 
 public class ContactServiceImpl implements ContactService {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactServiceImpl");
@@ -1457,4 +1460,26 @@ public class ContactServiceImpl implements ContactService {
 		}
 		return false;
 	}
+
+	@Override
+	@UiThread
+	public void reportSpam(@NonNull final ContactModel spammerContactModel, @Nullable Consumer<Void> onSuccess, @Nullable Consumer<String> onFailure) {
+		new Thread(() -> {
+			try {
+				apiConnector.reportJunk(identityStore, spammerContactModel.getIdentity(), spammerContactModel.getPublicNickName());
+
+				spammerContactModel.setIsHidden(true);
+				save(spammerContactModel);
+
+				if (onSuccess != null) {
+					RuntimeUtil.runOnUiThread(() -> onSuccess.accept(null));
+				}
+			} catch (Exception e) {
+				logger.error("Error reporting spam", e);
+				if (onFailure != null) {
+					RuntimeUtil.runOnUiThread(() -> onFailure.accept(e.getMessage()));
+				}
+			}
+		}).start();
+	}
 }

+ 7 - 3
app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java

@@ -34,6 +34,9 @@ import android.text.format.DateUtils;
 import android.util.Pair;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
 import com.lambdaworks.crypto.SCrypt;
 import com.neilalexander.jnacl.NaCl;
 
@@ -67,7 +70,6 @@ import java.util.zip.GZIPOutputStream;
 
 import javax.net.ssl.HttpsURLConnection;
 
-import androidx.annotation.Nullable;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -1395,7 +1397,7 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 		return linksArray;
 	}
 
-	private JSONObject getContact(ContactModel contactModel) throws JSONException {
+	private JSONObject getContact(@NonNull ContactModel contactModel) throws JSONException {
 		JSONObject contact = new JSONObject();
 
 		contact.put(TAG_SAFE_CONTACT_IDENTITY, contactModel.getIdentity());
@@ -1424,7 +1426,9 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 		JSONArray contactsArray = new JSONArray();
 
 		for (final ContactModel contactModel : contactService.find(null)) {
-			contactsArray.put(getContact(contactModel));
+			if (contactModel != null) {
+				contactsArray.put(getContact(contactModel));
+			}
 		}
 
 		return contactsArray;

+ 3 - 1
app/src/main/java/ch/threema/app/ui/AvatarEditView.java

@@ -167,7 +167,9 @@ public class AvatarEditView extends FrameLayout implements DefaultLifecycleObser
 	private final ContactListener contactListener = new ContactListener() {
 		@Override
 		public void onModified(ContactModel modifiedContactModel) {
-			RuntimeUtil.runOnUiThread(() -> loadAvatarForModel(modifiedContactModel, null));
+			if (modifiedContactModel != null && handle(modifiedContactModel.getIdentity())) {
+				RuntimeUtil.runOnUiThread(() -> loadAvatarForModel(modifiedContactModel, null));
+			}
 		}
 
 		@Override

+ 1305 - 1298
app/src/main/java/ch/threema/app/ui/ZoomableExoPlayerView.java

@@ -70,14 +70,15 @@ import com.google.android.exoplayer2.Player;
 import com.google.android.exoplayer2.Player.DiscontinuityReason;
 import com.google.android.exoplayer2.Timeline;
 import com.google.android.exoplayer2.Timeline.Period;
-import com.google.android.exoplayer2.TracksInfo;
-import com.google.android.exoplayer2.text.Cue;
+import com.google.android.exoplayer2.Tracks;
+import com.google.android.exoplayer2.text.CueGroup;
 import com.google.android.exoplayer2.ui.AdOverlayInfo;
 import com.google.android.exoplayer2.ui.AdViewProvider;
 import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
 import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode;
 import com.google.android.exoplayer2.ui.DefaultTimeBar;
 import com.google.android.exoplayer2.ui.PlayerControlView;
+import com.google.android.exoplayer2.ui.R;
 import com.google.android.exoplayer2.ui.StyledPlayerView;
 import com.google.android.exoplayer2.ui.SubtitleView;
 import com.google.android.exoplayer2.util.Assertions;
@@ -289,64 +290,62 @@ import static java.lang.annotation.ElementType.TYPE_USE;
 @Deprecated
 public class ZoomableExoPlayerView extends FrameLayout implements AdViewProvider {
 
-  /**
-   * Determines when the buffering view is shown. One of {@link #SHOW_BUFFERING_NEVER}, {@link
-   * #SHOW_BUFFERING_WHEN_PLAYING} or {@link #SHOW_BUFFERING_ALWAYS}.
-   */
-  @Documented
-  @Retention(RetentionPolicy.SOURCE)
-  @Target(TYPE_USE)
-  @IntDef({SHOW_BUFFERING_NEVER, SHOW_BUFFERING_WHEN_PLAYING, SHOW_BUFFERING_ALWAYS})
-  public @interface ShowBuffering {}
-  /** The buffering view is never shown. */
-  public static final int SHOW_BUFFERING_NEVER = 0;
-  /**
-   * The buffering view is shown when the player is in the {@link Player#STATE_BUFFERING buffering}
-   * state and {@link Player#getPlayWhenReady() playWhenReady} is {@code true}.
-   */
-  public static final int SHOW_BUFFERING_WHEN_PLAYING = 1;
-  /**
-   * The buffering view is always shown when the player is in the {@link Player#STATE_BUFFERING
-   * buffering} state.
-   */
-  public static final int SHOW_BUFFERING_ALWAYS = 2;
-
-  private static final int SURFACE_TYPE_NONE = 0;
-  private static final int SURFACE_TYPE_SURFACE_VIEW = 1;
-  private static final int SURFACE_TYPE_TEXTURE_VIEW = 2;
-  private static final int SURFACE_TYPE_SPHERICAL_GL_SURFACE_VIEW = 3;
-  private static final int SURFACE_TYPE_VIDEO_DECODER_GL_SURFACE_VIEW = 4;
-
-  private final ComponentListener componentListener;
-  @Nullable private final AspectRatioFrameLayout contentFrame;
-  @Nullable private final View shutterView;
-  @Nullable private final View surfaceView;
-  private final boolean surfaceViewIgnoresVideoAspectRatio;
-  @Nullable private final ImageView artworkView;
-  @Nullable private final SubtitleView subtitleView;
-  @Nullable private final View bufferingView;
-  @Nullable private final TextView errorMessageView;
-  @Nullable private final PlayerControlView controller;
-  @Nullable private final FrameLayout adOverlayFrameLayout;
-  @Nullable private final FrameLayout overlayFrameLayout;
-
-  @Nullable private Player player;
-  private boolean useController;
-  @Nullable private PlayerControlView.VisibilityListener controllerVisibilityListener;
-  private boolean useArtwork;
-  @Nullable private Drawable defaultArtwork;
-  private @ShowBuffering int showBuffering;
-  private boolean keepContentOnPlayerReset;
-  @Nullable private ErrorMessageProvider<? super PlaybackException> errorMessageProvider;
-  @Nullable private CharSequence customErrorMessage;
-  private int controllerShowTimeoutMs;
-  private boolean controllerAutoShow;
-  private boolean controllerHideDuringAds;
-  private boolean controllerHideOnTouch;
-  private int textureViewRotation;
-  private boolean isTouching;
-  private static final int PICTURE_TYPE_FRONT_COVER = 3;
-  private static final int PICTURE_TYPE_NOT_SET = -1;
+	/**
+	 * Determines when the buffering view is shown. One of {@link #SHOW_BUFFERING_NEVER}, {@link
+	 * #SHOW_BUFFERING_WHEN_PLAYING} or {@link #SHOW_BUFFERING_ALWAYS}.
+	 */
+	@Documented
+	@Retention(RetentionPolicy.SOURCE)
+	@Target(TYPE_USE)
+	@IntDef({SHOW_BUFFERING_NEVER, SHOW_BUFFERING_WHEN_PLAYING, SHOW_BUFFERING_ALWAYS})
+	public @interface ShowBuffering {}
+	/** The buffering view is never shown. */
+	public static final int SHOW_BUFFERING_NEVER = 0;
+	/**
+	 * The buffering view is shown when the player is in the {@link Player#STATE_BUFFERING buffering}
+	 * state and {@link Player#getPlayWhenReady() playWhenReady} is {@code true}.
+	 */
+	public static final int SHOW_BUFFERING_WHEN_PLAYING = 1;
+	/**
+	 * The buffering view is always shown when the player is in the {@link Player#STATE_BUFFERING
+	 * buffering} state.
+	 */
+	public static final int SHOW_BUFFERING_ALWAYS = 2;
+
+	private static final int SURFACE_TYPE_NONE = 0;
+	private static final int SURFACE_TYPE_SURFACE_VIEW = 1;
+	private static final int SURFACE_TYPE_TEXTURE_VIEW = 2;
+	private static final int SURFACE_TYPE_SPHERICAL_GL_SURFACE_VIEW = 3;
+	private static final int SURFACE_TYPE_VIDEO_DECODER_GL_SURFACE_VIEW = 4;
+
+	private final ComponentListener componentListener;
+	@Nullable private final AspectRatioFrameLayout contentFrame;
+	@Nullable private final View shutterView;
+	@Nullable private final View surfaceView;
+	private final boolean surfaceViewIgnoresVideoAspectRatio;
+	@Nullable private final ImageView artworkView;
+	@Nullable private final SubtitleView subtitleView;
+	@Nullable private final View bufferingView;
+	@Nullable private final TextView errorMessageView;
+	@Nullable private final PlayerControlView controller;
+	@Nullable private final FrameLayout adOverlayFrameLayout;
+	@Nullable private final FrameLayout overlayFrameLayout;
+
+	@Nullable private Player player;
+	private boolean useController;
+	@Nullable private PlayerControlView.VisibilityListener controllerVisibilityListener;
+	private boolean useArtwork;
+	@Nullable private Drawable defaultArtwork;
+	private @ShowBuffering int showBuffering;
+	private boolean keepContentOnPlayerReset;
+	@Nullable private ErrorMessageProvider<? super PlaybackException> errorMessageProvider;
+	@Nullable private CharSequence customErrorMessage;
+	private int controllerShowTimeoutMs;
+	private boolean controllerAutoShow;
+	private boolean controllerHideDuringAds;
+	private boolean controllerHideOnTouch;
+	private int textureViewRotation;
+	private boolean isTouching;
 
   public ZoomableExoPlayerView(Context context) {
     this(context, /* attrs= */ null);
@@ -360,1245 +359,1253 @@ public class ZoomableExoPlayerView extends FrameLayout implements AdViewProvider
   public ZoomableExoPlayerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
     super(context, attrs, defStyleAttr);
 
-    componentListener = new ComponentListener();
-
-    if (isInEditMode()) {
-      contentFrame = null;
-      shutterView = null;
-      surfaceView = null;
-      surfaceViewIgnoresVideoAspectRatio = false;
-      artworkView = null;
-      subtitleView = null;
-      bufferingView = null;
-      errorMessageView = null;
-      controller = null;
-      adOverlayFrameLayout = null;
-      overlayFrameLayout = null;
-      ImageView logo = new ImageView(context);
-      if (Util.SDK_INT >= 23) {
-        configureEditModeLogoV23(getResources(), logo);
-      } else {
-        configureEditModeLogo(getResources(), logo);
-      }
-      addView(logo);
-      return;
-    }
-
-    boolean shutterColorSet = false;
-    int shutterColor = 0;
-    int playerLayoutId = com.google.android.exoplayer2.ui.R.layout.exo_player_view;
-    boolean useArtwork = true;
-    int defaultArtworkId = 0;
-    boolean useController = true;
-    int surfaceType = SURFACE_TYPE_SURFACE_VIEW;
-    int resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
-    int controllerShowTimeoutMs = PlayerControlView.DEFAULT_SHOW_TIMEOUT_MS;
-    boolean controllerHideOnTouch = true;
-    boolean controllerAutoShow = true;
-    boolean controllerHideDuringAds = true;
-    int showBuffering = SHOW_BUFFERING_NEVER;
-    if (attrs != null) {
-      TypedArray a =
-          context
-              .getTheme()
-              .obtainStyledAttributes(
-                  attrs, com.google.android.exoplayer2.ui.R.styleable.PlayerView, defStyleAttr, /* defStyleRes= */ 0);
-      try {
-        shutterColorSet = a.hasValue(com.google.android.exoplayer2.ui.R.styleable.PlayerView_shutter_background_color);
-        shutterColor = a.getColor(com.google.android.exoplayer2.ui.R.styleable.PlayerView_shutter_background_color, shutterColor);
-        playerLayoutId = a.getResourceId(com.google.android.exoplayer2.ui.R.styleable.PlayerView_player_layout_id, playerLayoutId);
-        useArtwork = a.getBoolean(com.google.android.exoplayer2.ui.R.styleable.PlayerView_use_artwork, useArtwork);
-        defaultArtworkId =
-            a.getResourceId(com.google.android.exoplayer2.ui.R.styleable.PlayerView_default_artwork, defaultArtworkId);
-        useController = a.getBoolean(com.google.android.exoplayer2.ui.R.styleable.PlayerView_use_controller, useController);
-        surfaceType = a.getInt(com.google.android.exoplayer2.ui.R.styleable.PlayerView_surface_type, surfaceType);
-        resizeMode = a.getInt(com.google.android.exoplayer2.ui.R.styleable.PlayerView_resize_mode, resizeMode);
-        controllerShowTimeoutMs =
-            a.getInt(com.google.android.exoplayer2.ui.R.styleable.PlayerView_show_timeout, controllerShowTimeoutMs);
-        controllerHideOnTouch =
-            a.getBoolean(com.google.android.exoplayer2.ui.R.styleable.PlayerView_hide_on_touch, controllerHideOnTouch);
-        controllerAutoShow = a.getBoolean(com.google.android.exoplayer2.ui.R.styleable.PlayerView_auto_show, controllerAutoShow);
-        showBuffering = a.getInteger(com.google.android.exoplayer2.ui.R.styleable.PlayerView_show_buffering, showBuffering);
-        keepContentOnPlayerReset =
-            a.getBoolean(
-                com.google.android.exoplayer2.ui.R.styleable.PlayerView_keep_content_on_player_reset, keepContentOnPlayerReset);
-        controllerHideDuringAds =
-            a.getBoolean(com.google.android.exoplayer2.ui.R.styleable.PlayerView_hide_during_ads, controllerHideDuringAds);
-      } finally {
-        a.recycle();
-      }
-    }
-
-    LayoutInflater.from(context).inflate(playerLayoutId, this);
-    setDescendantFocusability(FOCUS_AFTER_DESCENDANTS);
-
-    // Content frame.
-    contentFrame = findViewById(com.google.android.exoplayer2.ui.R.id.exo_content_frame);
-    if (contentFrame != null) {
-      setResizeModeRaw(contentFrame, resizeMode);
-    }
-
-    // Shutter view.
-    shutterView = findViewById(com.google.android.exoplayer2.ui.R.id.exo_shutter);
-    if (shutterView != null && shutterColorSet) {
-      shutterView.setBackgroundColor(shutterColor);
-    }
-
-    // Create a surface view and insert it into the content frame, if there is one.
-    boolean surfaceViewIgnoresVideoAspectRatio = false;
-    if (contentFrame != null && surfaceType != SURFACE_TYPE_NONE) {
-      ViewGroup.LayoutParams params =
-          new ViewGroup.LayoutParams(
-              ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
-      switch (surfaceType) {
-        case SURFACE_TYPE_TEXTURE_VIEW:
+		componentListener = new ComponentListener();
+
+		if (isInEditMode()) {
+			contentFrame = null;
+			shutterView = null;
+			surfaceView = null;
+			surfaceViewIgnoresVideoAspectRatio = false;
+			artworkView = null;
+			subtitleView = null;
+			bufferingView = null;
+			errorMessageView = null;
+			controller = null;
+			adOverlayFrameLayout = null;
+			overlayFrameLayout = null;
+			ImageView logo = new ImageView(context);
+			if (Util.SDK_INT >= 23) {
+				configureEditModeLogoV23(getResources(), logo);
+			} else {
+				configureEditModeLogo(getResources(), logo);
+			}
+			addView(logo);
+			return;
+		}
+
+		boolean shutterColorSet = false;
+		int shutterColor = 0;
+		int playerLayoutId = R.layout.exo_player_view;
+		boolean useArtwork = true;
+		int defaultArtworkId = 0;
+		boolean useController = true;
+		int surfaceType = SURFACE_TYPE_SURFACE_VIEW;
+		int resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
+		int controllerShowTimeoutMs = PlayerControlView.DEFAULT_SHOW_TIMEOUT_MS;
+		boolean controllerHideOnTouch = true;
+		boolean controllerAutoShow = true;
+		boolean controllerHideDuringAds = true;
+		int showBuffering = SHOW_BUFFERING_NEVER;
+		if (attrs != null) {
+			TypedArray a =
+				context
+					.getTheme()
+					.obtainStyledAttributes(
+						attrs, R.styleable.PlayerView, defStyleAttr, /* defStyleRes= */ 0);
+			try {
+				shutterColorSet = a.hasValue(R.styleable.PlayerView_shutter_background_color);
+				shutterColor = a.getColor(R.styleable.PlayerView_shutter_background_color, shutterColor);
+				playerLayoutId = a.getResourceId(R.styleable.PlayerView_player_layout_id, playerLayoutId);
+				useArtwork = a.getBoolean(R.styleable.PlayerView_use_artwork, useArtwork);
+				defaultArtworkId =
+					a.getResourceId(R.styleable.PlayerView_default_artwork, defaultArtworkId);
+				useController = a.getBoolean(R.styleable.PlayerView_use_controller, useController);
+				surfaceType = a.getInt(R.styleable.PlayerView_surface_type, surfaceType);
+				resizeMode = a.getInt(R.styleable.PlayerView_resize_mode, resizeMode);
+				controllerShowTimeoutMs =
+					a.getInt(R.styleable.PlayerView_show_timeout, controllerShowTimeoutMs);
+				controllerHideOnTouch =
+					a.getBoolean(R.styleable.PlayerView_hide_on_touch, controllerHideOnTouch);
+				controllerAutoShow = a.getBoolean(R.styleable.PlayerView_auto_show, controllerAutoShow);
+				showBuffering = a.getInteger(R.styleable.PlayerView_show_buffering, showBuffering);
+				keepContentOnPlayerReset =
+					a.getBoolean(
+						R.styleable.PlayerView_keep_content_on_player_reset, keepContentOnPlayerReset);
+				controllerHideDuringAds =
+					a.getBoolean(R.styleable.PlayerView_hide_during_ads, controllerHideDuringAds);
+			} finally {
+				a.recycle();
+			}
+		}
+
+		LayoutInflater.from(context).inflate(playerLayoutId, this);
+		setDescendantFocusability(FOCUS_AFTER_DESCENDANTS);
+
+		// Content frame.
+		contentFrame = findViewById(R.id.exo_content_frame);
+		if (contentFrame != null) {
+			setResizeModeRaw(contentFrame, resizeMode);
+		}
+
+		// Shutter view.
+		shutterView = findViewById(R.id.exo_shutter);
+		if (shutterView != null && shutterColorSet) {
+			shutterView.setBackgroundColor(shutterColor);
+		}
+
+		// Create a surface view and insert it into the content frame, if there is one.
+		boolean surfaceViewIgnoresVideoAspectRatio = false;
+		if (contentFrame != null && surfaceType != SURFACE_TYPE_NONE) {
+			ViewGroup.LayoutParams params =
+				new ViewGroup.LayoutParams(
+					ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
+			switch (surfaceType) {
+				case SURFACE_TYPE_TEXTURE_VIEW:
 					// THREEMA
 					surfaceView = new ZoomableTextureView(context);
 					surfaceView.setOnClickListener(v -> performClick());
-          break;
-        case SURFACE_TYPE_SPHERICAL_GL_SURFACE_VIEW:
-          try {
-            Class<?> clazz =
-                Class.forName(
-                    "com.google.android.exoplayer2.video.spherical.SphericalGLSurfaceView");
-            surfaceView = (View) clazz.getConstructor(Context.class).newInstance(context);
-          } catch (Exception e) {
-            throw new IllegalStateException(
-                "spherical_gl_surface_view requires an ExoPlayer dependency", e);
-          }
-          surfaceViewIgnoresVideoAspectRatio = true;
-          break;
-        case SURFACE_TYPE_VIDEO_DECODER_GL_SURFACE_VIEW:
-          try {
-            Class<?> clazz =
-                Class.forName("com.google.android.exoplayer2.video.VideoDecoderGLSurfaceView");
-            surfaceView = (View) clazz.getConstructor(Context.class).newInstance(context);
-          } catch (Exception e) {
-            throw new IllegalStateException(
-                "video_decoder_gl_surface_view requires an ExoPlayer dependency", e);
-          }
-          break;
-        default:
-          surfaceView = new SurfaceView(context);
-          break;
-      }
-      surfaceView.setLayoutParams(params);
-      // We don't want surfaceView to be clickable separately to the PlayerView itself, but we
-      // do want to register as an OnClickListener so that surfaceView implementations can propagate
-      // click events up to the PlayerView by calling their own performClick method.
-      surfaceView.setOnClickListener(componentListener);
-      surfaceView.setClickable(false);
-      contentFrame.addView(surfaceView, 0);
-    } else {
-      surfaceView = null;
-    }
-    this.surfaceViewIgnoresVideoAspectRatio = surfaceViewIgnoresVideoAspectRatio;
-
-    // Ad overlay frame layout.
-    adOverlayFrameLayout = findViewById(com.google.android.exoplayer2.ui.R.id.exo_ad_overlay);
-
-    // Overlay frame layout.
-    overlayFrameLayout = findViewById(com.google.android.exoplayer2.ui.R.id.exo_overlay);
-
-    // Artwork view.
-    artworkView = findViewById(com.google.android.exoplayer2.ui.R.id.exo_artwork);
-    this.useArtwork = useArtwork && artworkView != null;
-    if (defaultArtworkId != 0) {
-      defaultArtwork = ContextCompat.getDrawable(getContext(), defaultArtworkId);
-    }
-
-    // Subtitle view.
-    subtitleView = findViewById(com.google.android.exoplayer2.ui.R.id.exo_subtitles);
-    if (subtitleView != null) {
-      subtitleView.setUserDefaultStyle();
-      subtitleView.setUserDefaultTextSize();
-    }
-
-    // Buffering view.
-    bufferingView = findViewById(com.google.android.exoplayer2.ui.R.id.exo_buffering);
-    if (bufferingView != null) {
-      bufferingView.setVisibility(View.GONE);
-    }
-    this.showBuffering = showBuffering;
-
-    // Error message view.
-    errorMessageView = findViewById(com.google.android.exoplayer2.ui.R.id.exo_error_message);
-    if (errorMessageView != null) {
-      errorMessageView.setVisibility(View.GONE);
-    }
-
-    // Playback control view.
-    PlayerControlView customController = findViewById(com.google.android.exoplayer2.ui.R.id.exo_controller);
-    View controllerPlaceholder = findViewById(com.google.android.exoplayer2.ui.R.id.exo_controller_placeholder);
-    if (customController != null) {
-      this.controller = customController;
-    } else if (controllerPlaceholder != null) {
-      // Propagate attrs as playbackAttrs so that PlayerControlView's custom attributes are
-      // transferred, but standard attributes (e.g. background) are not.
-      this.controller = new PlayerControlView(context, null, 0, attrs);
-      controller.setId(com.google.android.exoplayer2.ui.R.id.exo_controller);
-      controller.setLayoutParams(controllerPlaceholder.getLayoutParams());
-      ViewGroup parent = ((ViewGroup) controllerPlaceholder.getParent());
-      int controllerIndex = parent.indexOfChild(controllerPlaceholder);
-      parent.removeView(controllerPlaceholder);
-      parent.addView(controller, controllerIndex);
-    } else {
-      this.controller = null;
-    }
-    this.controllerShowTimeoutMs = controller != null ? controllerShowTimeoutMs : 0;
-    this.controllerHideOnTouch = controllerHideOnTouch;
-    this.controllerAutoShow = controllerAutoShow;
-    this.controllerHideDuringAds = controllerHideDuringAds;
-    this.useController = useController && controller != null;
-    hideController();
-    updateContentDescription();
-    if (controller != null) {
-      controller.addVisibilityListener(/* listener= */ componentListener);
-    }
-  }
-
-  /**
-   * Switches the view targeted by a given {@link Player}.
-   *
-   * @param player The player whose target view is being switched.
-   * @param oldPlayerView The old view to detach from the player.
-   * @param newPlayerView The new view to attach to the player.
-   */
-  public static void switchTargetView(
-	  Player player, @Nullable ZoomableExoPlayerView oldPlayerView, @Nullable ZoomableExoPlayerView newPlayerView) {
-    if (oldPlayerView == newPlayerView) {
-      return;
-    }
-    // We attach the new view before detaching the old one because this ordering allows the player
-    // to swap directly from one surface to another, without transitioning through a state where no
-    // surface is attached. This is significantly more efficient and achieves a more seamless
-    // transition when using platform provided video decoders.
-    if (newPlayerView != null) {
-      newPlayerView.setPlayer(player);
-    }
-    if (oldPlayerView != null) {
-      oldPlayerView.setPlayer(null);
-    }
-  }
-
-  /** Returns the player currently set on this view, or null if no player is set. */
-  @Nullable
-  public Player getPlayer() {
-    return player;
-  }
-
-  /**
-   * Sets the {@link Player} to use.
-   *
-   * <p>To transition a {@link Player} from targeting one view to another, it's recommended to use
-   * {@link #switchTargetView(Player, ZoomableExoPlayerView, ZoomableExoPlayerView)} rather than this method. If you do
-   * wish to use this method directly, be sure to attach the player to the new view <em>before</em>
-   * calling {@code setPlayer(null)} to detach it from the old one. This ordering is significantly
-   * more efficient and may allow for more seamless transitions.
-   *
-   * @param player The {@link Player} to use, or {@code null} to detach the current player. Only
-   *     players which are accessed on the main thread are supported ({@code
-   *     player.getApplicationLooper() == Looper.getMainLooper()}).
-   */
-  public void setPlayer(@Nullable Player player) {
-    Assertions.checkState(Looper.myLooper() == Looper.getMainLooper());
-    Assertions.checkArgument(
-        player == null || player.getApplicationLooper() == Looper.getMainLooper());
-    if (this.player == player) {
-      return;
-    }
-    @Nullable Player oldPlayer = this.player;
-    if (oldPlayer != null) {
-      oldPlayer.removeListener(componentListener);
-      if (oldPlayer.isCommandAvailable(COMMAND_SET_VIDEO_SURFACE)) {
-        if (surfaceView instanceof TextureView) {
-          oldPlayer.clearVideoTextureView((TextureView) surfaceView);
-        } else if (surfaceView instanceof SurfaceView) {
-          oldPlayer.clearVideoSurfaceView((SurfaceView) surfaceView);
-        }
-      }
-    }
-    if (subtitleView != null) {
-      subtitleView.setCues(null);
-    }
-    this.player = player;
-    if (useController()) {
-      controller.setPlayer(player);
-    }
-    updateBuffering();
-    updateErrorMessage();
-    updateForCurrentTrackSelections(/* isNewPlayer= */ true);
-    if (player != null) {
-      if (player.isCommandAvailable(COMMAND_SET_VIDEO_SURFACE)) {
-        if (surfaceView instanceof TextureView) {
-          player.setVideoTextureView((TextureView) surfaceView);
-        } else if (surfaceView instanceof SurfaceView) {
-          player.setVideoSurfaceView((SurfaceView) surfaceView);
-        }
-        updateAspectRatio();
-      }
-      if (subtitleView != null && player.isCommandAvailable(COMMAND_GET_TEXT)) {
-        subtitleView.setCues(player.getCurrentCues());
-      }
-      player.addListener(componentListener);
-      maybeShowController(false);
-    } else {
-      hideController();
-    }
-  }
-
-  @Override
-  public void setVisibility(int visibility) {
-    super.setVisibility(visibility);
-    if (surfaceView instanceof SurfaceView) {
-      // Work around https://github.com/google/ExoPlayer/issues/3160.
-      surfaceView.setVisibility(visibility);
-    }
-  }
-
-  /**
-   * Sets the {@link ResizeMode}.
-   *
-   * @param resizeMode The {@link ResizeMode}.
-   */
-  public void setResizeMode(@ResizeMode int resizeMode) {
-    Assertions.checkStateNotNull(contentFrame);
-    contentFrame.setResizeMode(resizeMode);
-  }
-
-  /** Returns the {@link ResizeMode}. */
-  public @ResizeMode int getResizeMode() {
-    Assertions.checkStateNotNull(contentFrame);
-    return contentFrame.getResizeMode();
-  }
-
-  /** Returns whether artwork is displayed if present in the media. */
-  public boolean getUseArtwork() {
-    return useArtwork;
-  }
-
-  /**
-   * Sets whether artwork is displayed if present in the media.
-   *
-   * @param useArtwork Whether artwork is displayed.
-   */
-  public void setUseArtwork(boolean useArtwork) {
-    Assertions.checkState(!useArtwork || artworkView != null);
-    if (this.useArtwork != useArtwork) {
-      this.useArtwork = useArtwork;
-      updateForCurrentTrackSelections(/* isNewPlayer= */ false);
-    }
-  }
-
-  /** Returns the default artwork to display. */
-  @Nullable
-  public Drawable getDefaultArtwork() {
-    return defaultArtwork;
-  }
-
-  /**
-   * Sets the default artwork to display if {@code useArtwork} is {@code true} and no artwork is
-   * present in the media.
-   *
-   * @param defaultArtwork the default artwork to display
-   */
-  public void setDefaultArtwork(@Nullable Drawable defaultArtwork) {
-    if (this.defaultArtwork != defaultArtwork) {
-      this.defaultArtwork = defaultArtwork;
-      updateForCurrentTrackSelections(/* isNewPlayer= */ false);
-    }
-  }
-
-  /** Returns whether the playback controls can be shown. */
-  public boolean getUseController() {
-    return useController;
-  }
-
-  /**
-   * Sets whether the playback controls can be shown. If set to {@code false} the playback controls
-   * are never visible and are disconnected from the player.
-   *
-   * @param useController Whether the playback controls can be shown.
-   */
-  public void setUseController(boolean useController) {
-    Assertions.checkState(!useController || controller != null);
-    if (this.useController == useController) {
-      return;
-    }
-    this.useController = useController;
-    if (useController()) {
-      controller.setPlayer(player);
-    } else if (controller != null) {
-      controller.hide();
-      controller.setPlayer(/* player= */ null);
-    }
-    updateContentDescription();
-  }
-
-  /**
-   * Sets the background color of the {@code exo_shutter} view.
-   *
-   * @param color The background color.
-   */
-  public void setShutterBackgroundColor(int color) {
-    if (shutterView != null) {
-      shutterView.setBackgroundColor(color);
-    }
-  }
-
-  /**
-   * Sets whether the currently displayed video frame or media artwork is kept visible when the
-   * player is reset. A player reset is defined to mean the player being re-prepared with different
-   * media, the player transitioning to unprepared media or an empty list of media items, or the
-   * player being replaced or cleared by calling {@link #setPlayer(Player)}.
-   *
-   * <p>If enabled, the currently displayed video frame or media artwork will be kept visible until
-   * the player set on the view has been successfully prepared with new media and loaded enough of
-   * it to have determined the available tracks. Hence enabling this option allows transitioning
-   * from playing one piece of media to another, or from using one player instance to another,
-   * without clearing the view's content.
-   *
-   * <p>If disabled, the currently displayed video frame or media artwork will be hidden as soon as
-   * the player is reset. Note that the video frame is hidden by making {@code exo_shutter} visible.
-   * Hence the video frame will not be hidden if using a custom layout that omits this view.
-   *
-   * @param keepContentOnPlayerReset Whether the currently displayed video frame or media artwork is
-   *     kept visible when the player is reset.
-   */
-  public void setKeepContentOnPlayerReset(boolean keepContentOnPlayerReset) {
-    if (this.keepContentOnPlayerReset != keepContentOnPlayerReset) {
-      this.keepContentOnPlayerReset = keepContentOnPlayerReset;
-      updateForCurrentTrackSelections(/* isNewPlayer= */ false);
-    }
-  }
-
-  /**
-   * Sets whether a buffering spinner is displayed when the player is in the buffering state. The
-   * buffering spinner is not displayed by default.
-   *
-   * @param showBuffering The mode that defines when the buffering spinner is displayed. One of
-   *     {@link #SHOW_BUFFERING_NEVER}, {@link #SHOW_BUFFERING_WHEN_PLAYING} and {@link
-   *     #SHOW_BUFFERING_ALWAYS}.
-   */
-  public void setShowBuffering(@ShowBuffering int showBuffering) {
-    if (this.showBuffering != showBuffering) {
-      this.showBuffering = showBuffering;
-      updateBuffering();
-    }
-  }
-
-  /**
-   * Sets the optional {@link ErrorMessageProvider}.
-   *
-   * @param errorMessageProvider The error message provider.
-   */
-  public void setErrorMessageProvider(
-      @Nullable ErrorMessageProvider<? super PlaybackException> errorMessageProvider) {
-    if (this.errorMessageProvider != errorMessageProvider) {
-      this.errorMessageProvider = errorMessageProvider;
-      updateErrorMessage();
-    }
-  }
-
-  /**
-   * Sets a custom error message to be displayed by the view. The error message will be displayed
-   * permanently, unless it is cleared by passing {@code null} to this method.
-   *
-   * @param message The message to display, or {@code null} to clear a previously set message.
-   */
-  public void setCustomErrorMessage(@Nullable CharSequence message) {
-    Assertions.checkState(errorMessageView != null);
-    customErrorMessage = message;
-    updateErrorMessage();
-  }
-
-  @Override
-  public boolean dispatchKeyEvent(KeyEvent event) {
-    if (player != null && player.isPlayingAd()) {
-      return super.dispatchKeyEvent(event);
-    }
-
-    boolean isDpadKey = isDpadKey(event.getKeyCode());
-    boolean handled = false;
-    if (isDpadKey && useController() && !controller.isVisible()) {
-      // Handle the key event by showing the controller.
-      maybeShowController(true);
-      handled = true;
-    } else if (dispatchMediaKeyEvent(event) || super.dispatchKeyEvent(event)) {
-      // The key event was handled as a media key or by the super class. We should also show the
-      // controller, or extend its show timeout if already visible.
-      maybeShowController(true);
-      handled = true;
-    } else if (isDpadKey && useController()) {
-      // The key event wasn't handled, but we should extend the controller's show timeout.
-      maybeShowController(true);
-    }
-    return handled;
-  }
-
-  /**
-   * Called to process media key events. Any {@link KeyEvent} can be passed but only media key
-   * events will be handled. Does nothing if playback controls are disabled.
-   *
-   * @param event A key event.
-   * @return Whether the key event was handled.
-   */
-  public boolean dispatchMediaKeyEvent(KeyEvent event) {
-    return useController() && controller.dispatchMediaKeyEvent(event);
-  }
-
-  /** Returns whether the controller is currently visible. */
-  public boolean isControllerVisible() {
-    return controller != null && controller.isVisible();
-  }
-
-  /**
-   * Shows the playback controls. Does nothing if playback controls are disabled.
-   *
-   * <p>The playback controls are automatically hidden during playback after {{@link
-   * #getControllerShowTimeoutMs()}}. They are shown indefinitely when playback has not started yet,
-   * is paused, has ended or failed.
-   */
-  public void showController() {
-    showController(shouldShowControllerIndefinitely());
-  }
-
-  /** Hides the playback controls. Does nothing if playback controls are disabled. */
-  public void hideController() {
-    if (controller != null) {
-      controller.hide();
-    }
-  }
-
-  /**
-   * Returns the playback controls timeout. The playback controls are automatically hidden after
-   * this duration of time has elapsed without user input and with playback or buffering in
-   * progress.
-   *
-   * @return The timeout in milliseconds. A non-positive value will cause the controller to remain
-   *     visible indefinitely.
-   */
-  public int getControllerShowTimeoutMs() {
-    return controllerShowTimeoutMs;
-  }
-
-  /**
-   * Sets the playback controls timeout. The playback controls are automatically hidden after this
-   * duration of time has elapsed without user input and with playback or buffering in progress.
-   *
-   * @param controllerShowTimeoutMs The timeout in milliseconds. A non-positive value will cause the
-   *     controller to remain visible indefinitely.
-   */
-  public void setControllerShowTimeoutMs(int controllerShowTimeoutMs) {
-    Assertions.checkStateNotNull(controller);
-    this.controllerShowTimeoutMs = controllerShowTimeoutMs;
-    if (controller.isVisible()) {
-      // Update the controller's timeout if necessary.
-      showController();
-    }
-  }
-
-  /** Returns whether the playback controls are hidden by touch events. */
-  public boolean getControllerHideOnTouch() {
-    return controllerHideOnTouch;
-  }
-
-  /**
-   * Sets whether the playback controls are hidden by touch events.
-   *
-   * @param controllerHideOnTouch Whether the playback controls are hidden by touch events.
-   */
-  public void setControllerHideOnTouch(boolean controllerHideOnTouch) {
-    Assertions.checkStateNotNull(controller);
-    this.controllerHideOnTouch = controllerHideOnTouch;
-    updateContentDescription();
-  }
-
-  /**
-   * Returns whether the playback controls are automatically shown when playback starts, pauses,
-   * ends, or fails. If set to false, the playback controls can be manually operated with {@link
-   * #showController()} and {@link #hideController()}.
-   */
-  public boolean getControllerAutoShow() {
-    return controllerAutoShow;
-  }
-
-  /**
-   * Sets whether the playback controls are automatically shown when playback starts, pauses, ends,
-   * or fails. If set to false, the playback controls can be manually operated with {@link
-   * #showController()} and {@link #hideController()}.
-   *
-   * @param controllerAutoShow Whether the playback controls are allowed to show automatically.
-   */
-  public void setControllerAutoShow(boolean controllerAutoShow) {
-    this.controllerAutoShow = controllerAutoShow;
-  }
-
-  /**
-   * Sets whether the playback controls are hidden when ads are playing. Controls are always shown
-   * during ads if they are enabled and the player is paused.
-   *
-   * @param controllerHideDuringAds Whether the playback controls are hidden when ads are playing.
-   */
-  public void setControllerHideDuringAds(boolean controllerHideDuringAds) {
-    this.controllerHideDuringAds = controllerHideDuringAds;
-  }
-
-  /**
-   * Sets the {@link PlayerControlView.VisibilityListener}.
-   *
-   * @param listener The listener to be notified about visibility changes, or null to remove the
-   *     current listener.
-   */
-  public void setControllerVisibilityListener(
-      @Nullable PlayerControlView.VisibilityListener listener) {
-    Assertions.checkStateNotNull(controller);
-    if (this.controllerVisibilityListener == listener) {
-      return;
-    }
-    if (this.controllerVisibilityListener != null) {
-      controller.removeVisibilityListener(this.controllerVisibilityListener);
-    }
-    this.controllerVisibilityListener = listener;
-    if (listener != null) {
-      controller.addVisibilityListener(listener);
-    }
-  }
-
-  /**
-   * Sets whether the rewind button is shown.
-   *
-   * @param showRewindButton Whether the rewind button is shown.
-   */
-  public void setShowRewindButton(boolean showRewindButton) {
-    Assertions.checkStateNotNull(controller);
-    controller.setShowRewindButton(showRewindButton);
-  }
-
-  /**
-   * Sets whether the fast forward button is shown.
-   *
-   * @param showFastForwardButton Whether the fast forward button is shown.
-   */
-  public void setShowFastForwardButton(boolean showFastForwardButton) {
-    Assertions.checkStateNotNull(controller);
-    controller.setShowFastForwardButton(showFastForwardButton);
-  }
-
-  /**
-   * Sets whether the previous button is shown.
-   *
-   * @param showPreviousButton Whether the previous button is shown.
-   */
-  public void setShowPreviousButton(boolean showPreviousButton) {
-    Assertions.checkStateNotNull(controller);
-    controller.setShowPreviousButton(showPreviousButton);
-  }
-
-  /**
-   * Sets whether the next button is shown.
-   *
-   * @param showNextButton Whether the next button is shown.
-   */
-  public void setShowNextButton(boolean showNextButton) {
-    Assertions.checkStateNotNull(controller);
-    controller.setShowNextButton(showNextButton);
-  }
-
-  /**
-   * Sets which repeat toggle modes are enabled.
-   *
-   * @param repeatToggleModes A set of {@link RepeatModeUtil.RepeatToggleModes}.
-   */
-  public void setRepeatToggleModes(@RepeatModeUtil.RepeatToggleModes int repeatToggleModes) {
-    Assertions.checkStateNotNull(controller);
-    controller.setRepeatToggleModes(repeatToggleModes);
-  }
-
-  /**
-   * Sets whether the shuffle button is shown.
-   *
-   * @param showShuffleButton Whether the shuffle button is shown.
-   */
-  public void setShowShuffleButton(boolean showShuffleButton) {
-    Assertions.checkStateNotNull(controller);
-    controller.setShowShuffleButton(showShuffleButton);
-  }
-
-  /**
-   * Sets whether the time bar should show all windows, as opposed to just the current one.
-   *
-   * @param showMultiWindowTimeBar Whether to show all windows.
-   */
-  public void setShowMultiWindowTimeBar(boolean showMultiWindowTimeBar) {
-    Assertions.checkStateNotNull(controller);
-    controller.setShowMultiWindowTimeBar(showMultiWindowTimeBar);
-  }
-
-  /**
-   * Sets the millisecond positions of extra ad markers relative to the start of the window (or
-   * timeline, if in multi-window mode) and whether each extra ad has been played or not. The
-   * markers are shown in addition to any ad markers for ads in the player's timeline.
-   *
-   * @param extraAdGroupTimesMs The millisecond timestamps of the extra ad markers to show, or
-   *     {@code null} to show no extra ad markers.
-   * @param extraPlayedAdGroups Whether each ad has been played, or {@code null} to show no extra ad
-   *     markers.
-   */
-  public void setExtraAdGroupMarkers(
-      @Nullable long[] extraAdGroupTimesMs, @Nullable boolean[] extraPlayedAdGroups) {
-    Assertions.checkStateNotNull(controller);
-    controller.setExtraAdGroupMarkers(extraAdGroupTimesMs, extraPlayedAdGroups);
-  }
-
-  /**
-   * Sets the {@link AspectRatioFrameLayout.AspectRatioListener}.
-   *
-   * @param listener The listener to be notified about aspect ratios changes of the video content or
-   *     the content frame.
-   */
-  public void setAspectRatioListener(
-      @Nullable AspectRatioFrameLayout.AspectRatioListener listener) {
-    Assertions.checkStateNotNull(contentFrame);
-    contentFrame.setAspectRatioListener(listener);
-  }
-
-  /**
-   * Gets the view onto which video is rendered. This is a:
-   *
-   * <ul>
-   *   <li>{@link SurfaceView} by default, or if the {@code surface_type} attribute is set to {@code
-   *       surface_view}.
-   *   <li>{@link TextureView} if {@code surface_type} is {@code texture_view}.
-   *   <li>{@code SphericalGLSurfaceView} if {@code surface_type} is {@code
-   *       spherical_gl_surface_view}.
-   *   <li>{@code VideoDecoderGLSurfaceView} if {@code surface_type} is {@code
-   *       video_decoder_gl_surface_view}.
-   *   <li>{@code null} if {@code surface_type} is {@code none}.
-   * </ul>
-   *
-   * @return The {@link SurfaceView}, {@link TextureView}, {@code SphericalGLSurfaceView}, {@code
-   *     VideoDecoderGLSurfaceView} or {@code null}.
-   */
-  @Nullable
-  public View getVideoSurfaceView() {
-    return surfaceView;
-  }
-
-  /**
-   * Gets the overlay {@link FrameLayout}, which can be populated with UI elements to show on top of
-   * the player.
-   *
-   * @return The overlay {@link FrameLayout}, or {@code null} if the layout has been customized and
-   *     the overlay is not present.
-   */
-  @Nullable
-  public FrameLayout getOverlayFrameLayout() {
-    return overlayFrameLayout;
-  }
-
-  /**
-   * Gets the {@link SubtitleView}.
-   *
-   * @return The {@link SubtitleView}, or {@code null} if the layout has been customized and the
-   *     subtitle view is not present.
-   */
-  @Nullable
-  public SubtitleView getSubtitleView() {
-    return subtitleView;
-  }
-
-  @Override
-  public boolean onTouchEvent(MotionEvent event) {
-    if (!useController() || player == null) {
-      return false;
-    }
-    switch (event.getAction()) {
-      case MotionEvent.ACTION_DOWN:
-        isTouching = true;
-        return true;
-      case MotionEvent.ACTION_UP:
-        if (isTouching) {
-          isTouching = false;
-          performClick();
-          return true;
-        }
-        return false;
-      default:
-        return false;
-    }
-  }
-
-  @Override
-  public boolean performClick() {
-    super.performClick();
-    return toggleControllerVisibility();
-  }
-
-  @Override
-  public boolean onTrackballEvent(MotionEvent ev) {
-    if (!useController() || player == null) {
-      return false;
-    }
-    maybeShowController(true);
-    return true;
-  }
-
-  /**
-   * Should be called when the player is visible to the user, if the {@code surface_type} extends
-   * {@link GLSurfaceView}. It is the counterpart to {@link #onPause()}.
-   *
-   * <p>This method should typically be called in {@code Activity.onStart()}, or {@code
-   * Activity.onResume()} for API versions &lt;= 23.
-   */
-  public void onResume() {
-    if (surfaceView instanceof GLSurfaceView) {
-      ((GLSurfaceView) surfaceView).onResume();
-    }
-  }
-
-  /**
-   * Should be called when the player is no longer visible to the user, if the {@code surface_type}
-   * extends {@link GLSurfaceView}. It is the counterpart to {@link #onResume()}.
-   *
-   * <p>This method should typically be called in {@code Activity.onStop()}, or {@code
-   * Activity.onPause()} for API versions &lt;= 23.
-   */
-  public void onPause() {
-    if (surfaceView instanceof GLSurfaceView) {
-      ((GLSurfaceView) surfaceView).onPause();
-    }
-  }
-
-  /**
-   * Called when there's a change in the desired aspect ratio of the content frame. The default
-   * implementation sets the aspect ratio of the content frame to the specified value.
-   *
-   * @param contentFrame The content frame, or {@code null}.
-   * @param aspectRatio The aspect ratio to apply.
-   */
-  protected void onContentAspectRatioChanged(
-      @Nullable AspectRatioFrameLayout contentFrame, float aspectRatio) {
-    if (contentFrame != null) {
-      contentFrame.setAspectRatio(aspectRatio);
-    }
-  }
-
-  // AdsLoader.AdViewProvider implementation.
-
-  @Override
-  public ViewGroup getAdViewGroup() {
-    return Assertions.checkStateNotNull(
-        adOverlayFrameLayout, "exo_ad_overlay must be present for ad playback");
-  }
-
-  @Override
-  public List<AdOverlayInfo> getAdOverlayInfos() {
-    List<AdOverlayInfo> overlayViews = new ArrayList<>();
-    if (overlayFrameLayout != null) {
-      overlayViews.add(
-          new AdOverlayInfo(
-              overlayFrameLayout,
-              AdOverlayInfo.PURPOSE_NOT_VISIBLE,
-              /* detailedReason= */ "Transparent overlay does not impact viewability"));
-    }
-    if (controller != null) {
-      overlayViews.add(new AdOverlayInfo(controller, AdOverlayInfo.PURPOSE_CONTROLS));
-    }
-    return ImmutableList.copyOf(overlayViews);
-  }
-
-  // Internal methods.
-  private boolean useController() {
-    if (useController) {
-      Assertions.checkStateNotNull(controller);
-      return true;
-    }
-    return false;
-  }
-
-  private boolean useArtwork() {
-    if (useArtwork) {
-      Assertions.checkStateNotNull(artworkView);
-      return true;
-    }
-    return false;
-  }
-
-  private boolean toggleControllerVisibility() {
-    if (!useController() || player == null) {
-      return false;
-    }
-    if (!controller.isVisible()) {
-      maybeShowController(true);
-    } else if (controllerHideOnTouch) {
-      controller.hide();
-    }
-    return true;
-  }
-
-  /** Shows the playback controls, but only if forced or shown indefinitely. */
-  private void maybeShowController(boolean isForced) {
-    if (isPlayingAd() && controllerHideDuringAds) {
-      return;
-    }
-    if (useController()) {
-      boolean wasShowingIndefinitely = controller.isVisible() && controller.getShowTimeoutMs() <= 0;
-      boolean shouldShowIndefinitely = shouldShowControllerIndefinitely();
-      if (isForced || wasShowingIndefinitely || shouldShowIndefinitely) {
-        showController(shouldShowIndefinitely);
-      }
-    }
-  }
-
-  private boolean shouldShowControllerIndefinitely() {
-    if (player == null) {
-      return true;
-    }
-    int playbackState = player.getPlaybackState();
-    return controllerAutoShow
-        && (playbackState == Player.STATE_IDLE
-            || playbackState == Player.STATE_ENDED
-            || !player.getPlayWhenReady());
-  }
-
-  private void showController(boolean showIndefinitely) {
-    if (!useController()) {
-      return;
-    }
-    controller.setShowTimeoutMs(showIndefinitely ? 0 : controllerShowTimeoutMs);
-    controller.show();
-  }
-
-  private boolean isPlayingAd() {
-    return player != null && player.isPlayingAd() && player.getPlayWhenReady();
-  }
-
-  private void updateForCurrentTrackSelections(boolean isNewPlayer) {
-    @Nullable Player player = this.player;
-    if (player == null
-        || !player.isCommandAvailable(Player.COMMAND_GET_TRACK_INFOS)
-        || player.getCurrentTracksInfo().getTrackGroupInfos().isEmpty()) {
-      if (!keepContentOnPlayerReset) {
-        hideArtwork();
-        closeShutter();
-      }
-      return;
-    }
-
-    if (isNewPlayer && !keepContentOnPlayerReset) {
-      // Hide any video from the previous player.
-      closeShutter();
-    }
-    if (player.getCurrentTracksInfo().isTypeSelected(C.TRACK_TYPE_VIDEO)) {
-      // Video enabled, so artwork must be hidden. If the shutter is closed, it will be opened
-      // in onRenderedFirstFrame().
-      hideArtwork();
-      return;
-    }
-
-    // Video disabled so the shutter must be closed.
-    closeShutter();
-    // Display artwork if enabled and available, else hide it.
-    if (useArtwork()) {
-      if (setArtworkFromMediaMetadata(player.getMediaMetadata())) {
-        return;
-      }
-      if (setDrawableArtwork(defaultArtwork)) {
-        return;
-      }
-    }
-    // Artwork disabled or unavailable.
-    hideArtwork();
-  }
-
-  private void updateAspectRatio() {
-    VideoSize videoSize = player != null ? player.getVideoSize() : VideoSize.UNKNOWN;
-    int width = videoSize.width;
-    int height = videoSize.height;
-    int unappliedRotationDegrees = videoSize.unappliedRotationDegrees;
-    float videoAspectRatio =
-        (height == 0 || width == 0) ? 0 : (width * videoSize.pixelWidthHeightRatio) / height;
-
-    if (surfaceView instanceof TextureView) {
-      // Try to apply rotation transformation when our surface is a TextureView.
-      if (videoAspectRatio > 0
-          && (unappliedRotationDegrees == 90 || unappliedRotationDegrees == 270)) {
-        // We will apply a rotation 90/270 degree to the output texture of the TextureView.
-        // In this case, the output video's width and height will be swapped.
-        videoAspectRatio = 1 / videoAspectRatio;
-      }
-      if (textureViewRotation != 0) {
-        surfaceView.removeOnLayoutChangeListener(componentListener);
-      }
-      textureViewRotation = unappliedRotationDegrees;
-      if (textureViewRotation != 0) {
-        // The texture view's dimensions might be changed after layout step.
-        // So add an OnLayoutChangeListener to apply rotation after layout step.
-        surfaceView.addOnLayoutChangeListener(componentListener);
-      }
-      applyTextureViewRotation((TextureView) surfaceView, textureViewRotation);
-    }
-
-    onContentAspectRatioChanged(
-        contentFrame, surfaceViewIgnoresVideoAspectRatio ? 0 : videoAspectRatio);
-  }
-
-  private boolean setArtworkFromMediaMetadata(MediaMetadata mediaMetadata) {
-    if (mediaMetadata.artworkData == null) {
-      return false;
-    }
-    Bitmap bitmap =
-        BitmapFactory.decodeByteArray(
-            mediaMetadata.artworkData, /* offset= */ 0, mediaMetadata.artworkData.length);
-    return setDrawableArtwork(new BitmapDrawable(getResources(), bitmap));
-  }
-
-  private boolean setDrawableArtwork(@Nullable Drawable drawable) {
-    if (drawable != null) {
-      int drawableWidth = drawable.getIntrinsicWidth();
-      int drawableHeight = drawable.getIntrinsicHeight();
-      if (drawableWidth > 0 && drawableHeight > 0) {
-        float artworkAspectRatio = (float) drawableWidth / drawableHeight;
-        onContentAspectRatioChanged(contentFrame, artworkAspectRatio);
-        artworkView.setImageDrawable(drawable);
-        artworkView.setVisibility(VISIBLE);
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private void hideArtwork() {
-    if (artworkView != null) {
-      artworkView.setImageResource(android.R.color.transparent); // Clears any bitmap reference.
-      artworkView.setVisibility(INVISIBLE);
-    }
-  }
-
-  private void closeShutter() {
-    if (shutterView != null) {
-      shutterView.setVisibility(View.VISIBLE);
-    }
-  }
-
-  private void updateBuffering() {
-    if (bufferingView != null) {
-      boolean showBufferingSpinner =
-          player != null
-              && player.getPlaybackState() == Player.STATE_BUFFERING
-              && (showBuffering == SHOW_BUFFERING_ALWAYS
-                  || (showBuffering == SHOW_BUFFERING_WHEN_PLAYING && player.getPlayWhenReady()));
-      bufferingView.setVisibility(showBufferingSpinner ? View.VISIBLE : View.GONE);
-    }
-  }
-
-  private void updateErrorMessage() {
-    if (errorMessageView != null) {
-      if (customErrorMessage != null) {
-        errorMessageView.setText(customErrorMessage);
-        errorMessageView.setVisibility(View.VISIBLE);
-        return;
-      }
-      @Nullable PlaybackException error = player != null ? player.getPlayerError() : null;
-      if (error != null && errorMessageProvider != null) {
-        CharSequence errorMessage = errorMessageProvider.getErrorMessage(error).second;
-        errorMessageView.setText(errorMessage);
-        errorMessageView.setVisibility(View.VISIBLE);
-      } else {
-        errorMessageView.setVisibility(View.GONE);
-      }
-    }
-  }
-
-  private void updateContentDescription() {
-    if (controller == null || !useController) {
-      setContentDescription(/* contentDescription= */ null);
-    } else if (controller.getVisibility() == View.VISIBLE) {
-      setContentDescription(
-          /* contentDescription= */ controllerHideOnTouch
-              ? getResources().getString(com.google.android.exoplayer2.ui.R.string.exo_controls_hide)
-              : null);
-    } else {
-      setContentDescription(
-          /* contentDescription= */ getResources().getString(com.google.android.exoplayer2.ui.R.string.exo_controls_show));
-    }
-  }
-
-  private void updateControllerVisibility() {
-    if (isPlayingAd() && controllerHideDuringAds) {
-      hideController();
-    } else {
-      maybeShowController(false);
-    }
-  }
-
-  @RequiresApi(23)
-  private static void configureEditModeLogoV23(Resources resources, ImageView logo) {
-    logo.setImageDrawable(resources.getDrawable(com.google.android.exoplayer2.ui.R.drawable.exo_edit_mode_logo, null));
-    logo.setBackgroundColor(resources.getColor(com.google.android.exoplayer2.ui.R.color.exo_edit_mode_background_color, null));
-  }
-
-  private static void configureEditModeLogo(Resources resources, ImageView logo) {
-    logo.setImageDrawable(resources.getDrawable(com.google.android.exoplayer2.ui.R.drawable.exo_edit_mode_logo));
-    logo.setBackgroundColor(resources.getColor(com.google.android.exoplayer2.ui.R.color.exo_edit_mode_background_color));
-  }
-
-  @SuppressWarnings("ResourceType")
-  private static void setResizeModeRaw(AspectRatioFrameLayout aspectRatioFrame, int resizeMode) {
-    aspectRatioFrame.setResizeMode(resizeMode);
-  }
-
-  /** Applies a texture rotation to a {@link TextureView}. */
-  private static void applyTextureViewRotation(TextureView textureView, int textureViewRotation) {
-    Matrix transformMatrix = new Matrix();
-    float textureViewWidth = textureView.getWidth();
-    float textureViewHeight = textureView.getHeight();
-    if (textureViewWidth != 0 && textureViewHeight != 0 && textureViewRotation != 0) {
-      float pivotX = textureViewWidth / 2;
-      float pivotY = textureViewHeight / 2;
-      transformMatrix.postRotate(textureViewRotation, pivotX, pivotY);
-
-      // After rotation, scale the rotated texture to fit the TextureView size.
-      RectF originalTextureRect = new RectF(0, 0, textureViewWidth, textureViewHeight);
-      RectF rotatedTextureRect = new RectF();
-      transformMatrix.mapRect(rotatedTextureRect, originalTextureRect);
-      transformMatrix.postScale(
-          textureViewWidth / rotatedTextureRect.width(),
-          textureViewHeight / rotatedTextureRect.height(),
-          pivotX,
-          pivotY);
-    }
-    textureView.setTransform(transformMatrix);
-  }
-
-  @SuppressLint("InlinedApi")
-  private boolean isDpadKey(int keyCode) {
-    return keyCode == KeyEvent.KEYCODE_DPAD_UP
-        || keyCode == KeyEvent.KEYCODE_DPAD_UP_RIGHT
-        || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
-        || keyCode == KeyEvent.KEYCODE_DPAD_DOWN_RIGHT
-        || keyCode == KeyEvent.KEYCODE_DPAD_DOWN
-        || keyCode == KeyEvent.KEYCODE_DPAD_DOWN_LEFT
-        || keyCode == KeyEvent.KEYCODE_DPAD_LEFT
-        || keyCode == KeyEvent.KEYCODE_DPAD_UP_LEFT
-        || keyCode == KeyEvent.KEYCODE_DPAD_CENTER;
-  }
-
-  private final class ComponentListener
-      implements Player.Listener,
-          OnLayoutChangeListener,
-          OnClickListener,
-          PlayerControlView.VisibilityListener {
-
-    private final Period period;
-    private @Nullable Object lastPeriodUidWithTracks;
-
-    public ComponentListener() {
-      period = new Period();
-    }
-
-    // Player.Listener implementation
-
-    @Override
-    public void onCues(List<Cue> cues) {
-      if (subtitleView != null) {
-        subtitleView.setCues(cues);
-      }
-    }
-
-    @Override
-    public void onVideoSizeChanged(VideoSize videoSize) {
-      updateAspectRatio();
-    }
-
-    @Override
-    public void onRenderedFirstFrame() {
-      if (shutterView != null) {
-        shutterView.setVisibility(INVISIBLE);
-      }
-    }
-
-    @Override
-    public void onTracksInfoChanged(TracksInfo tracksInfo) {
-      // Suppress the update if transitioning to an unprepared period within the same window. This
-      // is necessary to avoid closing the shutter when such a transition occurs. See:
-      // https://github.com/google/ExoPlayer/issues/5507.
-      Player player = Assertions.checkNotNull(ZoomableExoPlayerView.this.player);
-      Timeline timeline = player.getCurrentTimeline();
-      if (timeline.isEmpty()) {
-        lastPeriodUidWithTracks = null;
-      } else if (!player.getCurrentTracksInfo().getTrackGroupInfos().isEmpty()) {
-        lastPeriodUidWithTracks =
-            timeline.getPeriod(player.getCurrentPeriodIndex(), period, /* setIds= */ true).uid;
-      } else if (lastPeriodUidWithTracks != null) {
-        int lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks);
-        if (lastPeriodIndexWithTracks != C.INDEX_UNSET) {
-          int lastWindowIndexWithTracks =
-              timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex;
-          if (player.getCurrentMediaItemIndex() == lastWindowIndexWithTracks) {
-            // We're in the same media item. Suppress the update.
-            return;
-          }
-        }
-        lastPeriodUidWithTracks = null;
-      }
-
-      updateForCurrentTrackSelections(/* isNewPlayer= */ false);
-    }
-
-    @Override
-    public void onPlaybackStateChanged(@Player.State int playbackState) {
-      updateBuffering();
-      updateErrorMessage();
-      updateControllerVisibility();
-    }
-
-    @Override
-    public void onPlayWhenReadyChanged(
-        boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
-      updateBuffering();
-      updateControllerVisibility();
-    }
-
-    @Override
-    public void onPositionDiscontinuity(
-        Player.PositionInfo oldPosition,
-        Player.PositionInfo newPosition,
-        @DiscontinuityReason int reason) {
-      if (isPlayingAd() && controllerHideDuringAds) {
-        hideController();
-      }
-    }
-
-    // OnLayoutChangeListener implementation
-
-    @Override
-    public void onLayoutChange(
-        View view,
-        int left,
-        int top,
-        int right,
-        int bottom,
-        int oldLeft,
-        int oldTop,
-        int oldRight,
-        int oldBottom) {
-      applyTextureViewRotation((TextureView) view, textureViewRotation);
-    }
-
-    // OnClickListener implementation
-
-    @Override
-    public void onClick(View view) {
-      toggleControllerVisibility();
-    }
-
-    // PlayerControlView.VisibilityListener implementation
-
-    @Override
-    public void onVisibilityChange(int visibility) {
-      updateContentDescription();
-    }
-  }
+					break;
+				case SURFACE_TYPE_SPHERICAL_GL_SURFACE_VIEW:
+					try {
+						Class<?> clazz =
+							Class.forName(
+								"com.google.android.exoplayer2.video.spherical.SphericalGLSurfaceView");
+						surfaceView = (View) clazz.getConstructor(Context.class).newInstance(context);
+					} catch (Exception e) {
+						throw new IllegalStateException(
+							"spherical_gl_surface_view requires an ExoPlayer dependency", e);
+					}
+					surfaceViewIgnoresVideoAspectRatio = true;
+					break;
+				case SURFACE_TYPE_VIDEO_DECODER_GL_SURFACE_VIEW:
+					try {
+						Class<?> clazz =
+							Class.forName("com.google.android.exoplayer2.video.VideoDecoderGLSurfaceView");
+						surfaceView = (View) clazz.getConstructor(Context.class).newInstance(context);
+					} catch (Exception e) {
+						throw new IllegalStateException(
+							"video_decoder_gl_surface_view requires an ExoPlayer dependency", e);
+					}
+					break;
+				default:
+					surfaceView = new SurfaceView(context);
+					break;
+			}
+			surfaceView.setLayoutParams(params);
+			// We don't want surfaceView to be clickable separately to the PlayerView itself, but we
+			// do want to register as an OnClickListener so that surfaceView implementations can propagate
+			// click events up to the PlayerView by calling their own performClick method.
+			surfaceView.setOnClickListener(componentListener);
+			surfaceView.setClickable(false);
+			contentFrame.addView(surfaceView, 0);
+		} else {
+			surfaceView = null;
+		}
+		this.surfaceViewIgnoresVideoAspectRatio = surfaceViewIgnoresVideoAspectRatio;
+
+		// Ad overlay frame layout.
+		adOverlayFrameLayout = findViewById(R.id.exo_ad_overlay);
+
+		// Overlay frame layout.
+		overlayFrameLayout = findViewById(R.id.exo_overlay);
+
+		// Artwork view.
+		artworkView = findViewById(R.id.exo_artwork);
+		this.useArtwork = useArtwork && artworkView != null;
+		if (defaultArtworkId != 0) {
+			defaultArtwork = ContextCompat.getDrawable(getContext(), defaultArtworkId);
+		}
+
+		// Subtitle view.
+		subtitleView = findViewById(R.id.exo_subtitles);
+		if (subtitleView != null) {
+			subtitleView.setUserDefaultStyle();
+			subtitleView.setUserDefaultTextSize();
+		}
+
+		// Buffering view.
+		bufferingView = findViewById(R.id.exo_buffering);
+		if (bufferingView != null) {
+			bufferingView.setVisibility(View.GONE);
+		}
+		this.showBuffering = showBuffering;
+
+		// Error message view.
+		errorMessageView = findViewById(R.id.exo_error_message);
+		if (errorMessageView != null) {
+			errorMessageView.setVisibility(View.GONE);
+		}
+
+		// Playback control view.
+		PlayerControlView customController = findViewById(R.id.exo_controller);
+		View controllerPlaceholder = findViewById(R.id.exo_controller_placeholder);
+		if (customController != null) {
+			this.controller = customController;
+		} else if (controllerPlaceholder != null) {
+			// Propagate attrs as playbackAttrs so that PlayerControlView's custom attributes are
+			// transferred, but standard attributes (e.g. background) are not.
+			this.controller = new PlayerControlView(context, null, 0, attrs);
+			controller.setId(R.id.exo_controller);
+			controller.setLayoutParams(controllerPlaceholder.getLayoutParams());
+			ViewGroup parent = ((ViewGroup) controllerPlaceholder.getParent());
+			int controllerIndex = parent.indexOfChild(controllerPlaceholder);
+			parent.removeView(controllerPlaceholder);
+			parent.addView(controller, controllerIndex);
+		} else {
+			this.controller = null;
+		}
+		this.controllerShowTimeoutMs = controller != null ? controllerShowTimeoutMs : 0;
+		this.controllerHideOnTouch = controllerHideOnTouch;
+		this.controllerAutoShow = controllerAutoShow;
+		this.controllerHideDuringAds = controllerHideDuringAds;
+		this.useController = useController && controller != null;
+		if (controller != null) {
+			controller.hide();
+			controller.addVisibilityListener(/* listener= */ componentListener);
+		}
+		if (useController) {
+			setClickable(true);
+		}
+		updateContentDescription();
+	}
+
+	/**
+	 * Switches the view targeted by a given {@link Player}.
+	 *
+	 * @param player The player whose target view is being switched.
+	 * @param oldPlayerView The old view to detach from the player.
+	 * @param newPlayerView The new view to attach to the player.
+	 */
+	public static void switchTargetView(
+		Player player, @Nullable ZoomableExoPlayerView oldPlayerView, @Nullable ZoomableExoPlayerView newPlayerView) {
+		if (oldPlayerView == newPlayerView) {
+			return;
+		}
+		// We attach the new view before detaching the old one because this ordering allows the player
+		// to swap directly from one surface to another, without transitioning through a state where no
+		// surface is attached. This is significantly more efficient and achieves a more seamless
+		// transition when using platform provided video decoders.
+		if (newPlayerView != null) {
+			newPlayerView.setPlayer(player);
+		}
+		if (oldPlayerView != null) {
+			oldPlayerView.setPlayer(null);
+		}
+	}
+
+	/** Returns the player currently set on this view, or null if no player is set. */
+	@Nullable
+	public Player getPlayer() {
+		return player;
+	}
+
+	/**
+	 * Sets the {@link Player} to use.
+	 *
+	 * <p>To transition a {@link Player} from targeting one view to another, it's recommended to use
+	 * {@link #switchTargetView(Player, ZoomableExoPlayerView, ZoomableExoPlayerView)} rather than this method. If you do
+	 * wish to use this method directly, be sure to attach the player to the new view <em>before</em>
+	 * calling {@code setPlayer(null)} to detach it from the old one. This ordering is significantly
+	 * more efficient and may allow for more seamless transitions.
+	 *
+	 * @param player The {@link Player} to use, or {@code null} to detach the current player. Only
+	 *     players which are accessed on the main thread are supported ({@code
+	 *     player.getApplicationLooper() == Looper.getMainLooper()}).
+	 */
+	public void setPlayer(@Nullable Player player) {
+		Assertions.checkState(Looper.myLooper() == Looper.getMainLooper());
+		Assertions.checkArgument(
+			player == null || player.getApplicationLooper() == Looper.getMainLooper());
+		if (this.player == player) {
+			return;
+		}
+		@Nullable Player oldPlayer = this.player;
+		if (oldPlayer != null) {
+			oldPlayer.removeListener(componentListener);
+			if (oldPlayer.isCommandAvailable(COMMAND_SET_VIDEO_SURFACE)) {
+				if (surfaceView instanceof TextureView) {
+					oldPlayer.clearVideoTextureView((TextureView) surfaceView);
+				} else if (surfaceView instanceof SurfaceView) {
+					oldPlayer.clearVideoSurfaceView((SurfaceView) surfaceView);
+				}
+			}
+		}
+		if (subtitleView != null) {
+			subtitleView.setCues(null);
+		}
+		this.player = player;
+		if (useController()) {
+			controller.setPlayer(player);
+		}
+		updateBuffering();
+		updateErrorMessage();
+		updateForCurrentTrackSelections(/* isNewPlayer= */ true);
+		if (player != null) {
+			if (player.isCommandAvailable(COMMAND_SET_VIDEO_SURFACE)) {
+				if (surfaceView instanceof TextureView) {
+					player.setVideoTextureView((TextureView) surfaceView);
+				} else if (surfaceView instanceof SurfaceView) {
+					player.setVideoSurfaceView((SurfaceView) surfaceView);
+				}
+				updateAspectRatio();
+			}
+			if (subtitleView != null && player.isCommandAvailable(COMMAND_GET_TEXT)) {
+				subtitleView.setCues(player.getCurrentCues().cues);
+			}
+			player.addListener(componentListener);
+			maybeShowController(false);
+		} else {
+			hideController();
+		}
+	}
+
+	@Override
+	public void setVisibility(int visibility) {
+		super.setVisibility(visibility);
+		if (surfaceView instanceof SurfaceView) {
+			// Work around https://github.com/google/ExoPlayer/issues/3160.
+			surfaceView.setVisibility(visibility);
+		}
+	}
+
+	/**
+	 * Sets the {@link ResizeMode}.
+	 *
+	 * @param resizeMode The {@link ResizeMode}.
+	 */
+	public void setResizeMode(@ResizeMode int resizeMode) {
+		Assertions.checkStateNotNull(contentFrame);
+		contentFrame.setResizeMode(resizeMode);
+	}
+
+	/** Returns the {@link ResizeMode}. */
+	public @ResizeMode int getResizeMode() {
+		Assertions.checkStateNotNull(contentFrame);
+		return contentFrame.getResizeMode();
+	}
+
+	/** Returns whether artwork is displayed if present in the media. */
+	public boolean getUseArtwork() {
+		return useArtwork;
+	}
+
+	/**
+	 * Sets whether artwork is displayed if present in the media.
+	 *
+	 * @param useArtwork Whether artwork is displayed.
+	 */
+	public void setUseArtwork(boolean useArtwork) {
+		Assertions.checkState(!useArtwork || artworkView != null);
+		if (this.useArtwork != useArtwork) {
+			this.useArtwork = useArtwork;
+			updateForCurrentTrackSelections(/* isNewPlayer= */ false);
+		}
+	}
+
+	/** Returns the default artwork to display. */
+	@Nullable
+	public Drawable getDefaultArtwork() {
+		return defaultArtwork;
+	}
+
+	/**
+	 * Sets the default artwork to display if {@code useArtwork} is {@code true} and no artwork is
+	 * present in the media.
+	 *
+	 * @param defaultArtwork the default artwork to display
+	 */
+	public void setDefaultArtwork(@Nullable Drawable defaultArtwork) {
+		if (this.defaultArtwork != defaultArtwork) {
+			this.defaultArtwork = defaultArtwork;
+			updateForCurrentTrackSelections(/* isNewPlayer= */ false);
+		}
+	}
+
+	/** Returns whether the playback controls can be shown. */
+	public boolean getUseController() {
+		return useController;
+	}
+
+	/**
+	 * Sets whether the playback controls can be shown. If set to {@code false} the playback controls
+	 * are never visible and are disconnected from the player.
+	 *
+	 * <p>This call will update whether the view is clickable. After the call, the view will be
+	 * clickable if playback controls can be shown or if the view has a registered click listener.
+	 *
+	 * @param useController Whether the playback controls can be shown.
+	 */
+	public void setUseController(boolean useController) {
+		Assertions.checkState(!useController || controller != null);
+		setClickable(useController || hasOnClickListeners());
+		if (this.useController == useController) {
+			return;
+		}
+		this.useController = useController;
+		if (useController()) {
+			controller.setPlayer(player);
+		} else if (controller != null) {
+			controller.hide();
+			controller.setPlayer(/* player= */ null);
+		}
+		updateContentDescription();
+	}
+
+	/**
+	 * Sets the background color of the {@code exo_shutter} view.
+	 *
+	 * @param color The background color.
+	 */
+	public void setShutterBackgroundColor(int color) {
+		if (shutterView != null) {
+			shutterView.setBackgroundColor(color);
+		}
+	}
+
+	/**
+	 * Sets whether the currently displayed video frame or media artwork is kept visible when the
+	 * player is reset. A player reset is defined to mean the player being re-prepared with different
+	 * media, the player transitioning to unprepared media or an empty list of media items, or the
+	 * player being replaced or cleared by calling {@link #setPlayer(Player)}.
+	 *
+	 * <p>If enabled, the currently displayed video frame or media artwork will be kept visible until
+	 * the player set on the view has been successfully prepared with new media and loaded enough of
+	 * it to have determined the available tracks. Hence enabling this option allows transitioning
+	 * from playing one piece of media to another, or from using one player instance to another,
+	 * without clearing the view's content.
+	 *
+	 * <p>If disabled, the currently displayed video frame or media artwork will be hidden as soon as
+	 * the player is reset. Note that the video frame is hidden by making {@code exo_shutter} visible.
+	 * Hence the video frame will not be hidden if using a custom layout that omits this view.
+	 *
+	 * @param keepContentOnPlayerReset Whether the currently displayed video frame or media artwork is
+	 *     kept visible when the player is reset.
+	 */
+	public void setKeepContentOnPlayerReset(boolean keepContentOnPlayerReset) {
+		if (this.keepContentOnPlayerReset != keepContentOnPlayerReset) {
+			this.keepContentOnPlayerReset = keepContentOnPlayerReset;
+			updateForCurrentTrackSelections(/* isNewPlayer= */ false);
+		}
+	}
+
+	/**
+	 * Sets whether a buffering spinner is displayed when the player is in the buffering state. The
+	 * buffering spinner is not displayed by default.
+	 *
+	 * @param showBuffering The mode that defines when the buffering spinner is displayed. One of
+	 *     {@link #SHOW_BUFFERING_NEVER}, {@link #SHOW_BUFFERING_WHEN_PLAYING} and {@link
+	 *     #SHOW_BUFFERING_ALWAYS}.
+	 */
+	public void setShowBuffering(@ShowBuffering int showBuffering) {
+		if (this.showBuffering != showBuffering) {
+			this.showBuffering = showBuffering;
+			updateBuffering();
+		}
+	}
+
+	/**
+	 * Sets the optional {@link ErrorMessageProvider}.
+	 *
+	 * @param errorMessageProvider The error message provider.
+	 */
+	public void setErrorMessageProvider(
+		@Nullable ErrorMessageProvider<? super PlaybackException> errorMessageProvider) {
+		if (this.errorMessageProvider != errorMessageProvider) {
+			this.errorMessageProvider = errorMessageProvider;
+			updateErrorMessage();
+		}
+	}
+
+	/**
+	 * Sets a custom error message to be displayed by the view. The error message will be displayed
+	 * permanently, unless it is cleared by passing {@code null} to this method.
+	 *
+	 * @param message The message to display, or {@code null} to clear a previously set message.
+	 */
+	public void setCustomErrorMessage(@Nullable CharSequence message) {
+		Assertions.checkState(errorMessageView != null);
+		customErrorMessage = message;
+		updateErrorMessage();
+	}
+
+	@Override
+	public boolean dispatchKeyEvent(KeyEvent event) {
+		if (player != null && player.isPlayingAd()) {
+			return super.dispatchKeyEvent(event);
+		}
+
+		boolean isDpadKey = isDpadKey(event.getKeyCode());
+		boolean handled = false;
+		if (isDpadKey && useController() && !controller.isVisible()) {
+			// Handle the key event by showing the controller.
+			maybeShowController(true);
+			handled = true;
+		} else if (dispatchMediaKeyEvent(event) || super.dispatchKeyEvent(event)) {
+			// The key event was handled as a media key or by the super class. We should also show the
+			// controller, or extend its show timeout if already visible.
+			maybeShowController(true);
+			handled = true;
+		} else if (isDpadKey && useController()) {
+			// The key event wasn't handled, but we should extend the controller's show timeout.
+			maybeShowController(true);
+		}
+		return handled;
+	}
+
+	/**
+	 * Called to process media key events. Any {@link KeyEvent} can be passed but only media key
+	 * events will be handled. Does nothing if playback controls are disabled.
+	 *
+	 * @param event A key event.
+	 * @return Whether the key event was handled.
+	 */
+	public boolean dispatchMediaKeyEvent(KeyEvent event) {
+		return useController() && controller.dispatchMediaKeyEvent(event);
+	}
+
+	/** Returns whether the controller is currently visible. */
+	public boolean isControllerVisible() {
+		return controller != null && controller.isVisible();
+	}
+
+	/**
+	 * Shows the playback controls. Does nothing if playback controls are disabled.
+	 *
+	 * <p>The playback controls are automatically hidden during playback after {{@link
+	 * #getControllerShowTimeoutMs()}}. They are shown indefinitely when playback has not started yet,
+	 * is paused, has ended or failed.
+	 */
+	public void showController() {
+		showController(shouldShowControllerIndefinitely());
+	}
+
+	/** Hides the playback controls. Does nothing if playback controls are disabled. */
+	public void hideController() {
+		if (controller != null) {
+			controller.hide();
+		}
+	}
+
+	/**
+	 * Returns the playback controls timeout. The playback controls are automatically hidden after
+	 * this duration of time has elapsed without user input and with playback or buffering in
+	 * progress.
+	 *
+	 * @return The timeout in milliseconds. A non-positive value will cause the controller to remain
+	 *     visible indefinitely.
+	 */
+	public int getControllerShowTimeoutMs() {
+		return controllerShowTimeoutMs;
+	}
+
+	/**
+	 * Sets the playback controls timeout. The playback controls are automatically hidden after this
+	 * duration of time has elapsed without user input and with playback or buffering in progress.
+	 *
+	 * @param controllerShowTimeoutMs The timeout in milliseconds. A non-positive value will cause the
+	 *     controller to remain visible indefinitely.
+	 */
+	public void setControllerShowTimeoutMs(int controllerShowTimeoutMs) {
+		Assertions.checkStateNotNull(controller);
+		this.controllerShowTimeoutMs = controllerShowTimeoutMs;
+		if (controller.isVisible()) {
+			// Update the controller's timeout if necessary.
+			showController();
+		}
+	}
+
+	/** Returns whether the playback controls are hidden by touch events. */
+	public boolean getControllerHideOnTouch() {
+		return controllerHideOnTouch;
+	}
+
+	/**
+	 * Sets whether the playback controls are hidden by touch events.
+	 *
+	 * @param controllerHideOnTouch Whether the playback controls are hidden by touch events.
+	 */
+	public void setControllerHideOnTouch(boolean controllerHideOnTouch) {
+		Assertions.checkStateNotNull(controller);
+		this.controllerHideOnTouch = controllerHideOnTouch;
+		updateContentDescription();
+	}
+
+	/**
+	 * Returns whether the playback controls are automatically shown when playback starts, pauses,
+	 * ends, or fails. If set to false, the playback controls can be manually operated with {@link
+	 * #showController()} and {@link #hideController()}.
+	 */
+	public boolean getControllerAutoShow() {
+		return controllerAutoShow;
+	}
+
+	/**
+	 * Sets whether the playback controls are automatically shown when playback starts, pauses, ends,
+	 * or fails. If set to false, the playback controls can be manually operated with {@link
+	 * #showController()} and {@link #hideController()}.
+	 *
+	 * @param controllerAutoShow Whether the playback controls are allowed to show automatically.
+	 */
+	public void setControllerAutoShow(boolean controllerAutoShow) {
+		this.controllerAutoShow = controllerAutoShow;
+	}
+
+	/**
+	 * Sets whether the playback controls are hidden when ads are playing. Controls are always shown
+	 * during ads if they are enabled and the player is paused.
+	 *
+	 * @param controllerHideDuringAds Whether the playback controls are hidden when ads are playing.
+	 */
+	public void setControllerHideDuringAds(boolean controllerHideDuringAds) {
+		this.controllerHideDuringAds = controllerHideDuringAds;
+	}
+
+	/**
+	 * Sets the {@link PlayerControlView.VisibilityListener}.
+	 *
+	 * @param listener The listener to be notified about visibility changes, or null to remove the
+	 *     current listener.
+	 */
+	public void setControllerVisibilityListener(
+		@Nullable PlayerControlView.VisibilityListener listener) {
+		Assertions.checkStateNotNull(controller);
+		if (this.controllerVisibilityListener == listener) {
+			return;
+		}
+		if (this.controllerVisibilityListener != null) {
+			controller.removeVisibilityListener(this.controllerVisibilityListener);
+		}
+		this.controllerVisibilityListener = listener;
+		if (listener != null) {
+			controller.addVisibilityListener(listener);
+		}
+	}
+
+	/**
+	 * Sets whether the rewind button is shown.
+	 *
+	 * @param showRewindButton Whether the rewind button is shown.
+	 */
+	public void setShowRewindButton(boolean showRewindButton) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowRewindButton(showRewindButton);
+	}
+
+	/**
+	 * Sets whether the fast forward button is shown.
+	 *
+	 * @param showFastForwardButton Whether the fast forward button is shown.
+	 */
+	public void setShowFastForwardButton(boolean showFastForwardButton) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowFastForwardButton(showFastForwardButton);
+	}
+
+	/**
+	 * Sets whether the previous button is shown.
+	 *
+	 * @param showPreviousButton Whether the previous button is shown.
+	 */
+	public void setShowPreviousButton(boolean showPreviousButton) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowPreviousButton(showPreviousButton);
+	}
+
+	/**
+	 * Sets whether the next button is shown.
+	 *
+	 * @param showNextButton Whether the next button is shown.
+	 */
+	public void setShowNextButton(boolean showNextButton) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowNextButton(showNextButton);
+	}
+
+	/**
+	 * Sets which repeat toggle modes are enabled.
+	 *
+	 * @param repeatToggleModes A set of {@link RepeatModeUtil.RepeatToggleModes}.
+	 */
+	public void setRepeatToggleModes(@RepeatModeUtil.RepeatToggleModes int repeatToggleModes) {
+		Assertions.checkStateNotNull(controller);
+		controller.setRepeatToggleModes(repeatToggleModes);
+	}
+
+	/**
+	 * Sets whether the shuffle button is shown.
+	 *
+	 * @param showShuffleButton Whether the shuffle button is shown.
+	 */
+	public void setShowShuffleButton(boolean showShuffleButton) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowShuffleButton(showShuffleButton);
+	}
+
+	/**
+	 * Sets whether the time bar should show all windows, as opposed to just the current one.
+	 *
+	 * @param showMultiWindowTimeBar Whether to show all windows.
+	 */
+	public void setShowMultiWindowTimeBar(boolean showMultiWindowTimeBar) {
+		Assertions.checkStateNotNull(controller);
+		controller.setShowMultiWindowTimeBar(showMultiWindowTimeBar);
+	}
+
+	/**
+	 * Sets the millisecond positions of extra ad markers relative to the start of the window (or
+	 * timeline, if in multi-window mode) and whether each extra ad has been played or not. The
+	 * markers are shown in addition to any ad markers for ads in the player's timeline.
+	 *
+	 * @param extraAdGroupTimesMs The millisecond timestamps of the extra ad markers to show, or
+	 *     {@code null} to show no extra ad markers.
+	 * @param extraPlayedAdGroups Whether each ad has been played, or {@code null} to show no extra ad
+	 *     markers.
+	 */
+	public void setExtraAdGroupMarkers(
+		@Nullable long[] extraAdGroupTimesMs, @Nullable boolean[] extraPlayedAdGroups) {
+		Assertions.checkStateNotNull(controller);
+		controller.setExtraAdGroupMarkers(extraAdGroupTimesMs, extraPlayedAdGroups);
+	}
+
+	/**
+	 * Sets the {@link AspectRatioFrameLayout.AspectRatioListener}.
+	 *
+	 * @param listener The listener to be notified about aspect ratios changes of the video content or
+	 *     the content frame.
+	 */
+	public void setAspectRatioListener(
+		@Nullable AspectRatioFrameLayout.AspectRatioListener listener) {
+		Assertions.checkStateNotNull(contentFrame);
+		contentFrame.setAspectRatioListener(listener);
+	}
+
+	/**
+	 * Gets the view onto which video is rendered. This is a:
+	 *
+	 * <ul>
+	 *   <li>{@link SurfaceView} by default, or if the {@code surface_type} attribute is set to {@code
+	 *       surface_view}.
+	 *   <li>{@link TextureView} if {@code surface_type} is {@code texture_view}.
+	 *   <li>{@code SphericalGLSurfaceView} if {@code surface_type} is {@code
+	 *       spherical_gl_surface_view}.
+	 *   <li>{@code VideoDecoderGLSurfaceView} if {@code surface_type} is {@code
+	 *       video_decoder_gl_surface_view}.
+	 *   <li>{@code null} if {@code surface_type} is {@code none}.
+	 * </ul>
+	 *
+	 * @return The {@link SurfaceView}, {@link TextureView}, {@code SphericalGLSurfaceView}, {@code
+	 *     VideoDecoderGLSurfaceView} or {@code null}.
+	 */
+	@Nullable
+	public View getVideoSurfaceView() {
+		return surfaceView;
+	}
+
+	/**
+	 * Gets the overlay {@link FrameLayout}, which can be populated with UI elements to show on top of
+	 * the player.
+	 *
+	 * @return The overlay {@link FrameLayout}, or {@code null} if the layout has been customized and
+	 *     the overlay is not present.
+	 */
+	@Nullable
+	public FrameLayout getOverlayFrameLayout() {
+		return overlayFrameLayout;
+	}
+
+	/**
+	 * Gets the {@link SubtitleView}.
+	 *
+	 * @return The {@link SubtitleView}, or {@code null} if the layout has been customized and the
+	 *     subtitle view is not present.
+	 */
+	@Nullable
+	public SubtitleView getSubtitleView() {
+		return subtitleView;
+	}
+
+	@Override
+	public boolean performClick() {
+		toggleControllerVisibility();
+		return super.performClick();
+	}
+
+	@Override
+	public boolean onTrackballEvent(MotionEvent ev) {
+		if (!useController() || player == null) {
+			return false;
+		}
+		maybeShowController(true);
+		return true;
+	}
+
+	/**
+	 * Should be called when the player is visible to the user, if the {@code surface_type} extends
+	 * {@link GLSurfaceView}. It is the counterpart to {@link #onPause()}.
+	 *
+	 * <p>This method should typically be called in {@code Activity.onStart()}, or {@code
+	 * Activity.onResume()} for API versions &lt;= 23.
+	 */
+	public void onResume() {
+		if (surfaceView instanceof GLSurfaceView) {
+			((GLSurfaceView) surfaceView).onResume();
+		}
+	}
+
+	/**
+	 * Should be called when the player is no longer visible to the user, if the {@code surface_type}
+	 * extends {@link GLSurfaceView}. It is the counterpart to {@link #onResume()}.
+	 *
+	 * <p>This method should typically be called in {@code Activity.onStop()}, or {@code
+	 * Activity.onPause()} for API versions &lt;= 23.
+	 */
+	public void onPause() {
+		if (surfaceView instanceof GLSurfaceView) {
+			((GLSurfaceView) surfaceView).onPause();
+		}
+	}
+
+	/**
+	 * Called when there's a change in the desired aspect ratio of the content frame. The default
+	 * implementation sets the aspect ratio of the content frame to the specified value.
+	 *
+	 * @param contentFrame The content frame, or {@code null}.
+	 * @param aspectRatio The aspect ratio to apply.
+	 */
+	protected void onContentAspectRatioChanged(
+		@Nullable AspectRatioFrameLayout contentFrame, float aspectRatio) {
+		if (contentFrame != null) {
+			contentFrame.setAspectRatio(aspectRatio);
+		}
+	}
+
+	// AdsLoader.AdViewProvider implementation.
+
+	@Override
+	public ViewGroup getAdViewGroup() {
+		return Assertions.checkStateNotNull(
+			adOverlayFrameLayout, "exo_ad_overlay must be present for ad playback");
+	}
+
+	@Override
+	public List<AdOverlayInfo> getAdOverlayInfos() {
+		List<AdOverlayInfo> overlayViews = new ArrayList<>();
+		if (overlayFrameLayout != null) {
+			overlayViews.add(
+				new AdOverlayInfo(
+					overlayFrameLayout,
+					AdOverlayInfo.PURPOSE_NOT_VISIBLE,
+					/* detailedReason= */ "Transparent overlay does not impact viewability"));
+		}
+		if (controller != null) {
+			overlayViews.add(new AdOverlayInfo(controller, AdOverlayInfo.PURPOSE_CONTROLS));
+		}
+		return ImmutableList.copyOf(overlayViews);
+	}
+
+	// Internal methods.
+	private boolean useController() {
+		if (useController) {
+			Assertions.checkStateNotNull(controller);
+			return true;
+		}
+		return false;
+	}
+
+	private boolean useArtwork() {
+		if (useArtwork) {
+			Assertions.checkStateNotNull(artworkView);
+			return true;
+		}
+		return false;
+	}
+
+	private void toggleControllerVisibility() {
+		if (!useController() || player == null) {
+			return;
+		}
+		if (!controller.isVisible()) {
+			maybeShowController(true);
+		} else if (controllerHideOnTouch) {
+			controller.hide();
+		}
+	}
+
+	/** Shows the playback controls, but only if forced or shown indefinitely. */
+	private void maybeShowController(boolean isForced) {
+		if (isPlayingAd() && controllerHideDuringAds) {
+			return;
+		}
+		if (useController()) {
+			boolean wasShowingIndefinitely = controller.isVisible() && controller.getShowTimeoutMs() <= 0;
+			boolean shouldShowIndefinitely = shouldShowControllerIndefinitely();
+			if (isForced || wasShowingIndefinitely || shouldShowIndefinitely) {
+				showController(shouldShowIndefinitely);
+			}
+		}
+	}
+
+	private boolean shouldShowControllerIndefinitely() {
+		if (player == null) {
+			return true;
+		}
+		int playbackState = player.getPlaybackState();
+		return controllerAutoShow
+			&& (playbackState == Player.STATE_IDLE
+			|| playbackState == Player.STATE_ENDED
+			|| !player.getPlayWhenReady());
+	}
+
+	private void showController(boolean showIndefinitely) {
+		if (!useController()) {
+			return;
+		}
+		controller.setShowTimeoutMs(showIndefinitely ? 0 : controllerShowTimeoutMs);
+		controller.show();
+	}
+
+	private boolean isPlayingAd() {
+		return player != null && player.isPlayingAd() && player.getPlayWhenReady();
+	}
+
+	private void updateForCurrentTrackSelections(boolean isNewPlayer) {
+		@Nullable Player player = this.player;
+		if (player == null
+			|| !player.isCommandAvailable(Player.COMMAND_GET_TRACKS)
+			|| player.getCurrentTracks().isEmpty()) {
+			if (!keepContentOnPlayerReset) {
+				hideArtwork();
+				closeShutter();
+			}
+			return;
+		}
+
+		if (isNewPlayer && !keepContentOnPlayerReset) {
+			// Hide any video from the previous player.
+			closeShutter();
+		}
+		if (player.getCurrentTracks().isTypeSelected(C.TRACK_TYPE_VIDEO)) {
+			// Video enabled, so artwork must be hidden. If the shutter is closed, it will be opened
+			// in onRenderedFirstFrame().
+			hideArtwork();
+			return;
+		}
+
+		// Video disabled so the shutter must be closed.
+		closeShutter();
+		// Display artwork if enabled and available, else hide it.
+		if (useArtwork()) {
+			if (setArtworkFromMediaMetadata(player.getMediaMetadata())) {
+				return;
+			}
+			if (setDrawableArtwork(defaultArtwork)) {
+				return;
+			}
+		}
+		// Artwork disabled or unavailable.
+		hideArtwork();
+	}
+
+	private void updateAspectRatio() {
+		VideoSize videoSize = player != null ? player.getVideoSize() : VideoSize.UNKNOWN;
+		int width = videoSize.width;
+		int height = videoSize.height;
+		int unappliedRotationDegrees = videoSize.unappliedRotationDegrees;
+		float videoAspectRatio =
+			(height == 0 || width == 0) ? 0 : (width * videoSize.pixelWidthHeightRatio) / height;
+
+		if (surfaceView instanceof TextureView) {
+			// Try to apply rotation transformation when our surface is a TextureView.
+			if (videoAspectRatio > 0
+				&& (unappliedRotationDegrees == 90 || unappliedRotationDegrees == 270)) {
+				// We will apply a rotation 90/270 degree to the output texture of the TextureView.
+				// In this case, the output video's width and height will be swapped.
+				videoAspectRatio = 1 / videoAspectRatio;
+			}
+			if (textureViewRotation != 0) {
+				surfaceView.removeOnLayoutChangeListener(componentListener);
+			}
+			textureViewRotation = unappliedRotationDegrees;
+			if (textureViewRotation != 0) {
+				// The texture view's dimensions might be changed after layout step.
+				// So add an OnLayoutChangeListener to apply rotation after layout step.
+				surfaceView.addOnLayoutChangeListener(componentListener);
+			}
+			applyTextureViewRotation((TextureView) surfaceView, textureViewRotation);
+		}
+
+		onContentAspectRatioChanged(
+			contentFrame, surfaceViewIgnoresVideoAspectRatio ? 0 : videoAspectRatio);
+	}
+
+	private boolean setArtworkFromMediaMetadata(MediaMetadata mediaMetadata) {
+		if (mediaMetadata.artworkData == null) {
+			return false;
+		}
+		Bitmap bitmap =
+			BitmapFactory.decodeByteArray(
+				mediaMetadata.artworkData, /* offset= */ 0, mediaMetadata.artworkData.length);
+		return setDrawableArtwork(new BitmapDrawable(getResources(), bitmap));
+	}
+
+	private boolean setDrawableArtwork(@Nullable Drawable drawable) {
+		if (drawable != null) {
+			int drawableWidth = drawable.getIntrinsicWidth();
+			int drawableHeight = drawable.getIntrinsicHeight();
+			if (drawableWidth > 0 && drawableHeight > 0) {
+				float artworkAspectRatio = (float) drawableWidth / drawableHeight;
+				onContentAspectRatioChanged(contentFrame, artworkAspectRatio);
+				artworkView.setImageDrawable(drawable);
+				artworkView.setVisibility(VISIBLE);
+				return true;
+			}
+		}
+		return false;
+	}
+
+	private void hideArtwork() {
+		if (artworkView != null) {
+			artworkView.setImageResource(android.R.color.transparent); // Clears any bitmap reference.
+			artworkView.setVisibility(INVISIBLE);
+		}
+	}
+
+	private void closeShutter() {
+		if (shutterView != null) {
+			shutterView.setVisibility(View.VISIBLE);
+		}
+	}
+
+	private void updateBuffering() {
+		if (bufferingView != null) {
+			boolean showBufferingSpinner =
+				player != null
+					&& player.getPlaybackState() == Player.STATE_BUFFERING
+					&& (showBuffering == SHOW_BUFFERING_ALWAYS
+					|| (showBuffering == SHOW_BUFFERING_WHEN_PLAYING && player.getPlayWhenReady()));
+			bufferingView.setVisibility(showBufferingSpinner ? View.VISIBLE : View.GONE);
+		}
+	}
+
+	private void updateErrorMessage() {
+		if (errorMessageView != null) {
+			if (customErrorMessage != null) {
+				errorMessageView.setText(customErrorMessage);
+				errorMessageView.setVisibility(View.VISIBLE);
+				return;
+			}
+			@Nullable PlaybackException error = player != null ? player.getPlayerError() : null;
+			if (error != null && errorMessageProvider != null) {
+				CharSequence errorMessage = errorMessageProvider.getErrorMessage(error).second;
+				errorMessageView.setText(errorMessage);
+				errorMessageView.setVisibility(View.VISIBLE);
+			} else {
+				errorMessageView.setVisibility(View.GONE);
+			}
+		}
+	}
+
+	private void updateContentDescription() {
+		if (controller == null || !useController) {
+			setContentDescription(/* contentDescription= */ null);
+		} else if (controller.getVisibility() == View.VISIBLE) {
+			setContentDescription(
+				/* contentDescription= */ controllerHideOnTouch
+					? getResources().getString(R.string.exo_controls_hide)
+					: null);
+		} else {
+			setContentDescription(
+				/* contentDescription= */ getResources().getString(R.string.exo_controls_show));
+		}
+	}
+
+	private void updateControllerVisibility() {
+		if (isPlayingAd() && controllerHideDuringAds) {
+			hideController();
+		} else {
+			maybeShowController(false);
+		}
+	}
+
+	@RequiresApi(23)
+	private static void configureEditModeLogoV23(Resources resources, ImageView logo) {
+		logo.setImageDrawable(resources.getDrawable(R.drawable.exo_edit_mode_logo, null));
+		logo.setBackgroundColor(resources.getColor(R.color.exo_edit_mode_background_color, null));
+	}
+
+	private static void configureEditModeLogo(Resources resources, ImageView logo) {
+		logo.setImageDrawable(resources.getDrawable(R.drawable.exo_edit_mode_logo));
+		logo.setBackgroundColor(resources.getColor(R.color.exo_edit_mode_background_color));
+	}
+
+	@SuppressWarnings("ResourceType")
+	private static void setResizeModeRaw(AspectRatioFrameLayout aspectRatioFrame, int resizeMode) {
+		aspectRatioFrame.setResizeMode(resizeMode);
+	}
+
+	/** Applies a texture rotation to a {@link TextureView}. */
+	private static void applyTextureViewRotation(TextureView textureView, int textureViewRotation) {
+		Matrix transformMatrix = new Matrix();
+		float textureViewWidth = textureView.getWidth();
+		float textureViewHeight = textureView.getHeight();
+		if (textureViewWidth != 0 && textureViewHeight != 0 && textureViewRotation != 0) {
+			float pivotX = textureViewWidth / 2;
+			float pivotY = textureViewHeight / 2;
+			transformMatrix.postRotate(textureViewRotation, pivotX, pivotY);
+
+			// After rotation, scale the rotated texture to fit the TextureView size.
+			RectF originalTextureRect = new RectF(0, 0, textureViewWidth, textureViewHeight);
+			RectF rotatedTextureRect = new RectF();
+			transformMatrix.mapRect(rotatedTextureRect, originalTextureRect);
+			transformMatrix.postScale(
+				textureViewWidth / rotatedTextureRect.width(),
+				textureViewHeight / rotatedTextureRect.height(),
+				pivotX,
+				pivotY);
+		}
+		textureView.setTransform(transformMatrix);
+	}
+
+	@SuppressLint("InlinedApi")
+	private boolean isDpadKey(int keyCode) {
+		return keyCode == KeyEvent.KEYCODE_DPAD_UP
+			|| keyCode == KeyEvent.KEYCODE_DPAD_UP_RIGHT
+			|| keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
+			|| keyCode == KeyEvent.KEYCODE_DPAD_DOWN_RIGHT
+			|| keyCode == KeyEvent.KEYCODE_DPAD_DOWN
+			|| keyCode == KeyEvent.KEYCODE_DPAD_DOWN_LEFT
+			|| keyCode == KeyEvent.KEYCODE_DPAD_LEFT
+			|| keyCode == KeyEvent.KEYCODE_DPAD_UP_LEFT
+			|| keyCode == KeyEvent.KEYCODE_DPAD_CENTER;
+	}
+
+	private final class ComponentListener
+		implements Player.Listener,
+		OnLayoutChangeListener,
+		OnClickListener,
+		PlayerControlView.VisibilityListener {
+
+		private final Period period;
+		private @Nullable Object lastPeriodUidWithTracks;
+
+		public ComponentListener() {
+			period = new Period();
+		}
+
+		// Player.Listener implementation
+
+		@Override
+		public void onCues(CueGroup cueGroup) {
+			if (subtitleView != null) {
+				subtitleView.setCues(cueGroup.cues);
+			}
+		}
+
+		@Override
+		public void onVideoSizeChanged(VideoSize videoSize) {
+			updateAspectRatio();
+		}
+
+		@Override
+		public void onRenderedFirstFrame() {
+			if (shutterView != null) {
+				shutterView.setVisibility(INVISIBLE);
+			}
+		}
+
+		@Override
+		public void onTracksChanged(Tracks tracks) {
+			// Suppress the update if transitioning to an unprepared period within the same window. This
+			// is necessary to avoid closing the shutter when such a transition occurs. See:
+			// https://github.com/google/ExoPlayer/issues/5507.
+			Player player = Assertions.checkNotNull(ZoomableExoPlayerView.this.player);
+			Timeline timeline = player.getCurrentTimeline();
+			if (timeline.isEmpty()) {
+				lastPeriodUidWithTracks = null;
+			} else if (!player.getCurrentTracks().isEmpty()) {
+				lastPeriodUidWithTracks =
+					timeline.getPeriod(player.getCurrentPeriodIndex(), period, /* setIds= */ true).uid;
+			} else if (lastPeriodUidWithTracks != null) {
+				int lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks);
+				if (lastPeriodIndexWithTracks != C.INDEX_UNSET) {
+					int lastWindowIndexWithTracks =
+						timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex;
+					if (player.getCurrentMediaItemIndex() == lastWindowIndexWithTracks) {
+						// We're in the same media item. Suppress the update.
+						return;
+					}
+				}
+				lastPeriodUidWithTracks = null;
+			}
+
+			updateForCurrentTrackSelections(/* isNewPlayer= */ false);
+		}
+
+		@Override
+		public void onPlaybackStateChanged(@Player.State int playbackState) {
+			updateBuffering();
+			updateErrorMessage();
+			updateControllerVisibility();
+		}
+
+		@Override
+		public void onPlayWhenReadyChanged(
+			boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
+			updateBuffering();
+			updateControllerVisibility();
+		}
+
+		@Override
+		public void onPositionDiscontinuity(
+			Player.PositionInfo oldPosition,
+			Player.PositionInfo newPosition,
+			@DiscontinuityReason int reason) {
+			if (isPlayingAd() && controllerHideDuringAds) {
+				hideController();
+			}
+		}
+
+		// OnLayoutChangeListener implementation
+
+		@Override
+		public void onLayoutChange(
+			View view,
+			int left,
+			int top,
+			int right,
+			int bottom,
+			int oldLeft,
+			int oldTop,
+			int oldRight,
+			int oldBottom) {
+			applyTextureViewRotation((TextureView) view, textureViewRotation);
+		}
+
+		// OnClickListener implementation
+
+		@Override
+		public void onClick(View view) {
+			toggleControllerVisibility();
+		}
+
+		// PlayerControlView.VisibilityListener implementation
+
+		@Override
+		public void onVisibilityChange(int visibility) {
+			updateContentDescription();
+		}
+	}
+
+
+	// THREEMA
+	@Override
+	public boolean onTouchEvent(MotionEvent event) {
+		if (!useController() || player == null) {
+			return false;
+		}
+		switch (event.getAction()) {
+			case MotionEvent.ACTION_DOWN:
+				isTouching = true;
+				return true;
+			case MotionEvent.ACTION_UP:
+				if (isTouching) {
+					isTouching = false;
+					performClick();
+					return true;
+				}
+				return false;
+			default:
+				return false;
+		}
+	}
 }

+ 1 - 1
app/src/main/java/ch/threema/app/utils/AnimationUtil.java

@@ -155,7 +155,7 @@ public class AnimationUtil {
 		v.startAnimation(a);
 	}
 
-	public static void startActivityForResult(Activity activity, View v, Intent intent, int requestCode) {
+	public static void startActivityForResult(Activity activity, @Nullable View v, Intent intent, int requestCode) {
 		logger.debug("start activity for result " + activity + " " + intent + " " + requestCode);
 		if (activity != null) {
 			ActivityOptionsCompat options = null;

+ 8 - 0
app/src/main/java/ch/threema/app/utils/ConfigUtils.java

@@ -198,6 +198,10 @@ public class ConfigUtils {
 		return (Build.MANUFACTURER.equalsIgnoreCase("OnePlus"));
 	}
 
+	public static boolean isSamsungDevice() {
+		return (Build.MANUFACTURER.equalsIgnoreCase("Samsung"));
+	}
+
 	public static boolean isSonyDevice() {
 		return (Build.MANUFACTURER.equalsIgnoreCase("Sony"));
 	}
@@ -222,6 +226,10 @@ public class ConfigUtils {
 		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
 	}
 
+	public static boolean hasAsyncMediaCodecBug() {
+		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && ConfigUtils.isSamsungDevice() && Build.MODEL.startsWith("SM-G97");
+	}
+
 	public static boolean hasScopedStorage() {
 		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
 	}

+ 130 - 0
app/src/main/java/ch/threema/app/utils/GeoLocationUtil.java

@@ -22,6 +22,7 @@
 package ch.threema.app.utils;
 
 import android.content.Context;
+import android.content.Intent;
 import android.location.Address;
 import android.location.Geocoder;
 import android.location.Location;
@@ -31,6 +32,9 @@ import android.os.Handler;
 import android.os.Message;
 import android.widget.TextView;
 
+import com.mapbox.mapboxsdk.constants.GeometryConstants;
+import com.mapbox.mapboxsdk.geometry.LatLng;
+
 import org.slf4j.Logger;
 
 import java.io.IOException;
@@ -39,9 +43,13 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.regex.Pattern;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
 import ch.threema.app.R;
+import ch.threema.app.activities.MapActivity;
 import ch.threema.app.services.MessageService;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -50,6 +58,12 @@ import ch.threema.storage.models.data.LocationDataModel;
 public class GeoLocationUtil {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("GeoLocationUtil");
 
+	private static final String GEO_NUM = "-?\\d+(\\.\\d+)?";
+	private static final Pattern GEO_PATTERN = Pattern.compile(
+		"\\bgeo:-?" + GEO_NUM + "," + GEO_NUM + "(," + GEO_NUM + ")?"   // the geo keyword followed by 2 or 3 coordinates
+			+ "(;[\\w\\-]+(=[\\[\\]:&+$\\w\\-.!~*'()%]+)?)*\\b"         // additional parameters, e.g., ';u=12;crs=Moon-2011'
+	);
+
 	private TextView targetView;
 
 	private final static Map<String, String> addressCache = new HashMap<String, String>();
@@ -185,4 +199,120 @@ public class GeoLocationUtil {
 		}
 		return Uri.parse(geoString);
 	}
+
+	/**
+	 * Get the geo URI pattern to detect geo URIs. This geo URI regex also matches invalid numbers
+	 * or parameters, e.g.:
+	 * <ul>
+	 *     <li>invalid hexadecimal numbers as parameter values,</li>
+	 *     <li>negative uncertainty values,</li>
+	 *     <li>too large latitudes,</li>
+	 *     <li>invalid altitudes, and</li>
+	 *     <li>invalid additional parameters</li>
+	 * </ul>
+	 *
+	 * To do checks on latitude and longitude use {@link #isValidGeoUri(Uri) isValidGeoUri}
+	 *
+	 * @return the geo URI pattern
+	 */
+	public static Pattern getGeoUriPattern() {
+		return GEO_PATTERN;
+	}
+
+	/**
+	 * Returns true if the given geo uri string is a valid geo uri. It checks that it is syntactically
+	 * correct and also performs some checks on the latitude and longitude.
+	 */
+	public static boolean isValidGeoUri(@NonNull String geo) {
+		return isValidGeoUri(Uri.parse(geo));
+	}
+
+	/**
+	 * Returns true if the given geo uri is a valid geo uri. It checks that it is syntactically
+	 * correct and also performs some checks on the latitude and longitude. Parameters like the
+	 * uncertainty or altitude are ignored.
+	 */
+	public static boolean isValidGeoUri(@NonNull Uri uri) {
+		return getLocationDataFromGeoUri(uri) != null;
+	}
+
+	/**
+	 * Show the location data on a map.
+	 *
+	 * @return true if the device supports map libre and the location can be shown, false otherwise
+	 */
+	public static boolean viewLocation(@NonNull Context context, @NonNull LocationDataModel locationData) {
+		if (ConfigUtils.hasNoMapLibreSupport()) {
+			return false;
+		}
+		Intent intent = new Intent(context, MapActivity.class);
+		IntentDataUtil.append(new LatLng(locationData.getLatitude(),
+				locationData.getLongitude()),
+			context.getString(R.string.app_name),
+			locationData.getPoi(),
+			locationData.getAddress(),
+			intent);
+		context.startActivity(intent);
+
+		return true;
+	}
+
+	/**
+	 * Show the location data on a map.
+	 *
+	 * @return true if the device supports map libre and the location can be shown, false otherwise
+	 */
+	public static boolean viewLocation(@NonNull Context context, @NonNull Uri uri) {
+		LocationDataModel locationData = getLocationDataFromGeoUri(uri);
+		if (locationData == null) {
+			return false;
+		}
+		return viewLocation(context, locationData);
+	}
+
+	/**
+	 * Get the location data from a geo uri.
+	 *
+	 * @param uri the uri where the necessary geo information is extracted
+	 * @return the location data or null if the uri could not be parsed or contained invalid values
+	 */
+	@Nullable
+	private static LocationDataModel getLocationDataFromGeoUri(@NonNull Uri uri) {
+		String geoUri = uri.toString();
+		if (!GEO_PATTERN.matcher(geoUri).matches()) {
+			return null;
+		}
+
+		int separator = geoUri.indexOf(',');
+		int longitudeEnd;
+		for (longitudeEnd = separator + 1; longitudeEnd < geoUri.length(); longitudeEnd++) {
+			if (geoUri.charAt(longitudeEnd) == ',' || geoUri.charAt(longitudeEnd) == ';') {
+				break;
+			}
+		}
+
+		double latitude;
+		double longitude;
+
+		try {
+			latitude = Double.parseDouble(geoUri.substring(4, separator));
+			longitude = Double.parseDouble(geoUri.substring(separator + 1, longitudeEnd));
+		} catch (NumberFormatException nfe) {
+			// Illegal number as latitude or longitude
+			return null;
+		}
+
+		if (Math.abs(latitude) > GeometryConstants.MAX_LATITUDE) {
+			// Too large absolute value of latitude
+			return null;
+		}
+
+		if (longitude < GeometryConstants.MIN_LONGITUDE || longitude > GeometryConstants.MAX_LONGITUDE) {
+			// Longitude is invalid (infinity)
+			return null;
+		}
+
+		return new LocationDataModel(latitude, longitude, 0, "", "");
+	}
+
 }

+ 25 - 2
app/src/main/java/ch/threema/app/utils/LinkifyUtil.java

@@ -32,6 +32,7 @@ import android.os.Bundle;
 import android.text.Layout;
 import android.text.Selection;
 import android.text.Spannable;
+import android.text.SpannableString;
 import android.text.style.ClickableSpan;
 import android.text.style.URLSpan;
 import android.text.util.Linkify;
@@ -68,8 +69,8 @@ import static android.content.Context.CLIPBOARD_SERVICE;
 public class LinkifyUtil {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("LinkifyUtil");
 	public static final String DIALOG_TAG_CONFIRM_LINK = "cnfl";
-	private final Pattern compose, add, license;
-	private GestureDetectorCompat gestureDetector;
+	private final Pattern compose, add, license, geo;
+	private final GestureDetectorCompat gestureDetector;
 	private boolean isLongClick;
 	private Uri uri;
 
@@ -87,6 +88,7 @@ public class LinkifyUtil {
 		this.add = Pattern.compile("\\b" + BuildConfig.uriScheme + "://add\\?id=\\S{8}\\b");
 		this.compose = Pattern.compile("\\b" + BuildConfig.uriScheme + "://compose\\?\\S+\\b");
 		this.license = Pattern.compile("\\b" + BuildConfig.uriScheme + "://license\\?key=\\S{11}\\b");
+		this.geo = GeoLocationUtil.getGeoUriPattern();
 		this.gestureDetector = new GestureDetectorCompat(null, new GestureDetector.OnGestureListener() {
 			@Override
 			public boolean onDown(MotionEvent e) {
@@ -144,6 +146,21 @@ public class LinkifyUtil {
 		} else {
 			LinkifyCompatUtil.addLinks(bodyTextView, Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES);
 		}
+
+		// Add geo uris based on regex. Remove spans later when they contain invalid coordinates
+		LinkifyCompat.addLinks(bodyTextView, this.geo, null);
+
+		// Check that geo uris contain valid values
+		CharSequence text = bodyTextView.getText();
+		SpannableString spanString = SpannableString.valueOf(text);
+		URLSpan[] allSpans = spanString.getSpans(0, text != null ? text.length() : 0, URLSpan.class);
+		for (URLSpan span : allSpans) {
+			String url = span.getURL();
+			if (url != null && url.startsWith("geo:") && !GeoLocationUtil.isValidGeoUri(url)) {
+				spanString.removeSpan(span);
+			}
+		}
+
 		LinkifyCompat.addLinks(bodyTextView, this.add, null);
 		LinkifyCompat.addLinks(bodyTextView, this.compose, null);
 		LinkifyCompat.addLinks(bodyTextView, this.license, null);
@@ -319,6 +336,12 @@ public class LinkifyUtil {
 	}
 
 	public void openLink(@NonNull Context context, Uri uri) {
+		// Open geo uris with internal map activity
+		if (uri.toString().startsWith("geo:")) {
+			GeoLocationUtil.viewLocation(context, uri);
+			return;
+		}
+
 		Bundle bundle = new Bundle();
 		bundle.putBoolean("new_window", true);
 		Intent intent = new Intent(Intent.ACTION_VIEW, uri);

+ 15 - 0
app/src/main/java/ch/threema/app/utils/VideoUtil.java

@@ -28,8 +28,12 @@ import android.media.MediaMetadataRetriever;
 import android.net.Uri;
 import android.provider.MediaStore;
 
+import com.google.android.exoplayer2.DefaultRenderersFactory;
+import com.google.android.exoplayer2.ExoPlayer;
+
 import org.slf4j.Logger;
 
+import androidx.annotation.NonNull;
 import ch.threema.base.utils.LoggingUtil;
 
 public class VideoUtil {
@@ -79,4 +83,15 @@ public class VideoUtil {
 		}
 		return duration;
 	}
+
+	public static ExoPlayer getExoPlayer(@NonNull Context context) {
+		if (ConfigUtils.hasAsyncMediaCodecBug()) {
+			// Workaround for https://github.com/google/ExoPlayer/issues/10021
+			DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(context);
+			renderersFactory.forceDisableMediaCodecAsynchronousQueueing();
+			return new ExoPlayer.Builder(context, renderersFactory).build();
+		} else {
+			return new ExoPlayer.Builder(context).build();
+		}
+	}
 }

+ 1 - 1
app/src/main/java/ch/threema/app/webclient/converter/Utils.java

@@ -249,7 +249,7 @@ public class Utils extends Converter {
 		private DistributionListModel getDistributionListModel(String stringId) throws ConversionException {
 			try {
 				// Convert id
-				int id = Integer.parseInt(stringId);
+				long id = Long.parseLong(stringId);
 
 				// Get service and convert model to receiver
 				DistributionListService distributionListService = getDistributionListService();

+ 0 - 14
app/src/main/res/drawable/ic_new_badge_filled.xml

@@ -1,14 +0,0 @@
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:viewportWidth="24"
-    android:viewportHeight="24">
-  <path
-      android:pathData="M23,12 L20.56,9.22 20.9,5.54 17.29,4.72 15.4,1.54 12,3 8.6,1.54 6.71,4.72 3.1,5.53 3.44,9.21 1,12 3.44,14.78 3.1,18.47 6.71,19.29 8.6,22.47 12,21l3.4,1.46 1.89,-3.18 3.61,-0.82 -0.34,-3.68z"
-      android:strokeWidth="1.5"
-      android:strokeColor="?attr/colorAccent"
-      android:fillColor="@android:color/transparent"/>
-  <path
-      android:pathData="M8.2097,12.3261 L6.1662,9.4652L5.1445,9.4652L5.1445,14.3697L6.1662,14.3697L6.1662,11.5087l2.0844,2.8609l0.9809,0L9.2315,9.4652l-1.0218,0zM10.0489,14.3697l3.2696,0L13.3185,13.3479L11.275,13.3479L11.275,12.4406l2.0435,0l0,-1.0299l-2.0435,0l0,-0.9155l2.0435,0L13.3185,9.4652l-3.2696,0zM18.0186,9.4652l0,3.6783l-0.9155,0l0,-2.8691l-1.0218,0l0,2.8773L15.1577,13.1517L15.1577,9.4652l-1.0218,0l0,4.087c0,0.4496 0.3678,0.8174 0.8174,0.8174l3.2696,0c0.4496,0 0.8174,-0.3678 0.8174,-0.8174L19.0404,9.4652Z"
-      android:fillColor="?attr/colorAccent"/>
-</vector>

+ 7 - 0
app/src/main/res/drawable/ic_outline_report_24.xml

@@ -0,0 +1,7 @@
+<vector android:height="24dp" android:tint="#000000"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M15.73,3H8.27L3,8.27v7.46L8.27,21h7.46L21,15.73V8.27L15.73,3zM19,14.9L14.9,19H9.1L5,14.9V9.1L9.1,5h5.8L19,9.1v5.8z"/>
+    <path android:fillColor="@android:color/white" android:pathData="M12,16m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0"/>
+    <path android:fillColor="@android:color/white" android:pathData="M11,7h2v7h-2z"/>
+</vector>

+ 7 - 0
app/src/main/res/drawable/shape_recently_added_contacts_overlay.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+	<gradient
+		android:startColor="#00000000"
+		android:endColor="?attr/recently_added_background"
+		android:type="linear" />
+</shape>

+ 6 - 5
app/src/main/res/layout/item_contact_list.xml

@@ -30,7 +30,6 @@
 		android:id="@+id/initial_image"
 		android:layout_width="24dp"
 		android:layout_height="24dp"
-		app:srcCompat="@drawable/ic_new_badge_filled"
 		android:importantForAccessibility="no"
 		app:layout_constraintBottom_toBottomOf="parent"
 		app:layout_constraintLeft_toLeftOf="parent"
@@ -67,7 +66,7 @@
 
 	<ch.threema.app.emojis.EmojiTextView
 		android:id="@+id/name"
-		android:layout_width="wrap_content"
+		android:layout_width="0dp"
 		android:layout_height="wrap_content"
 		android:layout_marginLeft="90dp"
 		android:ellipsize="end"
@@ -80,7 +79,6 @@
 		app:layout_constraintBottom_toTopOf="@+id/subject"
 		app:layout_constraintVertical_chainStyle="packed"
 		app:layout_constraintHorizontal_chainStyle="packed"
-		app:layout_constraintHorizontal_bias="0"
 		app:layout_constrainedWidth="false"/>
 	<!-- textColor="@null" -> https://stackoverflow.com/a/45198884 -->
 
@@ -96,7 +94,10 @@
 		app:layout_constraintLeft_toRightOf="@id/name"
 		app:layout_constraintRight_toLeftOf="@+id/blocked_contact"
 		app:layout_constraintBaseline_toBaselineOf="@id/name"
-		app:layout_constrainedWidth="true" />
+		app:layout_constrainedWidth="true"
+		app:layout_constraintWidth_max="wrap"
+		app:layout_constraintWidth_percent="0.4"
+		/>
 	<!-- textColor="@null" -> https://stackoverflow.com/a/45198884 -->
 
 	<ImageView
@@ -136,7 +137,7 @@
 		android:layout_marginLeft="3dp"
 		android:ellipsize="end"
 		android:singleLine="true"
-		android:textAppearance="@style/Threema.TextAppearance.List.ThirdLine"
+		android:textAppearance="@style/Threema.TextAppearance.List.ThirdLine.Bold"
 		android:textColor="@null"
 		android:gravity="right"
 		app:layout_constraintBottom_toBottomOf="parent"

+ 114 - 135
app/src/main/res/layout/item_contact_list_recently_added.xml

@@ -1,150 +1,129 @@
 <?xml version="1.0" encoding="utf-8"?>
 
-<androidx.constraintlayout.widget.ConstraintLayout
-				xmlns:android="http://schemas.android.com/apk/res/android"
-                xmlns:app="http://schemas.android.com/apk/res-auto"
-                android:id="@+id/topLayout"
-                android:layout_width="match_parent"
-				android:layout_height="@dimen/listitem_contact_height"
-				android:paddingRight="@dimen/listitem_contacts_margin_left_right"
-				android:orientation="vertical">
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/topLayout"
+    android:layout_width="match_parent"
+    android:layout_height="88dp"
+    android:clickable="false"
+    android:focusable="false"
+    android:paddingRight="@dimen/listitem_contacts_margin_left_right">
 
-	<com.google.android.material.card.MaterialCardView
-		android:id="@+id/recently_added_background"
-		android:layout_width="match_parent"
-		android:layout_height="match_parent"
-		android:layout_marginLeft="8dp"
-		app:cardBackgroundColor="@color/recently_added_background"
-		app:cardCornerRadius="@dimen/recently_added_background_corner_size"
-		app:cardElevation="0dp"
-		app:cardPreventCornerOverlap="false"
-		app:cardUseCompatPadding="false"
-		app:layout_constraintBottom_toBottomOf="parent"
-		app:layout_constraintLeft_toLeftOf="parent"
-		app:layout_constraintTop_toTopOf="parent"
-		app:layout_constraintRight_toRightOf="parent"/>
+    <com.google.android.material.card.MaterialCardView
+        android:id="@+id/recently_added_background"
+        android:layout_width="match_parent"
+        android:layout_height="80dp"
+        android:layout_marginLeft="8dp"
+        android:clickable="true"
+        android:focusable="true"
+        app:cardBackgroundColor="?attr/recently_added_background"
+        app:cardCornerRadius="@dimen/recently_added_background_corner_size"
+        app:cardElevation="0dp"
+        app:cardPreventCornerOverlap="false"
+        app:cardUseCompatPadding="false"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintRight_toRightOf="parent">
 
-	<ch.threema.app.emojis.EmojiTextView
-		android:id="@+id/initial"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginLeft="@dimen/listitem_contacts_margin_left_right"
-		android:text="A"
-		android:importantForAccessibility="no"
-		android:textColor="@null"
-		android:textAppearance="@style/Threema.Material.Header.Big.Text"
-		app:layout_constraintBottom_toBottomOf="parent"
-		app:layout_constraintLeft_toLeftOf="parent"
-		app:layout_constraintTop_toTopOf="parent" />
-		<!-- textColor="@null" -> https://stackoverflow.com/a/45198884 -->
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:id="@+id/card_layout"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent">
 
-	<ImageView
-		android:id="@+id/initial_image"
-		android:layout_width="24dp"
-		android:layout_height="24dp"
-		android:layout_marginLeft="@dimen/listitem_contacts_margin_left_right"
-		app:srcCompat="@drawable/ic_new_badge_filled"
-		android:importantForAccessibility="no"
-		app:layout_constraintBottom_toBottomOf="parent"
-		app:layout_constraintLeft_toLeftOf="parent"
-		app:layout_constraintTop_toTopOf="parent"
-		android:visibility="gone" />
+            <com.google.android.material.imageview.ShapeableImageView
+                android:id="@+id/shapeable_avatar_view"
+                android:layout_width="88dp"
+                android:layout_height="80dp"
+                android:scaleType="centerCrop"
+                app:shapeAppearance="@style/Threema.ShapeAppearance.RecentlyAdded"
+                app:layout_constraintLeft_toLeftOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintBottom_toBottomOf="parent" />
 
-	<!-- avatar -->
+            <ImageView
+                android:id="@+id/gradient"
+                android:layout_width="88dp"
+                android:layout_height="80dp"
+                android:src="@drawable/shape_recently_added_contacts_overlay"
+                app:layout_constraintLeft_toLeftOf="@id/shapeable_avatar_view"
+                app:layout_constraintTop_toTopOf="@id/shapeable_avatar_view"
+                app:layout_constraintBottom_toBottomOf="@id/shapeable_avatar_view"
+                app:layout_constraintRight_toRightOf="@id/shapeable_avatar_view" />
 
-	<ch.threema.app.ui.AvatarView
-		android:id="@+id/avatar_view"
-		android:layout_width="@dimen/avatar_size_small"
-		android:layout_height="@dimen/avatar_size_small"
-		android:layout_marginLeft="50dp"
-		android:duplicateParentState="true"
-		android:stateListAnimator="@animator/selector_list_checkbox_bg"
-		android:visibility="visible"
-		app:layout_constraintBottom_toBottomOf="parent"
-		app:layout_constraintLeft_toLeftOf="@id/recently_added_background"
-		app:layout_constraintTop_toTopOf="parent" />
+            <!-- first line -->
 
-	<ch.threema.app.ui.CheckableView
-		android:id="@+id/check_box"
-		android:layout_width="@dimen/avatar_size_small"
-		android:layout_height="@dimen/avatar_size_small"
-		android:background="@drawable/selector_list_checkbox"
-		android:duplicateParentState="true"
-		android:stateListAnimator="@animator/selector_list_checkbox_fg"
-		app:layout_constraintBottom_toBottomOf="@id/avatar_view"
-		app:layout_constraintLeft_toLeftOf="@id/avatar_view"
-		app:layout_constraintRight_toRightOf="@id/avatar_view"
-		app:layout_constraintTop_toTopOf="@id/avatar_view" />
+            <TextView
+                android:id="@+id/recently_added_title"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="10dp"
+                android:layout_marginBottom="8dp"
+                android:ellipsize="end"
+                android:maxLines="1"
+                android:text="@string/last_added_contact"
+                android:textAppearance="@style/Threema.Text.Overline"
+                android:textColor="?attr/colorAccent"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintLeft_toRightOf="@id/shapeable_avatar_view"
+                app:layout_constraintRight_toRightOf="@+id/subject"
+                app:layout_constraintBottom_toTopOf="@+id/name"
+                app:layout_constraintVertical_chainStyle="packed"
+                app:layout_constraintHorizontal_chainStyle="packed" />
 
-	<!-- first line -->
+            <ch.threema.app.emojis.EmojiTextView
+                android:id="@+id/name"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="10dp"
+                android:ellipsize="end"
+                android:singleLine="true"
+                android:textAppearance="@style/Threema.TextAppearance.List.FirstLine"
+                android:textColor="@null"
+                app:layout_constraintTop_toBottomOf="@id/recently_added_title"
+                app:layout_constraintLeft_toRightOf="@id/shapeable_avatar_view"
+                app:layout_constraintRight_toLeftOf="@+id/blocked_contact"
+                app:layout_constraintBottom_toTopOf="@+id/subject"
+                app:layout_constraintHorizontal_chainStyle="packed"
+                app:layout_constraintHorizontal_bias="0"
+                app:layout_constrainedWidth="false" />
+            <!-- textColor="@null" -> https://stackoverflow.com/a/45198884 -->
 
-	<ch.threema.app.emojis.EmojiTextView
-		android:id="@+id/name"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginLeft="16dp"
-		android:ellipsize="end"
-		android:singleLine="true"
-		android:textColor="@null"
-		android:textAppearance="@style/Threema.TextAppearance.List.FirstLine"
-		app:layout_constraintTop_toTopOf="parent"
-		app:layout_constraintLeft_toRightOf="@id/avatar_view"
-		app:layout_constraintRight_toLeftOf="@+id/nick"
-		app:layout_constraintBottom_toTopOf="@+id/subject"
-		app:layout_constraintVertical_chainStyle="packed"
-		app:layout_constraintHorizontal_chainStyle="packed"
-		app:layout_constraintHorizontal_bias="0"
-		app:layout_constrainedWidth="false"/>
-	<!-- textColor="@null" -> https://stackoverflow.com/a/45198884 -->
+            <ImageView
+                android:id="@+id/blocked_contact"
+                android:layout_width="22dp"
+                android:layout_height="18dp"
+                android:layout_marginRight="16dp"
+                android:layout_marginBottom="2dp"
+                android:baselineAlignBottom="true"
+                android:contentDescription="@string/blocked"
+                android:paddingLeft="4dp"
+                android:visibility="visible"
+                app:srcCompat="@drawable/ic_block"
+                app:layout_constraintBottom_toBottomOf="@id/name"
+                app:layout_constraintRight_toRightOf="parent"
+                app:tint="@color/material_red" />
 
-	<ch.threema.app.emojis.EmojiTextView
-		android:id="@+id/nick"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginLeft="3dp"
-		android:ellipsize="end"
-		android:singleLine="true"
-		android:textColor="@null"
-		android:textAppearance="@style/Threema.TextAppearance.List.ThirdLine"
-		app:layout_constraintLeft_toRightOf="@id/name"
-		app:layout_constraintRight_toRightOf="parent"
-		app:layout_constraintBaseline_toBaselineOf="@id/name"
-		app:layout_constrainedWidth="true" />
-	<!-- textColor="@null" -> https://stackoverflow.com/a/45198884 -->
+            <!-- second line -->
 
-	<!-- second line -->
+            <TextView
+                android:id="@+id/subject"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="10dp"
+                android:layout_marginRight="16dp"
+                android:ellipsize="end"
+                android:singleLine="true"
+                android:textAppearance="@style/Threema.TextAppearance.List.ThirdLine"
+                android:textColor="@null"
+                app:layout_constraintTop_toBottomOf="@id/name"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintLeft_toRightOf="@id/shapeable_avatar_view"
+                app:layout_constraintRight_toRightOf="parent"
+                app:layout_constraintVertical_chainStyle="packed"
+                app:layout_constrainedWidth="true" />
+            <!-- textColor="@null" -> https://stackoverflow.com/a/45198884 -->
+        </androidx.constraintlayout.widget.ConstraintLayout>
 
-	<ch.threema.app.ui.VerificationLevelImageView
-		android:id="@+id/verification_level"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginLeft="16dp"
-		android:layout_marginBottom="2dp"
-		android:baselineAlignBottom="true"
-		android:visibility="visible"
-		app:layout_constraintBottom_toBottomOf="@+id/subject"
-		app:layout_constraintLeft_toRightOf="@id/avatar_view"
-		app:layout_constraintRight_toLeftOf="@+id/subject"
-		app:layout_constraintHorizontal_chainStyle="spread_inside"
-		app:srcCompat="@drawable/ic_verification_none" />
-
-	<TextView
-		android:id="@+id/subject"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginLeft="3dp"
-		android:layout_marginRight="16dp"
-		android:ellipsize="end"
-		android:singleLine="true"
-		android:textAppearance="@style/Threema.TextAppearance.List.ThirdLine"
-		android:textColor="@null"
-		android:gravity="right"
-		app:layout_constraintBottom_toBottomOf="parent"
-		app:layout_constraintRight_toRightOf="parent"
-		app:layout_constraintLeft_toRightOf="@id/verification_level"
-		app:layout_constraintTop_toBottomOf="@id/name"
-		app:layout_constraintVertical_chainStyle="packed"
-		app:layout_constrainedWidth="true"/>
-	<!-- textColor="@null" -> https://stackoverflow.com/a/45198884 -->
+    </com.google.android.material.card.MaterialCardView>
 
 </androidx.constraintlayout.widget.ConstraintLayout>

+ 2 - 0
app/src/main/res/values-de/strings.xml

@@ -1489,6 +1489,8 @@ sicheren Ort gesichert oder ausgedruckt haben.</string>
 	<string name="spam_report_short">Melden</string>
 	<string name="wizard_incompatible_contact_sync_params">Es sind inkompatible MDM Parameter gesetzt. Der Threema MDM Parameter th_contact_sync kann nicht auf true gesetzt werden, wenn die Restriction DISALLOW_MODIFY_ACCOUNTS aktiviert ist. Bitte kontaktieren Sie Ihren Systemadministator.</string>
 	<string name="messages_cannot_be_recovered">Die Nachrichten können nicht wiederhergestellt werden.</string>
+	<string name="contact_deleted">Kontakt wurde gelöscht</string>
+	<string name="last_added_contact">Zuletzt hinzugefügt</string>
 	<plurals name="contacts_counter_label">
 		<item quantity="one">%d Kontakt</item>
 		<item quantity="other">%d Kontakte</item>

+ 1 - 0
app/src/main/res/values/attrs.xml

@@ -116,6 +116,7 @@
 	<attr name="attach_button_background" format="reference|color"/>
 	<attr name="material_toolbar_color" format="reference|color" />
 	<attr name="circle_button_stroke_color" format="reference|color"/>
+	<attr name="recently_added_background" format="reference|color"/>
 
 	<!-- Confirm device credentials screen -->
 	<attr name="confirmDeviceCredentialsSideMargin" format="dimension"/>

+ 2 - 1
app/src/main/res/values/colors.xml

@@ -233,6 +233,7 @@
 	<color name="material_indigo_a200">#536dfe</color>
 	<color name="material_teal_secondary">#00ebd5</color>
 	<color name="material_grey_300_alpha">#44e0e0e0</color>
-	<color name="recently_added_background">#2241d68d</color>
+	<color name="recently_added_background_light">#fff3f3f3</color>
+	<color name="recently_added_background_dark">#ff1b392a</color>
 	<color name="qrscanner_masking_color">#50000000</color>
 </resources>

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -1432,6 +1432,8 @@
 	<string name="spam_report_short">Report</string>
 	<string name="wizard_incompatible_contact_sync_params">There are incompatible MDM parameters set. Threema MDM parameter th_contact_sync cannot be set to true if user restriction DISALLOW_MODIFY_ACCOUNTS is set. Please contact your device administrator.</string>
 	<string name="messages_cannot_be_recovered">You will not be able to recover the messages.</string>
+	<string name="contact_deleted">Contact deleted</string>
+	<string name="last_added_contact">Last added</string>
 	<plurals name="contacts_counter_label">
 		<item quantity="one">%d contact</item>
 		<item quantity="few">%d contacts</item>

+ 14 - 1
app/src/main/res/values/styles.xml

@@ -212,6 +212,14 @@
 		<item name="cornerSize">@dimen/media_attach_button_radius</item>
 	</style>
 
+	<style name="Threema.ShapeAppearance.RecentlyAdded" parent="">
+		<item name="cornerFamily">rounded</item>
+		<item name="cornerSizeBottomLeft">@dimen/recently_added_background_corner_size</item>
+		<item name="cornerSizeTopLeft">@dimen/recently_added_background_corner_size</item>
+		<item name="cornerSizeBottomRight">0dp</item>
+		<item name="cornerSizeTopRight">0dp</item>
+	</style>
+
 	<style name="Threema.ToolbarStyle" parent="Widget.MaterialComponents.Toolbar">
 		<item name="android:background">?attr/material_toolbar_color</item>
 		<item name="titleTextAppearance">@style/TextAppearance.Toolbar.Title</item>
@@ -290,11 +298,16 @@
 		<item name="android:textColor">?attr/text_color_third_line</item>
 	</style>
 
-	<style name="Threema.TextAppearance.List.ThirdLine.Bold" parent="Threema.TextAppearance.List.ThirdLine">
+	<style name="Threema.TextAppearance.List.ThirdLine.Red" parent="Threema.TextAppearance.List.ThirdLine">
 		<item name="android:textColor">@color/material_red</item>
 		<item name="android:textAllCaps">true</item>
 	</style>
 
+	<style name="Threema.TextAppearance.List.ThirdLine.Bold" parent="Threema.TextAppearance.List.ThirdLine">
+		<item name="android:textColor">?attr/textColorPrimary</item>
+		<item name="android:textAllCaps">true</item>
+	</style>
+
 	<style name="Threema.TextAppearance.Emptyview" parent="@android:style/TextAppearance.Large">
 		<item name="android:fontFamily">sans-serif</item>
 		<item name="android:textSize">18sp</item>

+ 5 - 1
app/src/main/res/values/themes.xml

@@ -15,7 +15,7 @@
 		<!-- colors -->
 		<item name="colorPrimary">@color/accent_light</item> <!--   your app branding color for the app bar -->
 		<item name="colorPrimaryDark">@color/material_primary_dark</item> <!--   darker variant for the status bar and contextual app bars -->
-		<item name="colorAccent">@color/accent_light</item> <!--   theme UI controls like checkboxs and text fields -->
+		<item name="colorAccent">@color/accent_light</item> <!--   theme UI controls like checkboxes and text fields -->
 		<item name="android:colorBackground">@color/activity_background_secondary</item> <!-- background of spinner popup -->
 		<item name="android:windowBackground">@color/activity_background_secondary</item> <!-- action mode appears on top of tool bar -->
 		<item name="android:textColorPrimary">@color/text_color_primary</item>
@@ -57,6 +57,7 @@
 		<item name="circle_button_stroke_color">@color/circle_button_stroke</item>
 		<item name="multipane_divider_start_color">#00000000</item>
 		<item name="multipane_divider_end_color">#44000000</item>
+		<item name="recently_added_background">@color/recently_added_background_light</item>
 
 		<!-- text styles -->
 		<item name="android:textAppearanceLarge">@style/TextAppearanceLarge</item>
@@ -183,6 +184,7 @@
 	<style name="Theme.Threema.TransparentStatusbar" parent="Theme.Threema.WithToolbar">
 		<item name="toolbarStyle">@style/Widget.MaterialComponents.Toolbar</item>
 		<item name="popup_menu_background">@color/activity_background_secondary</item>
+		<item name="background_secondary">@color/activity_background_secondary</item>
 	</style>
 
 	<style name="Theme.Threema.WithToolbarAndCheck" parent="Theme.Threema.WithToolbar">
@@ -332,6 +334,7 @@
 		<item name="circle_button_stroke_color">@color/dark_circle_button_stroke</item>
 		<item name="multipane_divider_start_color">#00FFFFFF</item>
 		<item name="multipane_divider_end_color">#44FFFFFF</item>
+		<item name="recently_added_background">@color/recently_added_background_dark</item>
 
 		<!-- text styles -->
 		<item name="android:textAppearanceLarge">@style/TextAppearanceLarge</item>
@@ -500,6 +503,7 @@
 	<style name="Theme.Threema.TransparentStatusbar.Dark" parent="Theme.Threema.WithToolbar.Dark">
 		<item name="toolbarStyle">@style/Widget.MaterialComponents.Toolbar</item>
 		<item name="popup_menu_background">@color/dark_material_level3</item>
+		<item name="background_secondary">@color/dark_material_level0</item>
 	</style>
 
 	<style name="Theme.Threema.Translucent.Dark" parent="Theme.Threema.WithToolbar.Dark">

+ 4 - 0
app/src/onprem/res/values/colors.xml

@@ -32,4 +32,8 @@
 	<color name="dark_bubble_selected_overlay">#44DC7371</color>
 	<color name="dark_unread_messages_text_color">#FF000000</color>
 	<color name="dark_unread_messages_bg_color">#FFFBCCD0</color>
+
+	<!-- recently added contact background -->
+	<color name="recently_added_background_light">#fff3f3f3</color>
+	<color name="recently_added_background_dark">#ff551211</color>
 </resources>

+ 4 - 0
app/src/red/res/values/colors.xml

@@ -32,4 +32,8 @@
 	<color name="dark_bubble_selected_overlay">#44DC7371</color>
 	<color name="dark_unread_messages_text_color">#FF000000</color>
 	<color name="dark_unread_messages_bg_color">#FFFBCCD0</color>
+
+	<!-- recently added contact background -->
+	<color name="recently_added_background_light">#fff3f3f3</color>
+	<color name="recently_added_background_dark">#ff551211</color>
 </resources>

+ 4 - 0
app/src/store_google_work/res/values/colors.xml

@@ -32,4 +32,8 @@
 	<color name="dark_bubble_selected_overlay">#4463b2eb</color>
 	<color name="dark_unread_messages_text_color">#FF000000</color>
 	<color name="dark_unread_messages_bg_color">#FFBADEFF</color>
+
+	<!-- recently added contact background -->
+	<color name="recently_added_background_light">#fff3f3f3</color>
+	<color name="recently_added_background_dark">#ff042c54</color>
 </resources>

+ 1 - 0
domain/src/main/proto/.gitignore

@@ -1,3 +1,4 @@
 build/
 node_modules/
+package.json
 package-lock.json

+ 2 - 0
domain/src/main/proto/call-signaling.proto

@@ -1,3 +1,5 @@
+// # Call Signaling
+
 syntax = "proto3";
 
 package callsignaling;

+ 17 - 5
domain/src/main/proto/common.proto

@@ -1,3 +1,5 @@
+// # Common Types
+
 syntax = "proto3";
 
 package common;
@@ -20,6 +22,9 @@ message Blob {
   // Secret (or public) key used for encrypting/decrypting the Blob.
   // Note: May be omitted if unambigously defined by the context.
   bytes key = 3;
+
+  // Unix-ish timestamp in milliseconds when the blob has been uploaded
+  uint64 uploaded_at = 4;
 }
 
 // Inline Blob data.
@@ -53,14 +58,15 @@ message GroupIdentity {
 }
 
 // Generic image allowing delta updates
+//
+// Note: Lack of presence generally means that the image should remain
+//       unchanged.
 message DeltaImage {
   oneof image {
-    // The image remains unchanged
-    Unit unchanged = 1;
     // The image is empty or was explicitly removed
-    Unit removed = 2;
+    Unit removed = 1;
     // The new updated image
-    Image updated = 3;
+    Image updated = 2;
   }
 }
 
@@ -72,4 +78,10 @@ message Timespan {
   uint64 from = 1;
   // End of the timespan (Unix-ish timestamp in miliseconds, inclusive)
   uint64 to = 2;
-}
+}
+
+// Container for a list of identities.
+message Identities {
+  // List of identities
+  repeated string identifies = 1;
+}

+ 186 - 0
domain/src/main/proto/csp-e2e-fs.proto

@@ -0,0 +1,186 @@
+// ## Forward Security Subprotocol
+
+// This protocol specifies forward security for end-to-end encrypted chat server
+// messages. It is based on sessions established between two parties, where each
+// session has a unique ID and is associated with ephemeral key material
+// negotiated between the parties using ECDH key agreement and hash chain based
+// key derivation.
+
+// Each party is either an initiator or a responder in a given session. For
+// bidirectional communication, two separate sessions are established. This is
+// reflected in the envelope message types: one for control and content messages
+// sent from the initiator of a session to the responder
+// (`FromSessionInitiatorEnvelope`), and another for control messages sent by
+// the responder back to the initiator (`FromSessionResponderEnvelope`) to
+// enable an ECDH based key exchange.
+
+// Content messages from an initiator to a responder can take any other type that
+// could normally be sent without Forward Security, and wrap the message
+// contained within using a separate cryptographic layer that provides Forward
+// Security.
+
+// ### Terminology
+
+// - `FS`: Forward Security
+// - `SI`: Session Initiator
+// - `SR`: Session Responder
+// - `2DH`: One-sided forward security with two DH calculations in the key
+//   derivation
+// - `4DH`: Full two-sided forward security with four DH calculations in the key
+//   derivation
+
+// # Modes
+
+// An ECDH key negotiation normally needs active calculations by both involved
+// parties before any actual messages can be exchanged. This is not practical
+// in a messaging app, as the other party may not be online at the time when the
+// first message(s) are sent.
+
+// Thus, the protocol specifies two modes, called 2DH and 4DH.
+
+// ## 2DH mode
+
+// 2DH mode can be used immediately, even in a new session, as it does not
+// involve any ECDH calculations from the peer (responder). However, it only
+// protects against a compromise of the initiator's permanent secret key, not of
+// the responder's permanent secret key. It is still better than sending all
+// messages without Forward Security until a full two-sided session has been
+// negotiated.
+
+// ## 4DH mode
+
+// A session enters 4DH mode once the responder has received and processed the
+// initiator's `Init` message, and the resulting `Accept` message has been
+// received by the initiator. At this point, ephemeral key material is available
+// from the responder and is used in the ECDH calculations. Messages sent from
+// this point on are secure even in the event of a future compromise of the
+// permanent secret key of either party.
+
+// # Ratchet counters
+
+// Each session is associated with a counter, which describes how many times the
+// KDF ratchet has been turned since the initial ECDH based key negotiation.
+// Whenever a new message has been sent in session, the counter must be
+// incremented and the ratchet must be turned. As a KDF ratchet operation cannot
+// be reversed, counter values cannot go back, and the original message order
+// must be preserved during transport over the network and during processing on
+// both sides.
+
+// To account for lost messages (e.g. when the recipient has been offline for an
+// extended period of time), the responder must be prepared to accept counters
+// that have skipped a few values, and turn the KDF ratchet as many times as is
+// needed to reach the new counter value. To limit the CPU impact on the
+// responder side, the permissible counter increment is limited to 10'000.
+
+// ### Usual Protocol Flow
+
+// A forward security session negotiation is typically started when a user sends
+// the first message to a peer. The user assumes the role of the session
+// initiator, creates a new session and sends an `Init` message, followed by any
+// number of encapsulated `Message`s in 2DH mode.
+
+//     SI -- 0xa0 Init -----------> SR   [1]
+//     SI -- 0xa0 Message (2DH) --> SR   [0..N]
+
+// At this point, SI established a session in 2DH mode for messages flowing from
+// SI to SR.
+
+// The session responder will then eventually process the `Init` and `Accept` the
+// session.
+
+//     SR -- 0xa1 Accept ---------> SI
+
+// At that point, the session has been upgraded to 4DH mode for future messages
+// sent from SI to SR.
+
+//     SI -- 0xa0 Message (4DH) --> SR   [0..N]
+
+// At any point SI may `Terminate` or replace a session by sending a new `Init`,
+// following the above flow.
+
+// In case of an error, the protocol flow will deviate. See the concrete message
+// descriptions.
+
+syntax = "proto3";
+
+package csp_e2e_fs;
+
+option java_package = "ch.threema.protobuf.csp.e2e.fs";
+option java_multiple_files = true;
+
+message FromSessionInitiatorEnvelope {
+  // Forward security session ID, 16 bytes
+  bytes session_id = 1;
+
+  // Establish a new FS session. The initiator picks a new random session ID.
+  message Init {
+    // Ephemeral public key of the initiator for this session
+    bytes ephemeral_public_key = 1;
+  }
+
+  // Signals that the initiator will not send any further `Message`s in this
+  // session. The responder should discard all key material related to this
+  // session.
+  message Terminate {}
+
+  // Encapsulates another CSP E2EE message, adding forward security.
+  message Message {
+    // Whether 2DH or 4DH was used in deriving the key for encrypting this
+    // message.
+    enum DHType {
+      TWODH = 0;
+      FOURDH = 1;
+    }
+    DHType dh_type = 1;
+
+    // A monotonically increasing counter, starting at 1 for the first 2DH or
+    // 4DH `Message` sent in this session, and incrementing by 1 for each
+    // successive `Message`.
+    //
+    // - Counters for 2DH and 4DH are separate, as they are based on different
+    //   root keys.
+    // - Can be used by the responder as a hint of how many times to
+    //   rotate/ratchet the KDF, in case an intermediate `Message` went missing.
+    uint64 counter = 2;
+
+    // A message defined in `e2e.container`, encrypted by the keys negotiated
+    // for FS in this session.
+    //
+    // An inner E2EE message of type `0xa0` or `0xa1` is disallowed and **must** be
+    // discarded.
+    bytes message = 3;
+  }
+
+  oneof content {
+    Init init = 2;
+    Terminate terminate = 3;
+    Message message = 4;
+  }
+}
+
+message FromSessionResponderEnvelope {
+  // Forward security session ID, 16 bytes
+  bytes session_id = 1;
+
+  // Accept a newly established session by the initiator. The session ID is
+  // equal to the one of the initiator.
+  message Accept {
+    // Ephemeral public key of the responder for this session
+    bytes ephemeral_public_key = 1;
+  }
+
+  // Sent when receiving a `FromSessionInitiatorEnvelope.Message` that cannot be
+  // decrypted (e.g. because the responder has lost the session information).
+  //
+  // The initiator should discard the FS session and start a new one.
+  message Reject {
+    // Message ID of the message that could not be decrypted and that should be
+    // sent again in a new session (8 bytes)
+    fixed64 rejected_message_id = 1;
+  }
+
+  oneof content {
+    Accept accept = 2;
+    Reject reject = 3;
+  }
+}

+ 9 - 8
domain/src/main/proto/csp-e2e.proto

@@ -1,3 +1,9 @@
+// ## End-to-End Encrypted Messages (Supplementary)
+//
+// This is a supplementary section to the corresponding structbuf section
+// with newer messages that use protobuf instead of structbuf. All defined
+// messages here follow the same logic.
+
 syntax = "proto3";
 
 package csp_e2e;
@@ -7,16 +13,11 @@ option java_multiple_files = true;
 
 import "common.proto";
 
-// ## End-to-End Encrypted Messages (Supplementary)
-//
-// This is a supplementary section to the corresponding structbuf section
-// with newer messages that use protobuf instead of structbuf. All defined
-// messages here follow the same logic.
-
 // Metadata sent within a CSP payload `message-with-meta` struct.
 message MessageMetadata {
-  // Random amount of padding ignored by the receiver. Recommended to be
-  // between 0 and 32 bytes.
+  // Padding that is ignored by the receiver.
+  // Recommended to be chosen such that the total length of padding + nickname
+  // is at least 16 bytes. May be empty if the nickname is long enough.
   bytes padding = 1;
 
   // The nickname associated to the sender's Threema ID. Recommended to not

+ 9 - 11
domain/src/main/proto/url-payloads.proto

@@ -1,3 +1,9 @@
+// # URL Payloads
+//
+// These payloads are part of universal URLs (e.g. group invite links). After
+// serializing the protobuf messages, they are encoded in URL safe Base64
+// (according to RFC 3548).
+
 syntax = "proto3";
 
 package url;
@@ -7,14 +13,6 @@ option java_multiple_files = true;
 
 import "common.proto";
 
-// # URL Payloads
-//
-// These payloads are part of universal URLs (e.g. group invite links). After
-// serializing the protobuf messages, they are encoded in URL safe Base64
-// (according to RFC 3548).
-
-
-
 // Group invitation containing information to request joining a group.
 //
 // Generated by the administrator of a group. The resulting URL can be shared
@@ -66,12 +64,12 @@ message DeviceFamilyJoinRequestOrOffer {
   message Variant {
     oneof type {
       // A device intends to join the (multi-)device family. `data` is to be
-      // handled according to the *Device Join Protocol* with `ND` being the
+      // handled according to the _Device Join Protocol_ with `ND` being the
       // initiator.
       common.Unit request_to_join = 1;
 
       // A device intends to let another device join the (multi-)device family.
-      // `data` is to be handled according to the *Device Join Protocol* with 
+      // `data` is to be handled according to the _Device Join Protocol_ with 
       // `ED` being the initiator.
       common.Unit offer_to_join = 2;
     }
@@ -86,4 +84,4 @@ message DeviceFamilyJoinRequestOrOffer {
   //     Box(PSK.secret)
   //       .encrypt(data=<rendezvous.RendezvousInit>, nonce=<random>)
   bytes encrypted_rendezvous_data = 2;
-}
+}

+ 1 - 0
domain/src/main/proto/version.txt

@@ -0,0 +1 @@
+4