Threema hace 5 años
padre
commit
23f00dbb2e
Se han modificado 34 ficheros con 654 adiciones y 341 borrados
  1. 5 5
      app/build.gradle
  2. 10 17
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  3. 1 1
      app/src/main/java/ch/threema/app/activities/DirectoryActivity.java
  4. 1 1
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  5. 1 0
      app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java
  6. 1 1
      app/src/main/java/ch/threema/app/fragments/WorkUserListFragment.java
  7. 1 1
      app/src/main/java/ch/threema/app/services/FileService.java
  8. 4 2
      app/src/main/java/ch/threema/app/services/FileServiceImpl.java
  9. 11 5
      app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayer.java
  10. 13 15
      app/src/main/java/ch/threema/app/stores/PreferenceStore.java
  11. 4 0
      app/src/main/java/ch/threema/app/utils/AppRestrictionUtil.java
  12. 12 1
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  13. 49 2
      app/src/main/java/ch/threema/app/voip/Config.java
  14. 24 23
      app/src/main/java/ch/threema/app/voip/PeerConnectionClient.java
  15. 5 5
      app/src/main/java/ch/threema/app/voip/services/CallRejectService.java
  16. 162 98
      app/src/main/java/ch/threema/app/voip/services/VoipCallService.java
  17. 127 66
      app/src/main/java/ch/threema/app/voip/services/VoipStateService.java
  18. 16 0
      app/src/main/java/ch/threema/app/voip/util/VoipUtil.java
  19. 1 1
      app/src/main/java/ch/threema/app/webclient/services/instance/SessionInstanceServiceImpl.java
  20. 6 4
      app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/ThumbnailRequestHandler.java
  21. 1 2
      app/src/main/java/ch/threema/app/webclient/utils/ThumbnailUtils.java
  22. 6 0
      app/src/main/java/ch/threema/client/AppVersion.java
  23. 28 1
      app/src/main/java/ch/threema/client/voip/VoipCallAnswerData.java
  24. 2 2
      app/src/main/res/values-cs/poi_strings.xml
  25. 2 2
      app/src/main/res/values-cs/qrscanner_strings.xml
  26. 45 47
      app/src/main/res/values-cs/strings.xml
  27. 5 5
      app/src/main/res/values-cs/voicemessage_strings.xml
  28. 32 32
      app/src/main/res/values-cs/voip_strings.xml
  29. 3 0
      app/src/main/res/values/restrictions_strings.xml
  30. 7 0
      app/src/store_google_work/res/xml/app_restrictions.xml
  31. 54 0
      app/src/test/java/ch/threema/app/voip/ConfigTest.java
  32. 4 0
      scripts/Dockerfile
  33. 4 1
      scripts/build-release.sh
  34. 7 1
      scripts/verify-build.sh

+ 5 - 5
app/build.gradle

@@ -75,8 +75,8 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 669
-        versionName "4.51"
+        versionCode 671
+        versionName "4.52"
         resValue "string", "version_name_suffix", ""
         resValue "string", "app_name", "Threema"
         resValue "string", "uri_scheme", "threema"
@@ -141,7 +141,7 @@ android {
         }
         store_threema { }
         store_google_work {
-            versionName "4.51k"
+            versionName "4.52k"
             applicationId "ch.threema.app.work"
             testApplicationId 'ch.threema.app.work.test'
             resValue "string", "package_name", applicationId
@@ -178,7 +178,7 @@ android {
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
         }
         sandbox_work {
-            versionName "4.51k"
+            versionName "4.52k"
             applicationId "ch.threema.app.sandbox.work"
             testApplicationId 'ch.threema.app.sandbox.work.test'
 
@@ -208,7 +208,7 @@ android {
             ]
         }
         red { // Essentially like sandbox work, but with a different icon and accent color, used for internal testing
-            versionName "4.51r"
+            versionName "4.52r"
             applicationId "ch.threema.app.red"
             testApplicationId 'ch.threema.app.red.test'
 

+ 10 - 17
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -702,34 +702,27 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	private static void resetPreferences() {
 		SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getAppContext());
 
-		/* fix master key preference state if necessary (could be wrong if user kills app
-		   while disabling master key passphrase
-		 */
+		// Fix master key preference state if necessary (could be wrong if user kills app
+		// while disabling master key passphrase).
 		if (masterKey.isProtected() && prefs != null && !prefs.getBoolean(getAppContext().getString(R.string.preferences__masterkey_switch), false)) {
 			logger.debug("Master key is protected, but switch preference is disabled - fixing");
 			prefs.edit().putBoolean(getAppContext().getString(R.string.preferences__masterkey_switch), true).commit();
 		}
 
-		// If device is in AEC blacklist and the user did not choose a preference yet,
+		// If device is in AEC exclusion list and the user did not choose a preference yet,
 		// update the shared preference.
 		if (prefs != null && prefs.getString(getAppContext().getString(R.string.preferences__voip_echocancel), "none").equals("none")) {
-
-			// Determine whether device is blacklisted from hardware AEC
+			// Determine whether device is excluded from hardware AEC
 			final String modelInfo = Build.MANUFACTURER + ";" + Build.MODEL;
-			boolean blacklisted = false;
-			for (String entry : Config.HW_AEC_BLACKLIST) {
-				if (modelInfo.equals(entry)) {
-					blacklisted = true;
-				}
-			}
+			boolean exclude = !Config.allowHardwareAec();
 
 			// Set default preference
 			final SharedPreferences.Editor editor = prefs.edit();
-			if (blacklisted) {
-				logger.debug("Device {} is on AEC blacklist, switching to software echo cancellation", modelInfo);
+			if (exclude) {
+				logger.debug("Device {} is on AEC exclusion list, switching to software echo cancellation", modelInfo);
 				editor.putString(getAppContext().getString(R.string.preferences__voip_echocancel), "sw");
 			} else {
-				logger.debug("Device {} is not on AEC blacklist", modelInfo);
+				logger.debug("Device {} is not on AEC exclusion list", modelInfo);
 				editor.putString(getAppContext().getString(R.string.preferences__voip_echocancel), "hw");
 			}
 			editor.commit();
@@ -825,10 +818,10 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			}
 
 			logger.info(
-				"*** App launched. Device: {} Version: {} Build: {}",
+				"*** App launched. Device/Android Version/Flavor: {} Version: {} Build: {}",
 				ConfigUtils.getDeviceInfo(getAppContext(), false),
 				BuildConfig.VERSION_NAME,
-				BuildConfig.VERSION_CODE
+				ConfigUtils.getBuildNumber(getAppContext())
 			);
 
 			// Set up logging

+ 1 - 1
app/src/main/java/ch/threema/app/activities/DirectoryActivity.java

@@ -155,7 +155,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 			return false;
 		}
 
-		if (!preferenceService.getWorkDirectoryEnabled()) {
+		if (!ConfigUtils.isWorkDirectoryEnabled()) {
 			Toast.makeText(this, getString(R.string.disabled_by_policy_short), Toast.LENGTH_LONG).show();
 			return false;
 		}

+ 1 - 1
app/src/main/java/ch/threema/app/activities/HomeActivity.java

@@ -1318,7 +1318,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			if (ConfigUtils.isWorkBuild()) {
 				MenuItem menuItem = menu.findItem(R.id.directory);
 				if (menuItem != null) {
-					menuItem.setVisible(preferenceService.getWorkDirectoryEnabled());
+					menuItem.setVisible(ConfigUtils.isWorkDirectoryEnabled());
 				}
 				menuItem = menu.findItem(R.id.threema_channel);
 				if (menuItem != null) {

+ 1 - 0
app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java

@@ -472,6 +472,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			new DeleteIdentityAsyncTask(getSupportFragmentManager(), new Runnable() {
 				@Override
 				public void run() {
+					finishAffinity();
 					System.exit(0);
 				}
 			}).execute();

+ 1 - 1
app/src/main/java/ch/threema/app/fragments/WorkUserListFragment.java

@@ -81,7 +81,7 @@ public class WorkUserListFragment extends RecipientListFragment {
 	public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
 		View view = super.onCreateView(inflater, container, savedInstanceState);
 
-		if (ConfigUtils.isWorkRestricted() && !multiSelect && view != null && preferenceService.getWorkDirectoryEnabled()) {
+		if (ConfigUtils.isWorkRestricted() && !multiSelect && view != null && ConfigUtils.isWorkDirectoryEnabled()) {
 			ListView listView = view.findViewById(android.R.id.list);
 
 			RelativeLayout header = (RelativeLayout) getLayoutInflater().inflate(R.layout.item_user_list_directory_header, listView, false);

+ 1 - 1
app/src/main/java/ch/threema/app/services/FileService.java

@@ -296,7 +296,7 @@ public interface FileService {
 	/**
 	 * return the decrypted thumbnail as bitmap
 	 */
-	Bitmap getMessageThumbnailBitmap(AbstractMessageModel messageModel, @Nullable ThumbnailCache thumbnailCache) throws Exception;
+	@Nullable Bitmap getMessageThumbnailBitmap(AbstractMessageModel messageModel, @Nullable ThumbnailCache thumbnailCache) throws Exception;
 
 	/**
 	 * return the "default" thumbnail

+ 4 - 2
app/src/main/java/ch/threema/app/services/FileServiceImpl.java

@@ -1106,8 +1106,10 @@ public class FileServiceImpl implements FileService {
 	}
 
 	@Override
-	public Bitmap getMessageThumbnailBitmap(AbstractMessageModel messageModel,
-	                                        @Nullable ThumbnailCache thumbnailCache) throws Exception {
+	public @Nullable Bitmap getMessageThumbnailBitmap(
+		AbstractMessageModel messageModel,
+		@Nullable ThumbnailCache thumbnailCache
+	) throws Exception {
 		if (thumbnailCache != null) {
 			Bitmap cached = thumbnailCache.get(messageModel.getId());
 			if (cached != null && !cached.isRecycled()) {

+ 11 - 5
app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayer.java

@@ -49,6 +49,8 @@ import ch.threema.client.ProgressListener;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.data.media.MediaMessageDataInterface;
 
+import static ch.threema.client.file.FileData.RENDERING_MEDIA;
+
 public abstract class MessagePlayer {
 	private static final Logger logger = LoggerFactory.getLogger(MessagePlayer.class);
 	public static final int SOURCE_UNDEFINED = 0;
@@ -98,7 +100,7 @@ public abstract class MessagePlayer {
 		@AnyThread void onError(String humanReadableMessage);
 	}
 
-	private interface InternalListener {
+	protected interface InternalListener {
 		@AnyThread void onComplete(boolean ok);
 	}
 
@@ -344,14 +346,18 @@ public abstract class MessagePlayer {
 				this.play(autoPlay);
 			}
 			else {
-				//download file
 				this.download(new InternalListener() {
 					@Override
 					public void onComplete(boolean ok) {
 						if(ok) {
 							data.isDownloaded(true);
 							messageService.save(setData(data));
-							open(autoPlay);
+
+							if (autoPlay ||
+								getMessageModel().getFileData().getRenderingType() != RENDERING_MEDIA ||
+								FileUtil.isAudioFile(getMessageModel().getFileData())) {
+								open(autoPlay);
+							}
 						}
 					}
 				}, autoPlay);
@@ -359,9 +365,9 @@ public abstract class MessagePlayer {
 			}
 			return true;
 		}
-
 		return false;
 	}
+
 	public MessagePlayer addListener(String key, PlayerListener listener) {
 		synchronized (this.playerListeners) {
 			this.playerListeners.put(key, listener);
@@ -525,7 +531,7 @@ public abstract class MessagePlayer {
 		});
 	}
 
-	private void download(final InternalListener internalListener, final boolean autoplay) {
+	protected void download(final InternalListener internalListener, final boolean autoplay) {
 		//download media first
 		if(this.state == State_DOWNLOADING) {
 			//do nothing, downloading in progress

+ 13 - 15
app/src/main/java/ch/threema/app/stores/PreferenceStore.java

@@ -716,23 +716,21 @@ public class PreferenceStore implements PreferenceStoreInterface {
 
 	@AnyThread
 	private void saveDataToCryptedFile(byte[] data, String filename) {
-		new Thread(() -> {
-			File f = new File(context.getFilesDir(), CRYPTED_FILE_PREFIX + filename);
-			if (!f.exists()) {
-				try {
-					FileUtil.createNewFileOrLog(f, logger);
-				} catch (Exception e) {
-					logger.error("Exception", e);
-				}
+		File f = new File(context.getFilesDir(), CRYPTED_FILE_PREFIX + filename);
+		if (!f.exists()) {
+			try {
+				FileUtil.createNewFileOrLog(f, logger);
+			} catch (Exception e) {
+				logger.error("Exception", e);
 			}
+		}
 
-			try (FileOutputStream fileOutputStream = new FileOutputStream(f);
-			     CipherOutputStream cipherOutputStream = masterKey.getCipherOutputStream(fileOutputStream)) {
-				cipherOutputStream.write(data);
-			} catch (IOException | MasterKeyLockedException e) {
-				logger.error("Unable to store prefs", e);
-			}
-		}).start();
+		try (FileOutputStream fileOutputStream = new FileOutputStream(f);
+		    CipherOutputStream cipherOutputStream = masterKey.getCipherOutputStream(fileOutputStream)) {
+			cipherOutputStream.write(data);
+		} catch (IOException | MasterKeyLockedException e) {
+			logger.error("Unable to store prefs", e);
+		}
 	}
 
 	@WorkerThread

+ 4 - 0
app/src/main/java/ch/threema/app/utils/AppRestrictionUtil.java

@@ -95,6 +95,10 @@ public class AppRestrictionUtil {
 		return getBoolRestriction(ThreemaApplication.getAppContext(), R.string.restriction__disable_video_calls);
 	}
 
+	public static boolean isWorkDirectoryDisabled() {
+		return getBoolRestriction(ThreemaApplication.getAppContext(), R.string.restriction__disable_work_directory);
+	}
+
 	/**
 	 * Check if a valid Threema Safe password pattern has been set by means of app restrictions
 	 * @param context Context

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

@@ -237,6 +237,16 @@ public class ConfigUtils {
 		return BuildConfig.VIDEO_CALLS_ENABLED;
 	}
 
+	public static boolean isWorkDirectoryEnabled() {
+		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+
+		if (serviceManager != null && serviceManager.getPreferenceService() != null) {
+			return (serviceManager.getPreferenceService().getWorkDirectoryEnabled() &&
+				!AppRestrictionUtil.isWorkDirectoryDisabled());
+		}
+		return false;
+	}
+
 	/**
 	 * Get a Socket Factory for certificate pinning and forced TLS version upgrade.
 	 * @param host
@@ -480,7 +490,8 @@ public class ConfigUtils {
 		}
 		info.append(Build.MANUFACTURER).append(";")
 			.append(Build.MODEL).append("/")
-			.append(Build.VERSION.RELEASE);
+			.append(Build.VERSION.RELEASE).append("/")
+			.append(BuildFlavor.getName());
 		return info.toString();
 	}
 

+ 49 - 2
app/src/main/java/ch/threema/app/voip/Config.java

@@ -21,6 +21,8 @@
 
 package ch.threema.app.voip;
 
+import android.os.Build;
+
 import androidx.annotation.NonNull;
 import ch.threema.app.utils.TurnServerCache;
 
@@ -30,13 +32,58 @@ import ch.threema.app.utils.TurnServerCache;
 public class Config {
 	private static final int MIN_SPARE_TURN_VALIDITY = 3600*1000;
 
-	// Hardware AEC Blacklist (Manufacturer;Model)
-	@NonNull public static String[] HW_AEC_BLACKLIST = new String[] {
+	// Hardware AEC exclusion list (Manufacturer;Model)
+	@NonNull private final static String[] HW_AEC_EXCLUSION_LIST = new String[] {
 		"Fairphone;FP2",
 		"ZUK;ZUK Z1", // Ticket #286367
         "bq;Aquaris X" // Ticket #494934
 	};
 
+	/**
+	 * Return whether this device is allowed to use hardware echo cancellation.
+	 *
+	 * This will return false only for devices on the {@link #HW_AEC_EXCLUSION_LIST}.
+	 */
+	public static boolean allowHardwareAec() {
+		final String deviceInfo = Build.MANUFACTURER + ";" + Build.MODEL;
+		for (String entry : HW_AEC_EXCLUSION_LIST) {
+			if (entry.equalsIgnoreCase(deviceInfo)) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	// Hardware video codec exclusion list (Manufacturer;Model;AndroidVersionPrefix)
+	@NonNull private final static String[] HW_VIDEO_CODEC_EXCLUSION_LIST = new String[] {
+		"Samsung;SM-A320FL;8.", // Galaxy A3 (2017), Ticket #926673
+		"Samsung;SM-G930F;7.", // Galaxy S7, Ticket #573851
+		"Samsung;SM-G960F;8.", // Galaxy S9, Ticket #379708
+	};
+
+	/**
+	 * Do not use this directly, only for simplified testing.
+	 * Use {@link #allowHardwareVideoCodec()} instead!
+	 */
+	protected static boolean allowHardwareVideoCodec(String[] exclusionList, String deviceInfo) {
+		for (String entry : exclusionList) {
+			if (deviceInfo.toLowerCase().startsWith(entry.toLowerCase())) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	/**
+	 * Return whether this device is allowed to use hardware video codecs.
+	 *
+	 * This will return false only for devices on the {@link #HW_VIDEO_CODEC_EXCLUSION_LIST}.
+	 */
+	public static boolean allowHardwareVideoCodec() {
+		final String deviceInfo = Build.MANUFACTURER + ";" + Build.MODEL + ";" + Build.VERSION.RELEASE;
+		return allowHardwareVideoCodec(HW_VIDEO_CODEC_EXCLUSION_LIST, deviceInfo);
+	}
+
 	private static final TurnServerCache TURN_SERVER_CACHE = new TurnServerCache("voip", MIN_SPARE_TURN_VALIDITY);
 
 	public static TurnServerCache getTurnServerCache() {

+ 24 - 23
app/src/main/java/ch/threema/app/voip/PeerConnectionClient.java

@@ -32,6 +32,7 @@
 package ch.threema.app.voip;
 
 import android.content.Context;
+import android.os.Build;
 import android.widget.Toast;
 
 import com.google.protobuf.InvalidProtocolBufferException;
@@ -47,7 +48,6 @@ import org.webrtc.DefaultVideoDecoderFactory;
 import org.webrtc.DefaultVideoEncoderFactory;
 import org.webrtc.EglBase;
 import org.webrtc.IceCandidate;
-import org.webrtc.Logging;
 import org.webrtc.MediaConstraints;
 import org.webrtc.MediaStream;
 import org.webrtc.MediaStreamTrack;
@@ -110,11 +110,11 @@ import ch.threema.app.voip.signaling.ToSignalingMessage;
 import ch.threema.app.voip.util.SdpPatcher;
 import ch.threema.app.voip.util.SdpUtil;
 import ch.threema.app.voip.util.VideoCapturerUtil;
+import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.app.voip.util.VoipVideoParams;
 import ch.threema.app.webrtc.DataChannelObserver;
 import ch.threema.app.webrtc.UnboundedFlowControlledDataChannel;
 import ch.threema.client.APIConnector;
-import ch.threema.logging.ThreemaLogger;
 import ch.threema.protobuf.callsignaling.CallSignaling;
 import java8.util.concurrent.CompletableFuture;
 import java8.util.stream.StreamSupport;
@@ -400,16 +400,12 @@ public class PeerConnectionClient {
 		final @Nullable EglBase.Context eglBaseContext,
 		final long callId
 	) {
-		// Set logger prefix
-		if (this.logger instanceof ThreemaLogger) {
-			((ThreemaLogger) logger).setPrefix(String.valueOf(callId));
-		}
+		// Set logging prefix
+		VoipUtil.setLoggerPrefix(logger, callId);
 
 		// Create logger for SdpPatcher
 		final Logger sdpPatcherLogger = LoggerFactory.getLogger(PeerConnectionClient.class + ":" + "SdpPatcher");
-		if (sdpPatcherLogger instanceof ThreemaLogger) {
-			((ThreemaLogger) sdpPatcherLogger).setPrefix(String.valueOf(callId));
-		}
+		VoipUtil.setLoggerPrefix(sdpPatcherLogger, callId);
 
 		// Initialize instance variables
 		this.appContext = appContext;
@@ -570,7 +566,12 @@ public class PeerConnectionClient {
 		// Determine video encoder/decoder factory
 		final VideoEncoderFactory encoderFactory;
 		final VideoDecoderFactory decoderFactory;
-		if (peerConnectionParameters.videoCodecHwAcceleration && this.eglBaseContext != null) {
+		boolean useHardwareVideoCodec = peerConnectionParameters.videoCodecHwAcceleration;
+		if (!Config.allowHardwareVideoCodec()) {
+			this.logger.info("Video codec: Device {} is on hardware codec exclusion list", Build.MODEL);
+			useHardwareVideoCodec = false;
+		}
+		if (useHardwareVideoCodec && this.eglBaseContext != null) {
 			logger.info("Video codec: HW acceleration (VP8={}, H264HiP={})",
 				peerConnectionParameters.videoCodecEnableVP8,
 				peerConnectionParameters.videoCodecEnableH264HiP);
@@ -1400,15 +1401,17 @@ public class PeerConnectionClient {
 
 		@Override
 		public void onIceCandidate(final IceCandidate candidate) {
+			logger.info("New local ICE candidate: {}", candidate.sdp);
+
 			// Discard loopback candidates
 			if (SdpUtil.isLoopbackCandidate(candidate.sdp)) {
-				logger.info("Discarding local loopback candidate: {}", candidate.sdp);
+				logger.info("Ignoring local ICE candidate (loopback): {}", candidate.sdp);
 				return;
 			}
 
 			// Discard IPv6 candidates if disabled
 			if (!PeerConnectionClient.this.peerConnectionParameters.allowIpv6 && SdpUtil.isIpv6Candidate(candidate.sdp)) {
-				logger.info("Discarding local IPv6 candidate (disabled via preferences): {}", candidate.sdp);
+				logger.info("Ignoring local ICE candidate (ipv6_disabled): {}", candidate.sdp);
 				return;
 			}
 
@@ -1419,7 +1422,7 @@ public class PeerConnectionClient {
 			final String relatedAddress = SdpUtil.getRelatedAddress(candidate.sdp);
 			if (relatedAddress != null && !relatedAddress.equals("0.0.0.0")) {
 				if (this.relatedAddresses.contains(relatedAddress)) {
-					logger.info("Discarding local relay candidate (duplicate related address {}): {}", relatedAddress, candidate.sdp);
+					logger.info("Ignoring local ICE candidate (duplicate_related_addr {}): {}", relatedAddress, candidate.sdp);
 					return;
 				} else {
 					this.relatedAddresses.add(relatedAddress);
@@ -1439,13 +1442,13 @@ public class PeerConnectionClient {
 
 		@Override
 		public void onSignalingChange(PeerConnection.SignalingState newState) {
-			logger.info("SignalingState: {}", newState);
+			logger.info("Signaling state change to {}", newState);
 		}
 
 		@Override
 		public void onIceConnectionChange(final PeerConnection.IceConnectionState newState) {
 			executor.execute(() -> {
-				logger.info("IceConnectionState: {}", newState);
+				logger.info("ICE connection state change to {}", newState);
 				if (newState == IceConnectionState.CHECKING) {
 					events.onIceChecking(callId);
 				} else if (newState == IceConnectionState.CONNECTED) {
@@ -1506,13 +1509,13 @@ public class PeerConnectionClient {
 
 		@Override
 		public void onIceGatheringChange(PeerConnection.IceGatheringState newState) {
-			logger.info("IceGatheringState: {}", newState);
+			logger.info("ICE gathering state change to {}", newState);
 			events.onIceGatheringStateChange(callId, newState);
 		}
 
 		@Override
 		public void onIceConnectionReceivingChange(boolean receiving) {
-			logger.info("IceConnectionReceiving changed to " + receiving);
+			logger.info("ICe connection receiving state change to {}", receiving);
 		}
 
 		@Override
@@ -1527,12 +1530,10 @@ public class PeerConnectionClient {
 
 		@Override
 		public void onDataChannel(final DataChannel dc) {
-			if (logger.isInfoEnabled()) {
-				try {
-					logger.info("New data channel: {} (id={})", dc.label(), dc.id());
-				} catch (IllegalStateException e) {
-					logger.error("Could not fetch data channel information", e);
-				}
+			try {
+				logger.warn("New unexpected data channel: {} (id={})", dc.label(), dc.id());
+			} catch (IllegalStateException e) {
+				logger.error("New unexpected data channel (could not fetch information)", e);
 			}
 		}
 

+ 5 - 5
app/src/main/java/ch/threema/app/voip/services/CallRejectService.java

@@ -32,9 +32,9 @@ import androidx.core.app.FixedJobIntentService;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.client.voip.VoipCallAnswerData;
-import ch.threema.logging.ThreemaLogger;
 import ch.threema.storage.models.ContactModel;
 
 import static ch.threema.app.voip.services.VoipCallService.EXTRA_CALL_ID;
@@ -59,15 +59,15 @@ public class CallRejectService extends FixedJobIntentService {
 
 	@Override
 	protected void onHandleWork(@NonNull Intent intent) {
+		logger.debug("CallRejectService onHandle work");
+
 		// Intent parameters
 		final String contactIdentity = intent.getStringExtra(EXTRA_CONTACT_IDENTITY);
 		final long callId = intent.getLongExtra(EXTRA_CALL_ID, 0L);
 		final byte rejectReason = intent.getByteExtra(EXTRA_REJECT_REASON, VoipCallAnswerData.RejectReason.UNKNOWN);
 
-		logger.debug("CallRejectService onHandle work");
-		if (logger instanceof ThreemaLogger) {
-			((ThreemaLogger) logger).setPrefix(String.valueOf(callId));
-		}
+		// Set logging prefix
+		VoipUtil.setLoggerPrefix(logger, callId);
 
 		// Services
 		try {

+ 162 - 98
app/src/main/java/ch/threema/app/voip/services/VoipCallService.java

@@ -492,7 +492,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			}
 
 			final long totalFramesReceived = getTotalFramesReceived(report);
-			logger.trace("FrameDetectorCallback: Total frames received = " + totalFramesReceived);
+			logger.trace("FrameDetectorCallback: Total frames received = {}", totalFramesReceived);
 
 			if (totalFramesReceived > this.lastFrameCount) {
 				// Frame count increased
@@ -772,7 +772,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	public void onCallHangUp() {
 		final CallStateSnapshot callState = this.voipStateService.getCallState();
 
-		logger.info("{}: Hanging up call", callState.getCallId());
+		logCallInfo(callState.getCallId(), "Hanging up call");
 
 		if (callState.isInitializing() || callState.isCalling()) {
 			new AsyncTask<Pair<ContactModel, Long>, Void, Void>() {
@@ -811,9 +811,10 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		// Do not initiate a new call if one is still running
 		final CallStateSnapshot callState = this.voipStateService.getCallState();
 		if (callState.isCalling()) {
-			logger.info(
-				"{}: Call is currently ongoing. Ignoring request to initiate new call ({}).",
-				callState.getCallId(), callId
+			logCallInfo(
+				callId,
+				"Call with ID {} is currently ongoing. Ignoring request to initiate new call.",
+				callState.getCallId()
 			);
 			return;
 		}
@@ -822,9 +823,9 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		final boolean isInitiator = intent.getBooleanExtra(EXTRA_IS_INITIATOR, false);
 		this.voipStateService.setInitiator(isInitiator);
 
-		logger.info(
-			"{}: Handle new call with {}, we are the {}",
+		logCallInfo(
 			callId,
+			"Handle new call with {}, we are the {}",
 			contactIdentity,
 			isInitiator ? "caller" : "callee"
 		);
@@ -839,7 +840,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		try {
 			newContact = getServiceManager().getContactService().getByIdentity(contactIdentity);
 		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
-			logger.error(callId + ": Could not get contact model", e);
+			logCallError(callId, "Could not get contact model", e);
 		}
 		if (newContact == null) {
 			// We cannot initialize a new call if the contact cannot be looked up.
@@ -856,11 +857,11 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 		// Can we use videocalls?
 		if (this.videoEnabled && !ConfigUtils.isVideoCallsEnabled()) {
-			logger.info("{}: videoEnabled=false, diabled via user config", callId);
+			logCallInfo(callId, "videoEnabled=false, diabled via user config");
 			this.videoEnabled = false;
 		}
 		if (this.videoEnabled && !ThreemaFeature.canVideocall(contact.getFeatureMask())) {
-			logger.info("{}: videoEnabled=false, remote feature mask does not support video calls", callId);
+			logCallInfo(callId, "videoEnabled=false, remote feature mask does not support video calls");
 			this.videoEnabled = false;
 		}
 
@@ -889,7 +890,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			// If the offerer does not signal video support, disable it
 			final FeatureList offerCallFeatures = callOfferData.getFeatures();
 			if (!offerCallFeatures.hasFeature(VideoFeature.NAME)) {
-				logger.info("{}: videoEnabled=false, remote does not signal support for video calls", callId);
+				logCallInfo(callId, "videoEnabled=false, remote does not signal support for video calls");
 				this.videoEnabled = false;
 			}
 		}
@@ -915,14 +916,14 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		if (contact.getVerificationLevel() == VerificationLevel.UNVERIFIED) {
 			// Force TURN if the contact is unverified, to hide the local IP address.
 			// This makes sure that a stranger cannot find out your IP simply by calling you.
-			logger.info("{}: Force TURN since contact is unverified", callId);
+			logCallInfo(callId, "Force TURN since contact is unverified");
 			forceTurn = true;
 		} else {
 			// Don't force turn for verified contacts unless the user explicitly enabled
 			// the setting.
 			forceTurn = this.preferenceService.getForceTURN();
 			if (forceTurn) {
-				logger.info("{}: Force TURN as requested by user", callId);
+				logCallInfo(callId, "Force TURN as requested by user");
 			}
 		}
 
@@ -987,7 +988,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		micEnabled = !micEnabled;
 
 		final long callId = this.voipStateService.getCallState().getCallId();
-		logger.debug("{}, onToggleMic enabled = {}", callId, micEnabled);
+		logCallDebug(callId, "onToggleMic enabled = {}", micEnabled);
 
 		if (peerConnectionClient != null) {
 			peerConnectionClient.setLocalAudioTrackEnabled(micEnabled);
@@ -1005,11 +1006,11 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 				this.audioManager.selectAudioDevice(audioDevice);
 			} else {
 				this.showSingleToast("Cannot switch to " + audioDevice, Toast.LENGTH_LONG);
-				logger.error("{}: Cannot switch to {}: Device not available", callId, audioDevice);
+				logCallError(callId, "Cannot switch to {}: Device not available", audioDevice);
 			}
 		} else {
 			this.showSingleToast("Cannot change audio device", Toast.LENGTH_LONG);
-			logger.error("{}: Cannot change audio device: Audio manager is null", callId);
+			logCallError(callId, "Cannot change audio device: Audio manager is null");
 		}
 	}
 
@@ -1035,7 +1036,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@UiThread
 	private synchronized void startCall(boolean startActivity, boolean launchVideo) {
 		final long callId = this.voipStateService.getCallState().getCallId();
-		logger.trace("{}: startCall", callId);
+		logCallTrace(callId, "startCall");
 
 		this.callStartedTimeMs = System.currentTimeMillis();
 		callStartedRealtimeMs = SystemClock.elapsedRealtime();
@@ -1043,7 +1044,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		// Show notification
 		this.showInCallNotification(this.callStartedTimeMs, callStartedRealtimeMs);
 
-		logger.info("{}: Video calls are {}", callId, this.videoEnabled ? "enabled" : "disabled");
+		logCallInfo(callId, "Video calls are {}", this.videoEnabled ? "enabled" : "disabled");
 
 		// Make sure that the peerConnectionClient is initialized
 		final @StringRes int initError = R.string.voip_error_init_call;
@@ -1057,7 +1058,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			this.abortCall(initError, "Cannot start call: video context is not initialized", false);
 			return;
 		}
-		logger.info("{}: Setting up call with {}", callId, contact.getIdentity());
+		logCallInfo(callId, "Setting up call with {}", contact.getIdentity());
 
 		// Start activity if desired
 		if (startActivity) {
@@ -1072,11 +1073,11 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		// audio modes, audio device enumeration etc.
 		this.audioManager = VoipAudioManager.create(getApplicationContext(), voipStateService.getRingtoneAudioFocusAbandoned());
 		VoipListenerManager.audioManagerListener.add(this.audioManagerListener);
-		logger.info("{}: Starting the audio manager...", callId);
+		logCallInfo(callId, "Starting the audio manager...");
 		this.audioManager.start();
 
 		// Create peer connection
-		logger.info("{}: Creating peer connection, delay={}ms", callId, System.currentTimeMillis() - this.callStartedTimeMs);
+		logCallInfo(callId, "Creating peer connection, delay={}ms", System.currentTimeMillis() - this.callStartedTimeMs);
 		final VideoSink localVideoSink = this.voipStateService.getVideoContext().getLocalVideoSinkProxy();
 		final VideoSink remoteVideoSink = this.voipStateService.getVideoContext().getRemoteVideoSinkProxy();
 		peerConnectionClient.createPeerConnection(localVideoSink, remoteVideoSink);
@@ -1097,7 +1098,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 	@UiThread
 	private void initAsInitiator(long callId, final boolean launchVideo) {
-		logger.info("{}: Init call as initiator", callId);
+		logCallInfo(callId, "Init call as initiator");
 
 		// Make sure that the peerConnectionClient is initialized
 		if (this.peerConnectionClient == null) {
@@ -1109,39 +1110,39 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		this.voipMessageListener = new VoipMessageListener() {
 			@Override
 			public synchronized void onOffer(final String identity, final VoipCallOfferData data) {
-				logger.error("{}: Received offer as initiator", data.getCallIdOrDefault(0L));
+				logCallError(data.getCallIdOrDefault(0L), "Received offer as initiator");
 			}
 
 			@Override
 			public synchronized void onAnswer(final String identity, final VoipCallAnswerData data) {
 				final long callId = data.getCallIdOrDefault(0L);
-				logger.info("{}: Received answer: {}", callId, data.getAction());
+				logCallInfo(callId, "Received answer: {}", data.getAction());
 
 				// Make sure that the peerConnectionClient is initialized
 				if (peerConnectionClient == null) {
-					logger.error("{}: Ignoring answer: peerConnectionClient is not initialized", callId);
+					logCallError(callId, "Ignoring answer: peerConnectionClient is not initialized");
 					return;
 				}
 
 				// Check state
 				final CallStateSnapshot callState = voipStateService.getCallState();
 				if (!callState.isInitializing()) {
-					logger.error("{}: Ignoring answer: callState is {}", callId, callState);
+					logCallError(callId, "Ignoring answer: callState is {}", callState);
 					return;
 				}
 
 				// Check contact in answer
 				if (contact == null) {
-					logger.error("{}: Ignoring answer: contact is not initialized", callId);
+					logCallError(callId, "Ignoring answer: contact is not initialized");
 					return;
 				} else if (!TestUtil.compare(contact.getIdentity(), identity)) {
-					logger.error("{}: Ignoring answer: Does not match current contact", callId);
+					logCallError(callId, "Ignoring answer: Does not match current contact");
 					return;
 				}
 
 				// Parse action
 				if (data.getAction() == null) {
-					logger.error("{}: Ignoring answer: Action is null", callId);
+					logCallError(callId, "Ignoring answer: Action is null");
 					return;
 				}
 				switch (data.getAction()) {
@@ -1149,8 +1150,8 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 						break;
 					case VoipCallAnswerData.Action.REJECT:
 						// Log event
-						logger.info("{}: Call to {} was rejected (reason code: {})",
-							callId, contact.getIdentity(), data.getRejectReason());
+						logCallInfo(callId, "Call to {} was rejected (reason code: {})",
+							contact.getIdentity(), data.getRejectReason());
 
 						// Stop ringing tone
 						stopLoopingSound(callId);
@@ -1165,7 +1166,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 						);
 
 						// Play busy sound
-						final boolean played = playSound(R.raw.busy_tone, "busy");
+						final boolean played = playSound(callId, R.raw.busy_tone, "busy");
 						if (!played) {
 							logger.error("Could not play busy tone!");
 						}
@@ -1183,7 +1184,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 				// Parse session description
 				final VoipCallAnswerData.AnswerData answerData = data.getAnswerData();
 				if (answerData == null) {
-					logger.error("{}: Ignoring answer: Answer data is null", callId);
+					logCallError(callId, "Ignoring answer: Answer data is null");
 					return;
 				}
 				final SessionDescription sd = SdpUtil.getAnswerSessionDescription(answerData);
@@ -1198,7 +1199,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 				// Detect video support in answer
 				if (!data.getFeatures().hasFeature(VideoFeature.NAME)) {
-					logger.info("{}: videoEnabled=false, remote does not signal support for video calls", callId);
+					logCallInfo(callId, "videoEnabled=false, remote does not signal support for video calls");
 					videoEnabled = false;
 					VoipUtil.sendVoipBroadcast(getApplicationContext(), CallActivity.ACTION_DISABLE_VIDEO);
 				}
@@ -1213,7 +1214,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			@Override
 			public void onRinging(String identity, final VoipCallRingingData data) {
 				long callId = data.getCallIdOrDefault(0L);
-				logger.info("{}: Peer device is ringing", callId);
+				logCallInfo(callId, "Peer device is ringing");
 				startLoopingSound(callId, R.raw.ringing_tone, "ringing");
 				VoipUtil.sendVoipBroadcast(getAppContext(), CallActivity.ACTION_PEER_RINGING);
 
@@ -1224,7 +1225,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 			@Override
 			public void onHangup(String identity, final VoipCallHangupData data) {
-				logger.info("{}: Received hangup from peer", data.getCallIdOrDefault(0L));
+				logCallInfo(data.getCallIdOrDefault(0L), "Received hangup from peer");
 			}
 
 			@Override
@@ -1234,13 +1235,13 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		};
 		VoipListenerManager.messageListener.add(this.voipMessageListener);
 
-		logger.info("{}: Creating offer...", callId);
+		logCallInfo(callId, "Creating offer...");
 		this.peerConnectionClient.createOffer();
 	}
 
 	@UiThread
 	private void initAsResponder(long callId) {
-		logger.info("{}: Init call as responder", callId);
+		logCallInfo(callId, "Init call as responder");
 
 		// Make sure that the peerConnectionClient is initialized
 		if (this.peerConnectionClient == null) {
@@ -1255,7 +1256,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		}
 
 		// Set remote description
-		logger.info("{}: Setting remote description", callId);
+		logCallInfo(callId, "Setting remote description");
 		this.peerConnectionClient.setRemoteDescription(this.offerSessionDescription);
 	}
 
@@ -1271,17 +1272,21 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 		// Sanity checks
 		if (contact == null) {
-			logger.info("{}: Ignore candidates from broadcast, contact hasn't been initialized yet", currentCallId);
+			logCallInfo(currentCallId, "Ignore candidates from broadcast, contact hasn't been initialized yet");
 			return;
 		}
 		if (!TestUtil.compare(contactIdentity, contact.getIdentity())) {
-			logger.info("{}: Ignore candidates from broadcast targeted at another identity (current {}, target {})",
-				currentCallId, contact.getIdentity(), contactIdentity);
+			logCallInfo(
+				currentCallId,
+				"Ignore candidates from broadcast targeted at another identity (current {}, target {})",
+				contact.getIdentity(),
+				contactIdentity
+			);
 			return;
 		}
 
-		logger.info("{}: Process candidates from broadcast", currentCallId);
-		this.processCandidates(candidatesData);
+		logCallInfo(currentCallId, "Process candidates from broadcast");
+		this.processCandidates(currentCallId, candidatesData);
 	}
 
 	/**
@@ -1305,9 +1310,9 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		this.stopLoopingSound(callId);
 
 		// Play pickup sound
-		final boolean played = this.playSound(R.raw.threema_pickup, "pickup");
+		final boolean played = this.playSound(callId, R.raw.threema_pickup, "pickup");
 		if (!played) {
-			logger.error("{}: Could not play pickup sound!", callId);
+			logCallError(callId, "Could not play pickup sound!");
 		}
 
 		// Start call duration counter
@@ -1322,13 +1327,13 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 		// Notify listeners
 		if (contact == null) {
-			logger.error("{}: contact is null in callConnected()", callId);
+			logCallError(callId, "contact is null in callConnected()");
 		} else {
 			final String contactIdentity = contact.getIdentity();
 			final Boolean isInitiator = this.voipStateService.isInitiator();
 			VoipListenerManager.callEventListener.handle(listener -> {
 				if (isInitiator == null) {
-					logger.error("{}: voipStateService.isInitiator() is null in callConnected()", callId);
+					logCallError(callId, "voipStateService.isInitiator() is null in callConnected()");
 				} else {
 					listener.onStarted(contactIdentity, isInitiator);
 				}
@@ -1438,11 +1443,13 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	 */
 	@UiThread
 	private synchronized void disconnect(@Nullable String message) {
+		// Get call ID (note: May already be reset to 0)
 		final CallStateSnapshot callState = this.voipStateService.getCallState();
 		final long callId = callState.getCallId();
+
 		logger.info(
-			"{}: disconnect (isConnected? {} | isError? {} | message: {})",
-			callId, this.iceConnected, this.isError, message
+			"disconnect (isConnected? {} | isError? {} | message: {})",
+			this.iceConnected, this.isError, message
 		);
 
 		// If the call is still connected, notify listeners about the finishing
@@ -1498,10 +1505,10 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	/**
 	 * Add or remove ICE candidates.
 	 */
-	private void processCandidates(@NonNull VoipICECandidatesData data) {
+	private void processCandidates(long callId, @NonNull VoipICECandidatesData data) {
 		// Null check
 		if (this.peerConnectionClient == null) {
-			logger.warn("Ignored ICE candidate message, peerConnectionClient is null");
+			logCallWarning(callId, "Ignored ICE candidate message, peerConnectionClient is null");
 			return;
 		}
 
@@ -1511,7 +1518,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			data.filter(candidate -> !SdpUtil.isIpv6Candidate(candidate.getCandidate()));
 			final int newSize = data.getCandidates().length;
 			if (newSize < prevSize) {
-				logger.info("Ignored {} remote IPv6 candidate (disabled via preferences)", prevSize - newSize);
+				logCallInfo(callId, "Ignored {} remote IPv6 candidate (disabled via preferences)", prevSize - newSize);
 			}
 		}
 
@@ -1522,9 +1529,9 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		}
 
 		// Log candidates
-		logger.info("Added {} VoIP ICE candidate(s):", candidates.length);
+		logCallInfo(callId, "Added {} VoIP ICE candidate(s):", candidates.length);
 		for (IceCandidate candidate : candidates) {
-			logger.info("  Incoming candidate: {}", candidate.sdp);
+			logCallInfo(callId, "  Incoming candidate: {}", candidate.sdp);
 		}
 	}
 
@@ -1651,6 +1658,58 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 	//endregion
 
+	//region Logging
+
+	// Note: Because the VoipCallService is not tied to a single call ID, we need to specify
+	//       the call ID for every logging call. These helper methods provide some boilerplate
+	//       code to make this easier.
+
+	private static void logCallTrace(long callId, String message) {
+		logger.trace("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallTrace(long callId, @NonNull String message, Object... arguments) {
+		logger.trace("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	private static void logCallDebug(long callId, String message) {
+		logger.debug("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallDebug(long callId, @NonNull String message, Object... arguments) {
+		logger.debug("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	private static void logCallInfo(long callId, String message) {
+		logger.info("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallInfo(long callId, @NonNull String message, Object... arguments) {
+		logger.info("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	private static void logCallWarning(long callId, String message) {
+		logger.warn("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallWarning(long callId, @NonNull String message, Object... arguments) {
+		logger.warn("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	private static void logCallError(long callId, String message) {
+		logger.error("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallError(long callId, String message, Throwable t) {
+		logger.error("[cid=" + callId + "]: " + message, t);
+	}
+
+	private static void logCallError(long callId, @NonNull String message, Object... arguments) {
+		logger.error("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	//endregion
+
 	//region Peer connection events
 
 	// -----Implementation of PeerConnectionClient.PeerConnectionEvents.---------
@@ -1660,11 +1719,11 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@Override
 	@AnyThread
 	public void onLocalDescription(long callId, final SessionDescription sdp) {
-		logger.info("{}: onLocalDescription", callId);
+		logCallInfo(callId, "onLocalDescription");
 		RuntimeUtil.runInAsyncTask(() -> {
 			synchronized (VoipCallService.this) {
 				final CallStateSnapshot callState = voipStateService.getCallState();
-				logger.info("{}: Sending {} in call state {}", callId, sdp.type, callState.getName());
+				logCallInfo(callId, "Sending {} in call state {}", sdp.type, callState.getName());
 				if (callState.isInitializing() || callState.isRinging()) {
 					try {
 						if (this.voipStateService.isInitiator() == Boolean.TRUE) {
@@ -1676,7 +1735,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 						this.abortCall(R.string.voip_error_init_call, "Could not send offer or answer message", e, false);
 					}
 				} else {
-					logger.info("{}: Discarding local description (wrong state)", callId);
+					logCallInfo(callId, "Discarding local description (wrong state)");
 				}
 			}
 		});
@@ -1685,15 +1744,15 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@Override
 	@AnyThread
 	public void onRemoteDescriptionSet(long callId) {
-		logger.info("{}: onRemoteDescriptionSet", callId);
+		logCallInfo(callId, "onRemoteDescriptionSet");
 
 		if (this.peerConnectionClient == null) {
-			logger.error("{}: Cannot create answer: peerConnectionClient is not initialized", callId);
+			logCallError(callId, "Cannot create answer: peerConnectionClient is not initialized");
 			return;
 		}
 
 		if (this.voipStateService.isInitiator() == Boolean.FALSE) {
-			logger.info("{}: Creating answer...", callId);
+			logCallInfo(callId, "Creating answer...");
 			peerConnectionClient.createAnswer();
 		}
 	}
@@ -1710,34 +1769,34 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			// to prevent a "candidate leak" otherwise.
 			final CallStateSnapshot callState = this.voipStateService.getCallState();
 			if (!(callState.isRinging() || callState.isInitializing() || callState.isCalling())) {
-				logger.info("Disposing ICE candidate, callState is {}", callState.getName());
+				logCallInfo(callId, "Disposing ICE candidate, callState is {}", callState.getName());
 				return;
 			}
 
 			// Log candidate
-			logger.info("Sending VoIP ICE candidate: {}", candidate.sdp);
+			logCallInfo(callId, "Sending VoIP ICE candidate: {}", candidate.sdp);
 
 			// Send
 			this.voipStateService.sendICECandidatesMessage(contact, callId, new IceCandidate[]{ candidate });
 		} catch (ThreemaException | IllegalArgumentException e) {
-			logger.error("Could not send ICE candidate", e);
+			logCallError(callId, "Could not send ICE candidate", e);
 		}
 	}
 
 	@Override
 	@AnyThread
 	public void onIceCandidate(long callId, final IceCandidate candidate) {
-		logger.trace("{}: onIceCandidate", callId);
+		logCallTrace(callId, "onIceCandidate");
 
 		// Send candidate
-		logger.trace("{}: onIceCandidate: {}", callId, candidate.sdp);
+		logCallTrace(callId, "onIceCandidate: {}", candidate.sdp);
 		VoipCallService.this.sendIceCandidate(callId, candidate);
 	}
 
 	@Override
 	@AnyThread
 	public void onIceChecking(long callId) {
-		logger.info("{}: ICE checking", callId);
+		logCallInfo(callId, "ICE checking");
 		synchronized (this) {
 			if (this.peerConnectionClient != null) {
 				// Register debug stats collector (fast interval until connected)
@@ -1762,7 +1821,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@Override
 	@AnyThread
 	public void onIceConnected(long callId) {
-		logger.info("{}: ICE connected", callId);
+		logCallInfo(callId, "ICE connected");
 		this.iceConnected = true;
 		if (this.iceWasConnected) {
 			// If we were previously connected, then the connection problem sound
@@ -1781,9 +1840,9 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 			// Play pickup sound
 			if (wasPlaying) {
-				final boolean played = this.playSound(R.raw.threema_pickup, "pickup");
+				final boolean played = this.playSound(callId, R.raw.threema_pickup, "pickup");
 				if (!played) {
-					logger.error("{}: Could not play pickup sound!", callId);
+					logCallError(callId, "Could not play pickup sound!");
 				}
 			}
 		} else {
@@ -1821,7 +1880,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	public void onIceDisconnected(final long callId) {
 		// ICE was disconnected. This can be a real closing of the connection,
 		// or just a temporary connectivity issue that can be recovered.
-		logger.info("{}: ICE disconnected", callId);
+		logCallInfo(callId, "ICE disconnected");
 		this.iceConnected = false;
 
 		// Notify activity about connectivity problems
@@ -1842,7 +1901,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 	@Override
 	public void onIceFailed(long callId) {
-		logger.warn("{}: ICE failed", callId);
+		logCallWarning(callId, "ICE failed");
 		this.iceConnected = false;
 
 		if (this.iceWasConnected) {
@@ -1863,6 +1922,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 
 			// Play problem sound and disconnect
 			final boolean played = playSound(
+				callId,
 				R.raw.threema_problem,
 				"problem",
 				() -> RuntimeUtil.runOnUiThread(
@@ -1870,26 +1930,26 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 				)
 			);
 			if (!played) {
-				logger.error("{}: Could not play problem sound!", callId);
+				logCallError(callId, "Could not play problem sound!");
 			}
 		}
 	}
 
 	@Override
 	public void onIceGatheringStateChange(long callId, PeerConnection.IceGatheringState newState) {
-		logger.trace("{}: onIceGatheringStateChange", callId);
+		logCallTrace(callId, "onIceGatheringStateChange");
 	}
 
 	@Override
 	@AnyThread
 	public void onPeerConnectionClosed(long callId) {
-		logger.trace("{}: onPeerConnectionClosed", callId);
-		logger.info("{}: Peer connection closed", callId);
+		logCallTrace(callId, "onPeerConnectionClosed");
+		logCallInfo(callId, "Peer connection closed");
 
 		// Play disconnect sound
-		final boolean played = this.playSound(R.raw.threema_hangup, "disconnect");
+		final boolean played = this.playSound(callId, R.raw.threema_hangup, "disconnect");
 		if (!played) {
-			logger.error("{}: Could not play disconnect sound!", callId);
+			logCallError(callId, "Could not play disconnect sound!");
 		}
 
 		// Call disconnect method
@@ -1914,11 +1974,11 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@WorkerThread
 	public void onSignalingMessage(long callId, @NonNull CallSignaling.Envelope envelope) {
 		if (envelope.hasCaptureStateChange()) {
-			this.handleCaptureStateChange(envelope.getCaptureStateChange());
+			this.handleCaptureStateChange(callId, envelope.getCaptureStateChange());
 		} else if (envelope.hasVideoQualityProfile()) {
-			this.handleVideoQualityProfileChange(envelope.getVideoQualityProfile());
+			this.handleVideoQualityProfileChange(callId, envelope.getVideoQualityProfile());
 		} else {
-			logger.warn("{}: onSignalingMessage: Unknown envelope variant", callId);
+			logCallWarning(callId, "onSignalingMessage: Unknown envelope variant");
 		}
 	}
 
@@ -1959,10 +2019,10 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	                                            int rawResource,
 	                                            final String soundName) {
 		if (this.mediaPlayer != null) {
-			logger.error("{}: Not playing {} sound, mediaPlayer is not null!", callId, soundName);
+			logCallError(callId, "Not playing {} sound, mediaPlayer is not null!", soundName);
 			return;
 		}
-		logger.info("{}: Playing {} sound...", callId, soundName);
+		logCallInfo(callId, "Playing {} sound...", soundName);
 
 		// Initialize media player
 		this.mediaPlayer = new MediaPlayerStateWrapper();
@@ -1999,7 +2059,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	@AnyThread
 	private synchronized void stopLoopingSound(long callId) {
 		if (this.mediaPlayer != null) {
-			logger.info("{}: Stopping ringing tone...", callId);
+			logCallInfo(callId, "Stopping ringing tone...");
 			this.mediaPlayer.stop();
 			this.mediaPlayer.release();
 		}
@@ -2010,10 +2070,13 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	 * Play a one-time sound.
 	 */
 	@AnyThread
-	private synchronized boolean playSound(int rawResource,
-	                                       final String soundName,
-	                                       @Nullable final OnSoundComplete onComplete) {
-		logger.info("Playing {} sound...", soundName);
+	private synchronized boolean playSound(
+		long callId,
+	    int rawResource,
+	    final String soundName,
+	    @Nullable final OnSoundComplete onComplete
+	) {
+		logCallInfo(callId, "Playing {} sound...", soundName);
 
 		// Initialize media player
 		final MediaPlayerStateWrapper soundPlayer = new MediaPlayerStateWrapper();
@@ -2027,7 +2090,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 			soundPlayer.setDataSource(afd);
 			soundPlayer.prepare();
 		} catch (IOException e) {
-			logger.error("Could not play " + soundName + " sound", e);
+			logCallError(callId, "Could not play " + soundName + " sound", e);
 			soundPlayer.release();
 			return false;
 		} finally {
@@ -2059,8 +2122,8 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	}
 
 	@AnyThread
-	private synchronized boolean playSound(final int rawResource, final String soundName) {
-		return this.playSound(rawResource, soundName, null);
+	private synchronized boolean playSound(long callId, final int rawResource, final String soundName) {
+		return this.playSound(callId, rawResource, soundName, null);
 	}
 
 	//endregion
@@ -2396,7 +2459,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		}
 
 		/**
-		 * Called by {@link #handleCaptureStateChange(CallSignaling.CaptureState)}.
+		 * Called by {@link #handleCaptureStateChange(long, CallSignaling.CaptureState)}.
 		 * Remote has signaled that video capturing has started.
 		 */
 		synchronized void onRemoteVideoCapturingEnabled() {
@@ -2413,7 +2476,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		}
 
 		/**
-		 * Called by {@link #handleCaptureStateChange(CallSignaling.CaptureState)}.
+		 * Called by {@link #handleCaptureStateChange(long, CallSignaling.CaptureState)}.
 		 * Remote has signaled that video capturing has stopped.
 		 */
 		synchronized void onRemoteVideoCapturingDisabled() {
@@ -2495,8 +2558,9 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	 * @param captureStateChange The received signaling message.
 	 */
 	@AnyThread
-	private void handleCaptureStateChange(@NonNull CallSignaling.CaptureState captureStateChange) {
-		logger.info(
+	private void handleCaptureStateChange(long callId, @NonNull CallSignaling.CaptureState captureStateChange) {
+		logCallInfo(
+			callId,
 			"Signaling: Call partner changed {} capturing state to {}",
 			captureStateChange.getDevice(),
 			captureStateChange.getState()
@@ -2512,7 +2576,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 					this.remoteVideoStateDetector.onRemoteVideoCapturingDisabled();
 					break;
 				default:
-					logger.warn("Unknown capture state received");
+					logCallWarning(callId, "Unknown capture state received");
 			}
 		}
 	}
@@ -2523,8 +2587,8 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 	 * @param videoQualityProfile The received signaling message.
 	 */
 	@AnyThread
-	private void handleVideoQualityProfileChange(@NonNull CallSignaling.VideoQualityProfile videoQualityProfile) {
-		logger.info("Signaling: Call partner changed video profile to {}", videoQualityProfile.getProfile());
+	private void handleVideoQualityProfileChange(long callId, @NonNull CallSignaling.VideoQualityProfile videoQualityProfile) {
+		logCallInfo(callId, "Signaling: Call partner changed video profile to {}", videoQualityProfile.getProfile());
 
 		final VoipVideoParams profile = VoipVideoParams.fromSignalingMessage(videoQualityProfile);
 		if (profile != null) {

+ 127 - 66
app/src/main/java/ch/threema/app/voip/services/VoipStateService.java

@@ -65,6 +65,7 @@ import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.ExecutionException;
 
 import androidx.annotation.AnyThread;
@@ -257,6 +258,58 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		this.audioManager = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
 	}
 
+	//region Logging
+
+	// Note: Because the VoipStateService is not tied to a single call ID, we need to specify
+	//       the call ID for every logging call. These helper methods provide some boilerplate
+	//       code to make this easier.
+
+	private static void logCallTrace(long callId, String message) {
+		logger.trace("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallTrace(long callId, @NonNull String message, Object... arguments) {
+		logger.trace("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	private static void logCallDebug(long callId, String message) {
+		logger.debug("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallDebug(long callId, @NonNull String message, Object... arguments) {
+		logger.debug("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	private static void logCallInfo(long callId, String message) {
+		logger.info("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallInfo(long callId, @NonNull String message, Object... arguments) {
+		logger.info("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	private static void logCallWarning(long callId, String message) {
+		logger.warn("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallWarning(long callId, @NonNull String message, Object... arguments) {
+		logger.warn("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	private static void logCallError(long callId, String message) {
+		logger.error("[cid={}]: {}", callId, message);
+	}
+
+	private static void logCallError(long callId, String message, Throwable t) {
+		logger.error("[cid=" + callId + "]: " + message, t);
+	}
+
+	private static void logCallError(long callId, @NonNull String message, Object... arguments) {
+		logger.error("[cid=" + callId + "]: " + message, arguments);
+	}
+
+	//endregion
+
 	//region State transitions
 
 	/**
@@ -282,8 +335,9 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		@NonNull CallStateSnapshot oldState,
 		@NonNull CallStateSnapshot newState
 	) {
-		logger.info(
-			"Call state change from {}({}/{}) to {}({}/{})",
+		logger.info("Call state change from {} to {}", oldState.getName(), newState.getName());
+		logger.debug(
+			"  State{{},id={},counter={}} → State{{},id={},counter={}}",
 			oldState.getName(), oldState.getCallId(), oldState.getIncomingCallCounter(),
 			newState.getName(), newState.getCallId(), newState.getIncomingCallCounter()
 		);
@@ -342,13 +396,16 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 
 		// Send cached candidates and clear cache
 		synchronized (this.candidatesCache) {
-			logger.info("{}: Processing cached candidates for {} ID(s)", callId, this.candidatesCache.size());
+			logCallInfo(callId, "Processing cached candidates for {} ID(s)", this.candidatesCache.size());
 
 			// Note: We're sending all cached candidates. The broadcast receiver
 			// is responsible for dropping the ones that aren't of interest.
 			for (Map.Entry<String, List<VoipICECandidatesData>> entry : this.candidatesCache.entrySet()) {
-				logger.info("{}: Broadcasting {} candidates data messages from {}",
-					callId, entry.getValue().size(), entry.getKey());
+				logCallInfo(
+					callId,
+					"Broadcasting {} candidates data messages from {}",
+					entry.getValue().size(), entry.getKey()
+				);
 				for (VoipICECandidatesData data : entry.getValue()) {
 					// Broadcast candidates
 					Intent intent = new Intent();
@@ -515,20 +572,21 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		// Unwrap data
 		final VoipCallOfferData callOfferData = msg.getData();
 		if (callOfferData == null) {
-			logger.warn("VoipCallOfferMessage received. Data is null, ignoring.");
+			logger.warn("Call offer received from {}. Data is null, ignoring.", msg.getFromIdentity());
 			return true;
 		}
 		final long callId = callOfferData.getCallIdOrDefault(0L);
-		logger.info(
-			"{}: VoipCallOfferMessage received from {} (Features: {})",
-			callId, msg.getFromIdentity(), callOfferData.getFeatures()
+		logCallInfo(
+			callId,
+			"Call offer received from {} (Features: {})",
+			msg.getFromIdentity(), callOfferData.getFeatures()
 		);
-		logger.info("{}: {}", callId, callOfferData.getOfferData());
+		logCallInfo(callId, "{}", callOfferData.getOfferData());
 
 		// Get contact and receiver
 		final ContactModel contact = this.contactService.getByIdentity(msg.getFromIdentity());
 		if (contact == null) {
-			logger.error("{}: Could not fetch contact for identity {}", callId, msg.getFromIdentity());
+			logCallError(callId, "Could not fetch contact for identity {}", msg.getFromIdentity());
 			return true;
 		}
 
@@ -537,25 +595,25 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		boolean silentReject = false; // Set to true if you don't want a "missed call" chat message
 		if (!this.preferenceService.isVoipEnabled()) {
 			// Calls disabled
-			logger.info("{}: Rejecting call from {} (disabled)", callId, contact.getIdentity());
+			logCallInfo(callId, "Rejecting call from {} (disabled)", contact.getIdentity());
 			rejectReason = VoipCallAnswerData.RejectReason.DISABLED;
 			silentReject = true;
 		} else if (!this.validateOfferData(callOfferData.getOfferData())) {
 			// Offer invalid
-			logger.warn("{}: Rejecting call from {} (invalid offer)", callId, contact.getIdentity());
+			logCallWarning(callId, "Rejecting call from {} (invalid offer)", contact.getIdentity());
 			rejectReason = VoipCallAnswerData.RejectReason.UNKNOWN;
 			silentReject = true;
 		} else if (!this.callState.isIdle()) {
 			// Another call is already active
-			logger.info("{}: Rejecting call from {} (busy)", callId, contact.getIdentity());
+			logCallInfo(callId, "Rejecting call from {} (busy)", contact.getIdentity());
 			rejectReason = VoipCallAnswerData.RejectReason.BUSY;
 		} else if (VoipUtil.isPSTNCallOngoing(this.appContext)) {
 			// A PSTN call is ongoing
-			logger.info("{}: Rejecting call from {} (PSTN call ongoing)", callId, contact.getIdentity());
+			logCallInfo(callId, "Rejecting call from {} (PSTN call ongoing)", contact.getIdentity());
 			rejectReason = VoipCallAnswerData.RejectReason.BUSY;
 		} else if (DNDUtil.getInstance().isMutedWork()) {
 			// Called outside working hours
-			logger.info("{}: Rejecting call from {} (called outside of working hours)", callId, contact.getIdentity());
+			logCallInfo(callId, "Rejecting call from {} (called outside of working hours)", contact.getIdentity());
 			rejectReason = VoipCallAnswerData.RejectReason.OFF_HOURS;
 		}
 		if (rejectReason != null) {
@@ -698,7 +756,7 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 			final long callId = callAnswerData.getCallIdOrDefault(0L);
 			if (!this.isCallIdValid(callId)) {
 				logger.info(
-					"Received answer for an invalid call ID ({}, local={}), ignoring",
+					"Call answer received for an invalid call ID ({}, local={}), ignoring",
 					callId, this.callState.getCallId()
 				);
 				return true;
@@ -706,18 +764,16 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 
 			// Ensure that action was set
 			if (callAnswerData.getAction() == null) {
-			    logger.error("Received answer without action, ignoring");
+			    logger.error("Call answer received without action, ignoring");
 			    return true;
 			}
 
 			switch (callAnswerData.getAction()) {
 				// Call was accepted
 				case VoipCallAnswerData.Action.ACCEPT:
-					logger.info(
-						"{}: VoipCallAnswerMessage received: Accept => (Features: {})",
-						callId, callAnswerData.getFeatures()
-					);
-					logger.info("{}: {}", callId, callAnswerData.getAnswerData());
+					logCallInfo(callId, "Call answer received from {}: accept", msg.getFromIdentity());
+					logCallInfo(callId, "Answer features: {}", callAnswerData.getFeatures());
+					logCallInfo(callId, "Answer data: {}", callAnswerData.getAnswerData());
 					VoipUtil.sendVoipBroadcast(this.appContext, CallActivity.ACTION_CALL_ACCEPTED);
 					break;
 
@@ -727,11 +783,12 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 					VoipListenerManager.callEventListener.handle(listener -> {
 						listener.onRejected(msg.getFromIdentity(), false, callAnswerData.getRejectReason());
 					});
-					logger.info("{}: VoipCallAnswerMessage received: Reject (reason code: {})", callId, callAnswerData.getRejectReason());
+					logCallInfo(callId, "Call answer received from {}: reject/{}",
+						msg.getFromIdentity(), callAnswerData.getRejectReasonName());
 					break;
 
 				default:
-					logger.info("{}: VoipCallAnswer message received: Unknown action: {}", callId, callAnswerData.getAction());
+					logCallInfo(callId, "Call answer received from {}: Unknown action: {}", callAnswerData.getAction());
 					break;
 			}
 		}
@@ -756,11 +813,11 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		// Unwrap data
 		final VoipICECandidatesData candidatesData = msg.getData();
 		if (candidatesData == null) {
-			logger.warn("VoipICECandidatesMessage received. Data is null, ignoring.");
+			logger.warn("Call ICE candidate message received from {}. Data is null, ignoring", msg.getFromIdentity());
 			return true;
 		}
 		if (candidatesData.getCandidates() == null) {
-			logger.warn("VoipICECandidatesMessage received. Candidates are null, ignoring.");
+			logger.warn("Call ICE candidate message received from {}. Candidates are null, ignoring", msg.getFromIdentity());
 			return true;
 		}
 
@@ -768,22 +825,26 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		final long callId = candidatesData.getCallIdOrDefault(0L);
 		if (!this.isCallIdValid(callId)) {
 			logger.info(
-				"Received candidates for an invalid Call ID ({}, local={}), ignoring",
-				callId, this.callState.getCallId()
+				"Call ICE candidate message received from {} for an invalid Call ID ({}, local={}), ignoring",
+				msg.getFromIdentity(), callId, this.callState.getCallId()
 			);
 			return true;
 		}
 
 		// The "removed" flag is deprecated, see ANDR-1145 / SE-66
 		if (candidatesData.isRemoved()) {
-			logger.info("{}: Ignoring VoipICECandidatesMessage with removed=true", callId);
+			logCallInfo(callId, "Call ICE candidate message received from {} with removed=true, ignoring");
 			return true;
 		}
 
-		logger.info(
-			"{}: VoipICECandidatesMessage with {} candidates received",
-			callId, candidatesData.getCandidates().length
+		logCallInfo(
+			callId,
+			"Call ICE candidate message received from {} ({} candidates)",
+			msg.getFromIdentity(), candidatesData.getCandidates().length
 		);
+		for (VoipICECandidatesData.Candidate candidate : candidatesData.getCandidates()) {
+			logCallInfo(callId, "  Incoming ICE candidate: {}", candidate.getCandidate());
+		}
 
 		// Handle candidates depending on state
 		if (this.callState.isIdle() || this.callState.isRinging()) {
@@ -798,7 +859,7 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 			intent.putExtra(EXTRA_CANDIDATES, candidatesData);
 			LocalBroadcastManager.getInstance(appContext).sendBroadcast(intent);
 		} else {
-			logger.warn("Received ICE candidates in invalid call state ({})", this.callState);
+			logCallWarning(callId, "Received ICE candidates in invalid call state ({})", this.callState);
 		}
 
 		// Otherwise, ignore message.
@@ -822,17 +883,21 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 			: msg.getData().getCallIdOrDefault(0L);
 		if (!this.isCallIdValid(callId)) {
 			logger.info(
-				"Received ringing for an invalid Call ID ({}, local={}), ignoring",
-				callId, state.getCallId()
+				"Call ringing message received from {} for an invalid Call ID ({}, local={}), ignoring",
+				msg.getFromIdentity(), callId, state.getCallId()
 			);
 			return true;
 		}
 
-		logger.info("VoipCallRingingMessage received.");
+		logCallInfo(callId, "Call ringing message received from {}", msg.getFromIdentity());
 
 		// Check whether we're in the correct state for a ringing message
 		if (!state.isInitializing()) {
-			logger.warn("Ignoring VoipCallRingingMessage, call state is {}", state.getName());
+			logCallWarning(
+				callId,
+				"Call ringing message from {} ignored, call state is {}",
+				msg.getFromIdentity(), state.getName()
+			);
 			return true;
 		}
 
@@ -862,13 +927,13 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 			: msg.getData().getCallIdOrDefault(0L);
 		if (!this.isCallIdValid(callId)) {
 			logger.info(
-				"Received hangup for an invalid Call ID ({}, local={}), ignoring",
-				callId, this.callState.getCallId()
+				"Call hangup message received from {} for an invalid Call ID ({}, local={}), ignoring",
+				msg.getFromIdentity(), callId, this.callState.getCallId()
 			);
 			return true;
 		}
 
-		logger.info("VoipCallHangupMessage received.");
+		logger.info("Call hangup message received from {}", msg.getFromIdentity());
 
 		final String identity = msg.getFromIdentity();
 
@@ -981,14 +1046,10 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		voipCallOfferMessage.setData(callOfferData);
 		voipCallOfferMessage.setToIdentity(receiver.getIdentity());
 
-		logger.info("{}: Enqueue VoipCallOfferMessage ID {} to {}: {} (features: {})",
-			callId,
-			voipCallOfferMessage.getMessageId(),
-			voipCallOfferMessage.getToIdentity(),
-			callOfferData.getOfferData(),
-			callOfferData.getFeatures()
-		);
 		this.messageQueue.enqueue(voipCallOfferMessage);
+		logCallInfo(callId, "Call offer enqueued to {}", voipCallOfferMessage.getToIdentity());
+		logCallInfo(callId, "  Offer features: {}", callOfferData.getFeatures());
+		logCallInfo(callId, "  Offer data: {}", callOfferData.getOfferData());
 		this.messageService.sendProfilePicture(new MessageReceiver[] {contactService.createReceiver(receiver)});
 	}
 
@@ -1110,16 +1171,10 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		voipCallAnswerMessage.setData(callAnswerData);
 		voipCallAnswerMessage.setToIdentity(receiver.getIdentity());
 
-		logger.info("{}: Enqueue VoipCallAnswerMessage ID {} to {}: action={} (features: {})",
-			callId,
-			voipCallAnswerMessage.getMessageId(),
-			voipCallAnswerMessage.getToIdentity(),
-			callAnswerData.getAction(),
-			callAnswerData.getFeatures()
-		);
+		logCallInfo(callId, "Call answer enqueued to {}: {}", voipCallAnswerMessage.getToIdentity(), callAnswerData.getAction());
+		logCallInfo(callId, "  Answer features: {}", callAnswerData.getFeatures());
 		messageQueue.enqueue(voipCallAnswerMessage);
 		this.messageService.sendProfilePicture(new MessageReceiver[] {contactService.createReceiver(receiver)});
-
 	}
 
 	/**
@@ -1137,26 +1192,29 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 			return;
 		}
 
+		// Build message
 		final List<VoipICECandidatesData.Candidate> candidates = new LinkedList<>();
 		for (IceCandidate c : iceCandidates) {
 			if (c != null) {
 				candidates.add(new VoipICECandidatesData.Candidate(c.sdp, c.sdpMid, c.sdpMLineIndex, null));
 			}
 		}
-
 		final VoipICECandidatesData voipICECandidatesData = new VoipICECandidatesData()
 			.setCallId(callId)
 			.setCandidates(candidates.toArray(new VoipICECandidatesData.Candidate[candidates.size()]));
-
 		final VoipICECandidatesMessage voipICECandidatesMessage = new VoipICECandidatesMessage();
 		voipICECandidatesMessage.setData(voipICECandidatesData);
 		voipICECandidatesMessage.setToIdentity(receiver.getIdentity());
 
-		logger.info(
-			"{}: Enqueue VoipICECandidatesMessage ID {} to {}",
-			callId, voipICECandidatesMessage.getMessageId(), voipICECandidatesMessage.getToIdentity()
-		);
+		// Enqueue
 		messageQueue.enqueue(voipICECandidatesMessage);
+
+		// Log
+		logCallInfo(callId, "Call ICE candidate message enqueued to {}", voipICECandidatesMessage.getToIdentity());
+		for (VoipICECandidatesData.Candidate candidate : Objects.requireNonNull(voipICECandidatesData.getCandidates())) {
+			logCallInfo(callId, "  Outgoing ICE candidate: {}", candidate.getCandidate());
+		}
+
 	}
 
 	/**
@@ -1178,8 +1236,8 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		msg.setToIdentity(contactModel.getIdentity());
 		msg.setData(callRingingData);
 
-		logger.info("{}: Enqueue VoipCallRinging message ID {} to {}", callId, msg.getMessageId(), msg.getToIdentity());
 		messageQueue.enqueue(msg);
+		logCallInfo(callId, "Call ringing message enqueued to {}", msg.getToIdentity());
 	}
 
 	/**
@@ -1202,9 +1260,12 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		final Integer duration = getCallDuration();
 		final boolean outgoing = this.isInitiator() == Boolean.TRUE;
 
-		logger.info("{}: Enqueue VoipCallHangup message ID {} to {} (prevState={}, duration={})",
-			callId, msg.getMessageId(), msg.getToIdentity(), state, duration);
 		messageQueue.enqueue(msg);
+		logCallInfo(
+			callId,
+			"Call hangup message enqueued to {} (prevState={}, duration={})",
+			msg.getToIdentity(), state, duration
+		);
 
 		// Notify the VoIP call event listener
 		if (duration == null && (state.isInitializing() || state.isCalling() || state.isDisconnecting())) {
@@ -1589,7 +1650,7 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 	 * Add a new ICE candidate to the cache.
 	 */
 	private void cacheCandidate(String identity, VoipICECandidatesData data) {
-		logger.debug("{}: Caching candidate from {}", data.getCallIdOrDefault(0L), identity);
+		logCallDebug(data.getCallIdOrDefault(0L), "Caching candidate from {}", identity);
 		synchronized (this.candidatesCache) {
 			if (this.candidatesCache.containsKey(identity)) {
 				List<VoipICECandidatesData> candidates = this.candidatesCache.get(identity);

+ 16 - 0
app/src/main/java/ch/threema/app/voip/util/VoipUtil.java

@@ -52,6 +52,7 @@ import ch.threema.app.voip.services.VoipCallService;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.base.ThreemaException;
 import ch.threema.client.ThreemaFeature;
+import ch.threema.logging.ThreemaLogger;
 import ch.threema.storage.models.ContactModel;
 
 
@@ -231,4 +232,19 @@ public class VoipUtil {
 		TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
 		return (telephonyManager != null && telephonyManager.getCallState() != TelephonyManager.CALL_STATE_IDLE);
 	}
+
+	/**
+	 * If the logger is a {@link ch.threema.logging.ThreemaLogger}, set the appropriate
+	 * call ID logging prefix.
+	 *
+	 * If the logger is null, or if it's not a {@link ch.threema.logging.ThreemaLogger}, do nothing.
+	 */
+	public static void setLoggerPrefix(@Nullable Logger logger, long callId) {
+		if (logger == null) {
+			return;
+		}
+		if (logger instanceof ThreemaLogger) {
+			((ThreemaLogger) logger).setPrefix("[cid=" + callId + "]");
+		}
+	}
 }

+ 1 - 1
app/src/main/java/ch/threema/app/webclient/services/instance/SessionInstanceServiceImpl.java

@@ -766,7 +766,7 @@ public class SessionInstanceServiceImpl implements SessionInstanceService {
 		} catch (NullPointerException e) {
 			// TODO: If you don't want this, recursively follow this code and all handlers and fix
 			//       the potential NPEs. There are dozens...
-			logger.error("Protocol error due to invalid message", e);
+			logger.error("Protocol error due to NPE", e);
 			this.stop(DisconnectContext.byUs(DisconnectContext.REASON_ERROR));
 		} catch (DispatchException e) {
 			logger.warn("Could not dispatch message", e);

+ 6 - 4
app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/ThumbnailRequestHandler.java

@@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory;
 import java.util.Map;
 
 import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
 import androidx.annotation.WorkerThread;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
@@ -110,15 +111,16 @@ public class ThumbnailRequestHandler extends MessageReceiver {
 			}
 
 			// Make sure that the thumbnail is not larger than a certain size
-			thumbnail = this.resizeThumbnail(thumbnail);
+			if (thumbnail != null) {
+				thumbnail = this.resizeThumbnail(thumbnail);
+			}
 
-			//return null if no avatar is available
+			// Return null if no avatar is available
 			byte[] data = null;
 			if (thumbnail != null) {
 				data = BitmapUtil.bitmapToByteArray(thumbnail, Protocol.FORMAT_THUMBNAIL, Protocol.QUALITY_THUMBNAIL);
 			}
 			this.send(this.dispatcher, data, args);
-
 		} catch (MessagePackException e) {
 			logger.error("Exception", e);
 		}
@@ -128,7 +130,7 @@ public class ThumbnailRequestHandler extends MessageReceiver {
 	 * Make sure that the thumbnail is not larger than a certain size,
 	 * resizing if necessary.
 	 */
-	private Bitmap resizeThumbnail(final Bitmap thumbnail) {
+	private Bitmap resizeThumbnail(final @NonNull Bitmap thumbnail) {
 		return ThumbnailUtils.resize(thumbnail, Protocol.SIZE_THUMBNAIL_MAX_PX);
 	}
 

+ 1 - 2
app/src/main/java/ch/threema/app/webclient/utils/ThumbnailUtils.java

@@ -64,8 +64,7 @@ public class ThumbnailUtils {
 	 * Make sure that no side is larger than maxSize,
 	 * resizing if necessary.
 	 */
-	public static Bitmap resize(@NonNull final Bitmap thumbnail,
-	                            int maxSidePx) {
+	public static Bitmap resize(@NonNull final Bitmap thumbnail, int maxSidePx) {
 		int w = thumbnail.getWidth();
 		int h = thumbnail.getHeight();
 

+ 6 - 0
app/src/main/java/ch/threema/client/AppVersion.java

@@ -48,11 +48,17 @@ public class AppVersion extends Version {
 		this.appSystemVersion = appSystemVersion;
 	}
 
+	/**
+	 * Return the short version: Version;PlatformCode
+	 */
 	@Override
 	public String getVersion() {
 		return appVersionNumber + appPlatformCode;
 	}
 
+	/**
+	 * Return the full version: Version;PlatformCode;Language/Country;SystemModel;SystemVersion
+	 */
 	@Override
 	public String getFullVersion() {
 		return appVersionNumber.replace(";", "_") + ";" + appPlatformCode.replace(";", "_") + ";" +

+ 28 - 1
app/src/main/java/ch/threema/client/voip/VoipCallAnswerData.java

@@ -194,7 +194,34 @@ public class VoipCallAnswerData extends VoipCallData<VoipCallAnswerData> {
 	}
 
 	public @Nullable Byte getRejectReason() {
-		return rejectReason;
+		return this.rejectReason;
+	}
+
+	/**
+	 * Return a string representation of the reject reason.
+	 *
+	 * This should only be used for debugging, do not match on this value!
+	 */
+	public @NonNull String getRejectReasonName() {
+		if (this.rejectReason == null) {
+			return "null";
+		}
+		switch (this.rejectReason) {
+			case RejectReason.UNKNOWN:
+				return "unknown";
+			case RejectReason.BUSY:
+				return "busy";
+			case RejectReason.TIMEOUT:
+				return "timeout";
+			case RejectReason.REJECTED:
+				return "rejected";
+			case RejectReason.DISABLED:
+				return "disabled";
+			case RejectReason.OFF_HOURS:
+				return "off_hours";
+			default:
+				return this.rejectReason.toString();
+		}
 	}
 
 	public @NonNull VoipCallAnswerData setRejectReason(byte rejectReason) {

+ 2 - 2
app/src/main/res/values-cs/poi_strings.xml

@@ -2,14 +2,14 @@
 <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
     <string name="hamlet">Osada</string>
     <string name="village">Vesnice</string>
-    <string name="town">Město</string>
+    <string name="town">Městečko</string>
     <string name="isolated_dwelling">Samota</string>
     <string name="island">Ostrov</string>
     <string name="islet">Ostrůvek</string>
     <string name="suburb">Předměstí</string>
     <string name="city">Město</string>
     <string name="city_block">Městská čtvrť</string>
-    <string name="neighbourhood">Blízké okolí</string>
+    <string name="neighbourhood">Sousedství</string>
     <string name="locality">Lokalita</string>
     <string name="state">Stát</string>
     <string name="farm">Statek</string>

+ 2 - 2
app/src/main/res/values-cs/qrscanner_strings.xml

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="msg_default_status">Pro naskenování QR kódu jej umístěte do obdélníku v hledáčku.</string>
-    <string name="msg_camera_framework_bug">Nelze komunikovat s fotoaparátem. Ujistěte se, že bylo uděleno oprávnění k přístupu ke kameře nebo restartujte zařízení.</string>
+    <string name="msg_default_status">QR kód naskenujete jeho umístěním do čtvercového rámečku.</string>
+    <string name="msg_camera_framework_bug">Nelze získat přístup k fotoaparátu. Ujistěte se, že bylo uděleno oprávnění pro přístup k fotoaparátu nebo zkuste zařízení restartovat.</string>
 </resources>

+ 45 - 47
app/src/main/res/values-cs/strings.xml

@@ -35,17 +35,17 @@
     <string name="prefs_header_chat">Konverzace</string>
     <string name="prefs_header_reset">Obnovení</string>
     <string name="prefs_header_keyboard">Klávesnice</string>
-    <string name="prefs_sum_sync_contacts_on">Udržovat uživatele Threemy synchronizované s adresářem kontaktů ve Vašem zařízení</string>
-    <string name="prefs_sum_sync_contacts_off">Nesynchronizovat uživatele Threemy s adresářem kontaktů ve Vašem zařízení</string>
+    <string name="prefs_sum_sync_contacts_on">Udržovat uživatele Threemy synchronizované s adresářem kontaktů ve vašem zařízení</string>
+    <string name="prefs_sum_sync_contacts_off">Nesynchronizovat uživatele Threemy s adresářem kontaktů ve vašem zařízení</string>
     <string name="prefs_title_sync_contacts">Synchronizace kontaktů</string>
     <string name="prefs_sum_block_unknown_off">Kdokoliv Vám může poslat zprávu. Nové kontakty budou přidány automaticky poté, co od nich obdržíte první zprávu.</string>
-    <string name="prefs_sum_block_unknown_on">Pouze lidé z Vašeho adresáře kontaktů Vám mohou poslat zprávu.</string>
+    <string name="prefs_sum_block_unknown_on">Pouze lidé z vašeho adresáře kontaktů vám mohou poslat zprávu.</string>
     <string name="prefs_title_block_unknown">Blokovat neznámá ID</string>
     <string name="prefs_title_read_receipts">Odesílat potvrzení o přečtení</string>
     <string name="prefs_title_typing_indicator">Odesílat indikaci psaní</string>
     <string name="prefs_media_title">Média a úložiště</string>
     <string name="prefs_sum_media_title">Nastavení médií a úložiště</string>
-    <string name="prefs_image_size">Velikost obrázků</string>
+    <string name="prefs_image_size">Rozměr obrázků</string>
     <string name="prefs_notification_sound">Zvuk oznámení</string>
     <string name="prefs_sum_notification_sound">Výchozí systémový</string>
     <string name="prefs_vibrate">Vibrace</string>
@@ -81,10 +81,10 @@
     <string name="prefs_notification_preview">Zobrazovat náhledy zpráv</string>
     <string name="prefs_sum_reset_ringtones">Obnoví výchozí nastavení</string>
     <string name="prefs_title_reset_ringtones">Obnovit nastavení zvuků oznámení</string>
-    <string name="image_size_small">Malá (640 × 640 px)</string>
+    <string name="image_size_small">Malý (640 × 640 px)</string>
     <string name="image_size_medium">Střední (1024 × 1024 px)</string>
-    <string name="image_size_large">Velká (1600 × 1600 px)</string>
-    <string name="image_size_xlarge">Extra velká (2592 × 2591 px)</string>
+    <string name="image_size_large">Velký (1600 × 1600 px)</string>
+    <string name="image_size_xlarge">Extra velký (2592 × 2592 px)</string>
     <string name="image_size_original">Původní</string>
     <string name="color_none">Žádná</string>
     <string name="color_orange">Oranžová</string>
@@ -98,15 +98,15 @@
     <string name="next">Další</string>
     <string name="finish">Konec</string>
     <string name="please_wait">Čekejte prosím…</string>
-    <string name="wizard_first_create_id">Vytváří se Vaše Threema ID…</string>
+    <string name="wizard_first_create_id">Vytváří se vaše Threema ID…</string>
     <string name="wizard1_sync_contacts">Synchronizují se kontakty…</string>
-    <string name="wizard2_email_hint">Zadejte Vaši emailovou adresu</string>
-    <string name="wizard2_email_linking">Propojení e‑mailu s Vaším ID</string>
-    <string name="wizard2_phone_hint">Zadejte telefonní číslo</string>
+    <string name="wizard2_email_hint">Zadejte svoji e‑mailovou adresu</string>
+    <string name="wizard2_email_linking">Propojení e‑mailu s vaším ID</string>
+    <string name="wizard2_phone_hint">Zadejte svoje telefonní číslo</string>
     <string name="wizard2_phone_number_confirm_title">Potvrďte zadané číslo</string>
     <string name="wizard2_phone_number_confirm">Chystáme se odeslat SMS na číslo:\n\n%1$s\n\nJe to takto v pořádku?</string>
-    <string name="wizard2_phone_linking">Propojení telefonního čísla s Vaším ID</string>
-    <string name="wizard3_nickname_hint">Zadejte Vaši přezdívku</string>
+    <string name="wizard2_phone_linking">Propojení telefonního čísla s vaším ID</string>
+    <string name="wizard3_nickname_hint">Zadejte svoji přezdívku</string>
     <string name="set_nickname_title">Zvolte přezdívku</string>
     <string name="ok">OK</string>
     <string name="cancel">Zrušit</string>
@@ -144,28 +144,26 @@
     <string name="verify_title">Ověřuje se číslo mobilního telefonu</string>
     <string name="verify_success_text">Vaše číslo mobilního telefonu bylo úspěšně ověřeno.</string>
     <string name="verify_failed">Ověření selhalo</string>
-    <string name="verify_failed_summary">Ověření Vašeho čísla mobilního telefonu selhalo. Předtím, než to zkusíte znovu, se prosím ujistěte, že je číslo zadané správně, a že je Vaše zařízení připojeno k mobilní síti.</string>
-    <string name="verify_failed_not_linked">Ověření Vašeho čísla mobilního telefonu selhalo. Proces ověření byl přerušen.</string>
+    <string name="verify_failed_summary">Ověření vašeho čísla mobilního telefonu selhalo. Předtím, než to zkusíte znovu, se prosím ujistěte, že je číslo zadané správně, a že je vaše zařízení připojeno k mobilní síti.</string>
+    <string name="verify_failed_not_linked">Ověření vašeho čísla mobilního telefonu selhalo. Proces ověření byl přerušen.</string>
     <string name="check_incoming_sms">Čeká se na přijetí SMS zprávy</string>
     <string name="backup_title">Vytvoření zálohy ID</string>
-    <string name="backup_sum">Záloha Vašeho Threema ID</string>
+    <string name="backup_sum">Záloha vašeho Threema ID</string>
     <string name="backup_and_delete">Záloha a smazání</string>
     <string name="delete_id_title">Smazat ID</string>
     <string name="delete_id_message">Pokud jste nevytvořili zálohu tohoto ID, nebo jste ho neexportovali a neuložili, nebudete moci již nikdy v budoucnu odesílat ani přijímat zprávy s touto identitou.\n\nJestliže nehodláte toto ID nadále používat, měli byste u něj nejprve zrušit propojení e‑mailové adresy či telefonního čísla dříve, než ho smažete.</string>
-    <string name="delete_id_message2">Poslední varování: Skutečně si z tohoto zařízení přejete odstranit Vaše ID?</string>
-    <string name="delete_id_sum">Trvale z tohoto zařízení odstranit Vaše ID a všechna data aplikace Threema</string>
+    <string name="delete_id_message2">Poslední varování: Skutečně si z tohoto zařízení přejete odstranit svoje ID?</string>
+    <string name="delete_id_sum">Trvale z tohoto zařízení odstraní vaše ID a všechna data aplikace Threema</string>
     <string name="backup_password_summary">Záloha vašeho ID bude zašifrována a chráněna heslem. Použijte kombinaci písmen, čísel a symbolů. Toto heslo nesmíte zapomenout!</string>
     <string name="backup_password_again_summary">Zadejte heslo znovu</string>
     <string name="password_hint">Heslo</string>
     <string name="generating_backup_data">Generují se data zálohy</string>
     <string name="backup_id_title">Záloha vašeho ID</string>
-    <string name="backup_id_summary">Textový řetězec zobrazený výše nebo QR kód, spolu s heslem, které jste zvolili, lze použít k obnovení Vašeho ID na jiném zařízení. Můžete jej zkopírovat na vhodné místo, sdílet jej
-e-mailem nebo skenovat QR kód pomocí jiného zařízení.</string>
+    <string name="backup_id_summary">Textový řetězec zobrazený výše nebo QR kód, společně s heslem, které jste zvolili, lze použít k obnovení vašeho ID na jiném zařízení. Můžete jej zkopírovat na vhodné místo, sdílet jej e‑mailem nebo skenovat QR kód pomocí jiného zařízení.</string>
     <string name="support">Nápověda</string>
     <string name="support_url">https://threema.ch/android/support/</string>
-    <string name="backup_share_content">Následující zálohovaná data lze použít společně s heslem k
-obnově Vašeho Threema ID.</string>
-    <string name="backup_share_subject">Záloha Threema ID pro</string>
+    <string name="backup_share_content">Následující textový řetězec lze použít společně se zvoleným heslem k obnově vašeho Threema ID.</string>
+    <string name="backup_share_subject">Záloha Threema ID pro</string>
     <string name="add_attachment">Nová příloha</string>
     <string name="invalid_passphrase">Neplatné heslo</string>
     <string name="master_key_locked">Hlavní klíč je uzamčen</string>
@@ -173,29 +171,29 @@ obnově Vašeho Threema ID.</string>
     <string name="prefs_masterkey_passphrase">Není nastaveno žádné heslo</string>
     <string name="prefs_title_masterkey_passphrase">Heslo</string>
     <string name="setting_masterkey_passphrase">Zadání hesla hlavního klíče</string>
-    <string name="masterkey_passphrase_title">Heslo k hlavnímu klíči</string>
+    <string name="masterkey_passphrase_title">Heslo k hlavnímu klíči</string>
     <string name="masterkey_passphrase_summary">Zadejte heslo pro ochranu hlavního klíče. Heslo bude nutné zadat při každém restartu aplikace Threema.</string>
     <string name="masterkey_passphrase_again_summary">Zadejte heslo znovu</string>
     <string name="masterkey_passphrase_hint">Heslo</string>
-    <string name="master_key_locked_want_exit">Hlavní klíč je stále uzamčen. Přejete si další pokus?</string>
-    <string name="click_here_to_change_passphrase">Klepnutím sem změňte heslo</string>
+    <string name="master_key_locked_want_exit">Hlavní klíč je stále uzamčen. Přejete si zkusit to znovu?</string>
+    <string name="click_here_to_change_passphrase">Klepnutím sem změte heslo</string>
     <string name="attach_camera">Fotoaparát</string>
     <string name="menu_restore">Obnovit ze zálohy</string>
-    <string name="restore_id_hint">Zadejte nebo zkopírujte Váš textový řetězec k ID</string>
+    <string name="restore_id_hint">Obnovení provedete zadáním nebo vložením textového řetězce zálohy ID</string>
     <string name="location_placeholder">Poloha</string>
     <string name="video_placeholder">Video</string>
     <string name="audio_placeholder">Audio soubory</string>
-    <string name="restoring_backup">Obnovení ze zálohy</string>
+    <string name="restoring_backup">Obnovuje se záloha</string>
     <string name="server_message_title">Zpráva od Serveru</string>
     <string name="error">Chyba</string>
     <string name="no_contacts"><![CDATA[Nemáte dosud žádné kontakty. Zapněte synchronizaci (Nastavení> Soukromí) nebo raději přidávájte kontakty ručně.]]></string>
     <string name="masterkey_title">Zadejte heslo</string>
     <string name="masterkey_body">Váš hlavní Threema klíč je chráněn heslem. Zadejte heslo pro jeho odemknutí.</string>
     <string name="masterkey_unlocking">Odemknutí hlavního klíče</string>
-    <string name="verify_phonecall_text">Požádejte o hovor</string>
+    <string name="verify_phonecall_text">Požádat o hovor</string>
     <string name="prepare_call_message">Budete-li pokračovat, pokusíme se Vám okamžitě zavolat. Váš ověřovací kód vám bude nadiktován dvakrát. Budete mít pouze jeden pokus, proto prosím, ujistěte se, že jste připraveni (máte tužku a papír.)</string>
     <string name="enter_code_hint">Napište kód</string>
-    <string name="enter_code_sum">Zadejte prosím kód z ověřovací SMS nebo telefonního hovoru.</string>
+    <string name="enter_code_sum">Zadejte prosím kód z ověřovací SMS nebo z telefonního hovoru.</string>
     <string name="no_matching_contacts">Kontakt nenalezen</string>
     <string name="code_invalid">Ověřovací kód není správný.</string>
     <string name="try_again">Další pokus</string>
@@ -338,7 +336,7 @@ klíč uzamčen</string>
     <string name="synchronize_contact">Svázáno s Android kontaktem</string>
     <string name="exclude_contact">Vyloučené z autom. synchronizace</string>
     <string name="prefs_header_lists">Seznamy</string>
-    <string name="prefs_title_black_list">Ignorovaná</string>
+    <string name="prefs_title_black_list">Ignorovaná ID</string>
     <string name="prefs_sum_black_list">Zprávy od zde uvedených ID budou ignorovány.</string>
     <string name="verified">Ověřený</string>
     <string name="want_to_add_to_exclude_list">Tento kontakt je propojen s adresářem telefonu. Jestli ho smažete, v Threema se objeví znovu po synchronizaci kontaktů.\nChcete ho vyloučit ze synchronizace?</string>
@@ -404,7 +402,7 @@ klíč uzamčen</string>
     <string name="info">Informace</string>
     <string name="resync_group">Resync skupiny</string>
     <string name="edit_name">Uprav jméno a obrázek</string>
-    <string name="edit_name_only">Uprav jméno</string>
+    <string name="edit_name_only">Úprava jména</string>
     <string name="group_was_synchronized">Synchronizováno</string>
     <string name="verification_level2_work_explain">"Interní kontakt, předem obsazený vaší organizací.
 "</string>
@@ -416,7 +414,7 @@ klíč uzamčen</string>
     <string name="prefs_title_hide_screenshots">Zakázat snímky obrazovky a náhledy</string>
     <string name="prefs_summary_hide_screenshots">"Nezobrazuj náhledy ve správci běžících aplikací a zakaž ukládání obrazovek s texty. "</string>
     <string name="media_gallery">Galerie médií</string>
-    <string name="media_file_not_found">Nelze otevřít médiální soubor. Byl odstraněn nebo nebyl stažen ze serveru.</string>
+    <string name="media_file_not_found">Mediální soubor nelze otevřít. Buď byl odstraněn nebo nebyl stažen ze serveru.</string>
     <string name="no_media_found">V této konverzaci nebyly nalezeny %s.</string>
     <string name="media_gallery_all">Vše</string>
     <string name="media_gallery_pictures">Obrázky</string>
@@ -560,10 +558,10 @@ Např. v případě ztráty mobilu, odcizení ID.</string>
     <string name="storage_explain">Pokud máte nedostatek volného místa, můžete odstranit starší šifrované soubory. Jejich náhledy zůstanou zachovány.</string>
     <string name="delete_data">Smazat data</string>
     <string name="delete_date_confirm_message">Pokud budete pokračovat, soubory budou smazány a zůstanou pouze jejich miniaturní náhledy.</string>
-    <string name="media_files_deleted" tools:ignore="PluralsCandidate">%d médiální soubory smazány.</string>
+    <string name="media_files_deleted" tools:ignore="PluralsCandidate">Mediálních souborů smazáno: %d</string>
     <string name="storage_management">Správa úložiště</string>
     <string name="media">Média</string>
-    <string name="prefs_storage_mgmt_title">Promazání zpráv adiálních souborů</string>
+    <string name="prefs_storage_mgmt_title">Promazání zpráv a mediálních souborů</string>
     <string name="num_messages">Počet zpráv</string>
     <string name="delete_messages_explain">Smazat zprávy starší než:</string>
     <string name="delete_message">Smazat zprávy</string>
@@ -603,7 +601,7 @@ možné je obnovit.</string>
     <string name="message_declined">Nesouhlas odeslán</string>
     <string name="notifications_settings">Nastavení upozornění</string>
     <string name="notifications_default">Systémové nastavení</string>
-    <string name="notifications_for_x_hours" tools:ignore="PluralsCandidate">Na %d hodin(u)</string>
+    <string name="notifications_for_x_hours" tools:ignore="PluralsCandidate">Po dobu %d h</string>
     <string name="notifications_until">Do %s</string>
     <string name="notifications_mute">Žádný</string>
     <string name="notifications_choose_sound">Změna tónu</string>
@@ -651,7 +649,7 @@ můžete tento krok přeskočit.</string>
 zadat pouze vaše křestní jméno nebo pseudonym. Pokud nenastavíte žádnou přezdívku, použijeme ve výchozím nastavení vaše Threema ID. Toto lze kdykoliv změnit.</string>
     <string name="not_linked">nepropojené</string>
     <string name="linked">propojené</string>
-    <string name="pending_sms_verification_notice">Vaše číslo mobilního telefonu není zatím ověřeno.</string>
+    <string name="pending_sms_verification_notice">Vaše číslo mobilního telefonu zatím nebylo ověřeno.</string>
     <string name="no_sms_received">Neobdrželi jste SMS?</string>
     <string name="really_cancel_verify">Skutečně si přejete přerušit proces ověření mobilního čísla?</string>
     <string name="verification_of">Ověření z %s</string>
@@ -753,8 +751,8 @@ zadat pouze vaše křestní jméno nebo pseudonym. Pokud nenastavíte žádnou p
     <string name="battery_optimizations_disable_confirm">Skutečně si přejete pro aplikaci %1$s ponechat optimalizaci baterie zapnutou? %2$s nebude pracovat správně.</string>
     <string name="enter_text_hint">Vložte text</string>
     <string name="backup_explain_text">Pokud se mobil rozbije nebo ho ztratíte, nikdo již nedokáže obnovit Vaše Threema ID ani vlastní konverzace bez záložních dat. Pravidelně zálohujte a ukládejte zálohovaná data na bezpečné místo.</string>
-    <string name="data_backup_explain">Zálohovaná data obsahují \n\n&#9679; Vaše ID a šifrovací klíče \n&#9679; Kontakty a jejich úrovně ověření \n&#9679; Členství ve skupinách \n&#9679; Chaty \n&#9679; Obrázky a jiné soubory (volitelně)\n\nData budou uložena do šifrovaného souboru ZIP. Po dokončení zálohy, je nutné, překopírovat soubor mimo zařízení.</string>
-    <string name="draw">Kreslit</string>
+    <string name="data_backup_explain">Zálohovaná data obsahují: \n\n&#9679; Vaše ID a šifrovací klíče \n&#9679; Kontakty a jejich úrovně ověření \n&#9679; Členství ve skupinách \n&#9679; Konverzace \n&#9679; Obrázky a jiné soubory (volitelné)\n\nData budou uložena do šifrovaného souboru ZIP. Po úspěšném vytvoření zálohy doporučujeme tento soubor překopírovat do jiného zařízení.</string>
+    <string name="draw">Editace</string>
     <string name="edit">Upravit</string>
     <string name="discard_changes">Chcete vše odstranit?</string>
     <string name="prefs_title_network">Síť</string>
@@ -787,7 +785,7 @@ zadat pouze vaše křestní jméno nebo pseudonym. Pokud nenastavíte žádnou p
     <string name="prefs_sum_receive_profilepics_recipients_list">Kontakty vybrané v tomto seznamu obdrží Váš profilový obrázek společně s první zprávou, kterou jim odešlete.</string>
     <string name="menu_send_profilpic">Přidat mezi příjemce profilového obrázku</string>
     <string name="menu_send_profilpic_off">Odebrat z příjemců profilového obrázku</string>
-    <string name="menu_send_profilpic_now">Odeslat profilový obrázek nyní</string>
+    <string name="menu_send_profilpic_now">Odeslat profilový obrázek</string>
     <string name="profile_picture_sent">Profilový obrázek byl odeslán</string>
     <string name="sending_messages">Odesílám…</string>
     <string name="backup_data_media_confirm">Uložení velkých mediálních souborů do lokální zálohy ZIP může překročit kapacitu úložiště vašeho zařízení. Také počítejte, že záloha poběží i desítky minut. Threema během zálohování nebude odesílat ani přijímat zprávy. Přesto pokračovat?</string>
@@ -828,8 +826,8 @@ zadat pouze vaše křestní jméno nebo pseudonym. Pokud nenastavíte žádnou p
     <string name="notification_priority_high">Střední</string>
     <string name="notification_priority_max">Maximální</string>
     <string name="prefs_title_notification_priority">Priorita</string>
-    <string name="pin">Označit</string>
-    <string name="unpin">Odznačeno</string>
+    <string name="pin">Připnout</string>
+    <string name="unpin">Odepnout</string>
     <string name="location_services_disabled">Služba určování polohy je zakázána. Chcete ji povolit?</string>
     <string name="send_location">Odeslání polohy</string>
     <string name="unknown_address">Neznámá adresa</string>
@@ -868,7 +866,7 @@ zadat pouze vaše křestní jméno nebo pseudonym. Pokud nenastavíte žádnou p
     <string name="prefs_title_accept_privacy_policy">Přijmout zásady ochrany osobních dat</string>
     <string name="privacy_policy_explain">%1$s chrání vaše soukromí přísněji než kterýkoli jiný komunikátor. Zjistěte více v našem %2$s.</string>
     <string name="privacy_policy_check_confirm">Před použitím %s prosím odsouhlaste Zásady ochrany osobních údajů.\n\n(Vyžadováno směrnicí EU 2016/679.)</string>
-    <string name="prefs_title_incognito_keyboard">Požádejte o inkognito klávesnici</string>
+    <string name="prefs_title_incognito_keyboard">Požádat o inkognito klávesnici</string>
     <string name="prefs_sum_incognito_keyboard">Zakázat sběr dat pro personalizované návrhy (pokud je funkce podporována klávesnicí)</string>
     <string name="tooltip_mentions">Zadejte na klávesnici znak @, pro výběr člena skupiny jako adresáta nebo pro jeho zmínění v textu.</string>
     <string name="tooltip_imagepaint">Buďte kreativní! Klepnutím na tlačítko kouzelné hůlky můžete před odesláním přidat samolepku i text do obrázku.</string>
@@ -1010,12 +1008,12 @@ zadat pouze vaše křestní jméno nebo pseudonym. Pokud nenastavíte žádnou p
     <string name="data_backup_save_path">Úložiště zálohy</string>
     <string name="change">Změnit</string>
     <string name="data_backup_last_date">Poslední úspěšná záloha</string>
-    <string name="archived">Archivováno</string>
+    <string name="archived">Archivované</string>
     <string name="to_archive">Archivovat</string>
     <string name="message_archived">Archivováno konverzací: %d</string>
-    <string name="archived_chats">Archiv konverzací</string>
-    <string name="unarchive">Odarchivování</string>
-    <string name="no_archived_chats">Nemáte žádné archivované chaty.\n\nArchivaci provedete posunem prstu vlevo na seznamu zpráv</string>
+    <string name="archived_chats">Archivované konverzace</string>
+    <string name="unarchive">Zrušit archivování</string>
+    <string name="no_archived_chats">Nemáte žádné archivované konverzace.\n\nArchivaci konverzace provedete na seznamu konverzací jejím odsunutím doleva</string>
     <string name="add_contact_enter_id_hint">Zadejte Threema ID kontaktu, který chcete přidat</string>
     <string name="notification_channel_new_contact">Nové kontakty</string>
     <string name="notification_channel_new_contact_desc">Oznámení o nových kontaktech</string>

+ 5 - 5
app/src/main/res/values-cs/voicemessage_strings.xml

@@ -1,10 +1,10 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="recording">Nahrávám zprávu</string>
-    <string name="recording_stopped_title">Záznam byl zastaven</string>
-    <string name="recording_stopped_message">Chcete nyní poslat nahranou hlasovou zprávu?</string>
+    <string name="recording">Zpráva se nahrává</string>
+    <string name="recording_stopped_title">Nahrávání bylo zastaveno</string>
+    <string name="recording_stopped_message">Přejete si nyní odeslat nahranou hlasovou zprávu?</string>
     <string name="recording_canceled">Nahrávání bylo zrušeno kvůli neznámé chybě.</string>
     <string name="cancel_recording">Zrušit nahrávání</string>
-    <string name="cancel_recording_message">Opravdu chcete zrušit nahrávání?</string>
-    <string name="stop">Stop</string>
+    <string name="cancel_recording_message">Opravdu chcete nahrávání zrušit?</string>
+    <string name="stop">Zastavit</string>
 </resources>

+ 32 - 32
app/src/main/res/values-cs/voip_strings.xml

@@ -2,34 +2,34 @@
 <resources>
     <string name="voip_title">Threema volání</string>
     <string name="voip_hangup">Zavěsit</string>
-    <string name="voip_toggle_mic">Přepněte mikrofon</string>
-    <string name="voip_toggle_speaker">Přepněte reproduktor</string>
-    <string name="voip_switch_cam">Přepnout kameru</string>
-    <string name="voip_switch_cam_front">Přepnuto na přední kameru</string>
-    <string name="voip_switch_cam_rear">Přepnuto na zadní kameru</string>
+    <string name="voip_toggle_mic">Přepnout mikrofon</string>
+    <string name="voip_toggle_speaker">Přepnout reproduktor</string>
+    <string name="voip_switch_cam">Přepnout fotoaparát</string>
+    <string name="voip_switch_cam_front">Přepnuto na přední fotoaparát</string>
+    <string name="voip_switch_cam_rear">Přepnuto na zadní fotoaparát</string>
     <string name="voip_toggle_video">Přepnout režim videa</string>
-    <string name="voip_call_confirm">Chcete zavolat %1$s?</string>
-    <string name="voip_error_call">Během hovoru Threema došlo k chybě</string>
-    <string name="voip_error_init_call">Při inicializaci hovoru došlo k chybě</string>
+    <string name="voip_call_confirm">Chcete zavolat kontaktu %1$s?</string>
+    <string name="voip_error_call">Během Threema volání došlo k chybě</string>
+    <string name="voip_error_init_call">Při inicializaci volání došlo k chybě</string>
     <string name="voip_notification_title">Příchozí Threema volání</string>
-    <string name="voip_notification_text">%1$s volá</string>
+    <string name="voip_notification_text">Kontakt %1$s volá</string>
     <!-- Shown when starting a call, before the peer device is ringing -->
-    <string name="voip_status_initializing">Inicializovat</string>
+    <string name="voip_status_initializing">Zahajování</string>
     <!-- Shown when the callee has accepted the call and the connection is being established -->
-    <string name="voip_status_connecting">Spojuji</string>
+    <string name="voip_status_connecting">Spojování</string>
     <!-- Shown when a call is being disconnected -->
-    <string name="voip_status_disconnecting">Přerušuji</string>
+    <string name="voip_status_disconnecting">Odpojování</string>
     <!-- Shown when the device of the callee is ringing -->
     <string name="voip_status_ringing">Vyzvánění</string>
-    <string name="voip_mic_enable">Zapnout mikrofon</string>
-    <string name="voip_mic_disable">Vypnout mikrofon</string>
-    <string name="voip_checking_compatibility">Zjišťuji, zda tento kontakt umí přijmout Threema volání</string>
-    <string name="voip_incompatible">Tento kontakt není připraven na Threema volání.</string>
-    <string name="voip_call_status_unavailable">Příjemce hovoru není k dispozici</string>
-    <string name="voip_call_status_rejected">Hovor odmítnut</string>
+    <string name="voip_mic_enable">Povolit mikrofon</string>
+    <string name="voip_mic_disable">Zakázat mikrofon</string>
+    <string name="voip_checking_compatibility">Ověřuje se, zda tento kontakt může přijmout Threema volání</string>
+    <string name="voip_incompatible">Tento kontakt nyní nemůže přijmout Threema volání.</string>
+    <string name="voip_call_status_unavailable">Příjemce hovoru není dostupný</string>
+    <string name="voip_call_status_rejected">Hovor byl odmítnut</string>
     <string name="voip_call_status_busy">Příjemce hovoru je zaneprázdněn</string>
     <string name="voip_call_status_busy_short">Obsazeno</string>
-    <string name="voip_call_status_disabled">Příjemce má hovory Threema vypnuté</string>
+    <string name="voip_call_status_disabled">Příjemce má zakázané Threema volání</string>
     <string name="voip_call_status_missed">Zmeškaný hovor</string>
     <string name="voip_call_finished_outbox">Odchozí hovor</string>
     <string name="voip_call_finished_inbox">Příchozí hovor</string>
@@ -43,25 +43,25 @@
     <string name="voip_bluetooth">Bluetooth</string>
     <string name="voip_none">Nedostupný</string>
     <string name="voip_call_finished">Threema volání bylo ukončeno</string>
-    <string name="voip_another_call">Nelze se spojit. Probíhá jiný Threema hovor.</string>
+    <string name="voip_another_call">Nelze se spojit. Probíhá jiné Threema volání.</string>
     <string name="voip_prefs_title_aec">Potlačení ozvěny</string>
     <string name="voip_prefs_aec_sw">Softwarové potlačení ozvěny</string>
     <string name="voip_prefs_aec_hw">Hardwarové potlačení ozvěny</string>
-    <string name="voip_connection_failed">Spojení nebylo navázáno.</string>
+    <string name="voip_connection_failed">Nepodařilo se navázat spojení</string>
     <string name="voip_connection_lost">Ztráta spojení</string>
-    <string name="prefs_voip_reject_incoming_calls_title">Zamítnout mobilní hovor</string>
-    <string name="prefs_voip_reject_incoming_calls_summary">Odmítnout příchozí mobilní volání, pokud již probíhá Threema hovor.</string>
-    <string name="voip_contact_not_found">Pro toto číslo nebyl nalezen Threema kontakt.</string>
-    <string name="voip_another_pstn_call">Nelze vytvořit hovor. Běžný telefonní hovor je stále aktivní.</string>
-    <string name="voip_call_status_off_hours">Mimo pracovní dobu</string>
-    <string name="voip_peer_video_disabled">Videohovory byly druhou stranou deaktivovány.</string>
+    <string name="prefs_voip_reject_incoming_calls_title">Odmítat mobilní hovory</string>
+    <string name="prefs_voip_reject_incoming_calls_summary">Odmítat příchozí mobilní hovory, pokud již probíhá Threema volání.</string>
+    <string name="voip_contact_not_found">Pod tímto číslem nebyl nalezen žádný Threema kontakt.</string>
+    <string name="voip_another_pstn_call">Hovor nelze zahájit. Stále probíhá běžný telefonní hovor.</string>
+    <string name="voip_call_status_off_hours">Volání mimo pracovní dobu</string>
+    <string name="voip_peer_video_disabled">Videohovory jsou u protistrany zakázány.</string>
     <!-- WebRTC debugger -->
     <string name="voip_prefs_webrtc_debug">Diagnostika WebRTC</string>
-    <string name="voip_prefs_webrtc_debug_summary">Spusťte tento nástroj pro odhalení problémů s nastavením hlasového volání</string>
-    <string name="voip_webrtc_debug">WebRTC Diagnostika</string>
-    <string name="voip_webrtc_debug_intro">Stiskněte \"Start\" pro zahájení testu.</string>
-    <string name="voip_webrtc_debug_start">Start</string>
-    <string name="voip_webrtc_debug_done">Hotovo. Pokud řešíte problémy s funkcí Threema volání, odešlete prosím tento výstup na podporu Threema.</string>
+    <string name="voip_prefs_webrtc_debug_summary">Spuštění tohoto nástroje vám umožní hledat zdroj problémů s nastavením spojení hlasového volání</string>
+    <string name="voip_webrtc_debug">Diagnostika WebRTC</string>
+    <string name="voip_webrtc_debug_intro">Stiskem tlačítka „Spustit“ zahájíte test.</string>
+    <string name="voip_webrtc_debug_start">Spustit</string>
+    <string name="voip_webrtc_debug_done">Hotovo. Pokud zaznamenáváte problémy s navázáním spojení hovoru, odešlete prosím tento výstup podpoře Threema.</string>
     <string name="voip_webrtc_debug_copied">Zkopírováno do schránky.</string>
     <string name="voip_webrtc_debug_copy_clipboard">Zkopírovat do schránky</string>
 </resources>

+ 3 - 0
app/src/main/res/values/restrictions_strings.xml

@@ -41,6 +41,7 @@
 	<string name="restriction__safe_password_pattern">th_safe_password_pattern</string>
 	<string name="restriction__safe_password_message">th_safe_password_message</string>
 	<string name="restriction__disable_video_calls">th_disable_video_calls</string>
+	<string name="restriction__disable_work_directory">th_disable_work_directory</string>
 
 	<!-- strings for policy controller -->
 	<string name="restriction_license_username">Corporate License Username</string>
@@ -123,4 +124,6 @@
 	<string name="restriction_safe_password_message_desc">Error message that is shown if the password that the user has chosen for Threema Safe does not match the pattern in th_safe_password_pattern</string>
 	<string name="restriction_disable_video_calls">Disable video calls</string>
 	<string name="restriction_disable_video_calls_desc">Disable both incoming and outgoing Threema video calls</string>
+	<string name="restriction_disable_work_directory">Disable Work directory</string>
+	<string name="restriction_disable_work_directory_desc">Hide option for Work directory</string>
 </resources>

+ 7 - 0
app/src/store_google_work/res/xml/app_restrictions.xml

@@ -278,4 +278,11 @@
 		android:restrictionType="bool"
 		android:title="@string/restriction_disable_video_calls"/>
 
+	<restriction
+		android:defaultValue="false"
+		android:description="@string/restriction_disable_work_directory_desc"
+		android:key="th_disable_work_directory"
+		android:restrictionType="bool"
+		android:title="@string/restriction_disable_work_directory"/>
+
 </restrictions>

+ 54 - 0
app/src/test/java/ch/threema/app/voip/ConfigTest.java

@@ -0,0 +1,54 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2020-2021 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.voip;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class ConfigTest {
+	@Test
+	public void testAllowHardwareVideoCodec() {
+		// Allow if exclusion list is empty
+		assertTrue(Config.allowHardwareVideoCodec(new String[] { }, "Samsung;SM-A320FL;8.0.0"));
+
+		// Deny if exclusion list contains model
+		assertFalse(Config.allowHardwareVideoCodec(new String[] { "Samsung;SM-A320FL;8" }, "Samsung;SM-A320FL;8.0.0"));
+
+		// Compare android major version
+		assertTrue(Config.allowHardwareVideoCodec(new String[] { "Samsung;SM-A320FL;8" }, "Samsung;SM-A320FL;9.0.0"));
+		assertFalse(Config.allowHardwareVideoCodec(new String[] { "Samsung;SM-A320FL;8" }, "Samsung;SM-A320FL;8.1.2"));
+
+		// Compare model name
+		assertTrue(Config.allowHardwareVideoCodec(new String[] { "Samsung;SM-A320FL;8" }, "Samsung;XX-A320FL;8.0.0"));
+
+		// Compare manufacturer
+		assertTrue(Config.allowHardwareVideoCodec(new String[] { "Fairphone;SM-A320FL;8" }, "Samsung;SM-A320FL;8.0.0"));
+
+		// Comparison is case insensitive
+		assertFalse(Config.allowHardwareVideoCodec(new String[] { "Samsung;SM-A320FL;8" }, "samsung;sm-A320FL;8.0.0"));
+
+		// Compare with every entry in the list
+		assertFalse(Config.allowHardwareVideoCodec(new String[] { "Samsung;SM-A320FL;8", "Fairphone;FP2;8" }, "Fairphone;FP2;8.0.1"));
+	}
+}

+ 4 - 0
scripts/Dockerfile

@@ -46,3 +46,7 @@ ENV PATH="$ANDROID_SDK_ROOT/cmdline-tools/tools/bin:$ANDROID_SDK_ROOT/platform-t
 
 # Create users with typical UIDs to avoid problems with Docker when remapping the UID
 RUN chmod a+w /home && for newuid in $(seq 1000 1010); do useradd -M -d /home  -u $newuid -s /bin/bash "user$newuid"; done
+
+# Create cache directories in order to be able to control the permissions of mounted volumes
+RUN mkdir -p /code/build /code/app/.cxx \
+ && chmod 777 /code/build /code/app/.cxx

+ 4 - 1
scripts/build-release.sh

@@ -173,7 +173,10 @@ for variant in "${variant_array[@]}"; do
     log_major "Building gradle target $target"
     run_command="docker run --rm -ti"
     run_command+=" -u \"$(id -u):$(id -g)\""
-    run_command+=" -v \"$DIR/..:/code\""
+    run_command+=" -v \"$DIR/..\":/code"
+    run_command+=" -v /dev/null:/code/local.properties"  # Mask local.properties file
+    run_command+=" -v /code/build/"  # Mask root build directory
+    run_command+=" -v /code/app/.cxx/"  # Mask ndk build directory
     if [ "$keystore" != "" ]; then
         log_minor "Using keystore at $keystore"
         keystore_realpath=$(realpath "$keystore")

+ 7 - 1
scripts/verify-build.sh

@@ -1,6 +1,12 @@
 #!/usr/bin/env bash
 #
 # A script to verify that a locally compiled APK matches the released APK.
+#
+# Steps taken to achieve this:
+#
+#   1. Unpack both APK files
+#   2. Remove meta information (containing things like the signature)
+#   3. Recursively diff the two directories to ensure they match
 #  _____ _
 # |_   _| |_  _ _ ___ ___ _ __  __ _
 #   | | | ' \| '_/ -_) -_) '  \/ _` |_
@@ -155,7 +161,7 @@ log_major "Unpacking local APK"
 unzip -q -d "$targetdir/local/" "$local_apk"
 
 # Remove meta information (containing things like the signature)
-log_major "Removing variable files:"
+log_major "Removing meta information, containing things like the app signature:"
 for path in META-INF/ resources.arsc; do
     for target in local published; do
         log_minor "rm -r $target/$path"