Threema il y a 4 ans
Parent
commit
81889c938c

+ 2 - 2
app/build.gradle

@@ -20,7 +20,7 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 
 // version codes
-def app_version = "4.61"
+def app_version = "4.62"
 def beta_suffix = "" // with leading dash
 
 /**
@@ -99,7 +99,7 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 708
+        versionCode 709
         versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter

+ 0 - 3
app/src/main/AndroidManifest.xml

@@ -55,9 +55,6 @@
 	<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
 	<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
 
-	<!-- To install updates - Threema Shop version only -->
-	<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
-
 	<!-- Access to address book -->
 	<uses-permission android:name="android.permission.MANAGE_ACCOUNTS"/>
 	<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS"/>

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

@@ -36,6 +36,7 @@ import android.widget.Toast;
 import com.google.android.material.snackbar.BaseTransientBottomBar;
 import com.google.android.material.snackbar.Snackbar;
 
+import java.io.IOException;
 import java.util.Date;
 
 import androidx.annotation.NonNull;
@@ -60,6 +61,9 @@ import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.QRScannerUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.webclient.services.QRCodeParser;
+import ch.threema.app.webclient.services.QRCodeParserImpl;
+import ch.threema.base.utils.Base64;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 
@@ -71,6 +75,7 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 	private static final String DIALOG_TAG_ADD_USER = "au";
 	private static final String DIALOG_TAG_ADD_BY_ID = "abi";
 	public static final String EXTRA_ADD_BY_ID = "add_by_id";
+	public static final String EXTRA_ADD_BY_QR = "add_by_qr";
 	public static final String EXTRA_QR_RESULT = "qr_result";
 
 	private static final int PERMISSION_REQUEST_CAMERA = 1;
@@ -145,6 +150,10 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 				parseQrResult(intent.getStringExtra(EXTRA_QR_RESULT));
 			}
 
+			if (intent.getBooleanExtra(EXTRA_ADD_BY_QR, false)) {
+				scanQR();
+			}
+
 			if (intent.getBooleanExtra(EXTRA_ADD_BY_ID, false)) {
 				requestID();
 			}
@@ -336,7 +345,11 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 
 	private void scanQR() {
 		if (ConfigUtils.requestCameraPermissions(this, null, PERMISSION_REQUEST_CAMERA)) {
-			QRScannerUtil.getInstance().initiateGeneralThreemaQrScanner(this, getString(R.string.qr_scanner_id_hint));
+			if (ConfigUtils.supportsGroupLinks()) {
+				QRScannerUtil.getInstance().initiateGeneralThreemaQrScanner(this, getString(R.string.qr_scanner_id_hint));
+			} else {
+				QRScannerUtil.getInstance().initiateScan(this, false, null);
+			}
 		}
 	}
 
@@ -349,6 +362,72 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 		dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_ADD_BY_ID);
 	}
 
+	@Override
+	public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+		super.onActivityResult(requestCode, resultCode, intent);
+
+		ConfigUtils.setLocaleOverride(this, serviceManager.getPreferenceService());
+
+		if (resultCode == RESULT_OK) {
+			String payload = QRScannerUtil.getInstance().parseActivityResult(this, requestCode, resultCode, intent);
+
+			if (!TestUtil.empty(payload)) {
+
+				// first: try to parse as content result (contact scan)
+				QRCodeService.QRCodeContentResult contactQRCode = this.qrCodeService.getResult(payload);
+
+				if (contactQRCode != null) {
+					// ok, try to add contact
+					if (contactQRCode.getExpirationDate() != null
+							&& contactQRCode.getExpirationDate().before(new Date())) {
+						GenericAlertDialog.newInstance(R.string.title_adduser, getString(R.string.expired_barcode), R.string.ok, 0).show(getSupportFragmentManager(), "ex");
+					} else {
+						addContactByQRResult(contactQRCode);
+					}
+
+					// return, qr code valid and exit method
+					DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_BY_ID, true);
+					return;
+				}
+
+				// second: try uri scheme
+				String scannedIdentity = null;
+				Uri uri = Uri.parse(payload);
+				if (uri != null) {
+					String scheme = uri.getScheme();
+					if (BuildConfig.uriScheme.equals(scheme) && "add".equals(uri.getAuthority())) {
+						scannedIdentity = uri.getQueryParameter("id");
+					} else if ("https".equals(scheme) && BuildConfig.contactActionUrl.equals(uri.getHost())) {
+						scannedIdentity = uri.getLastPathSegment();
+					}
+
+					if (scannedIdentity != null && scannedIdentity.length() == IDENTITY_LEN) {
+						addContactByIdentity(scannedIdentity);
+						return;
+					}
+				}
+
+				// third: try to parse as web client qr
+				try {
+					byte[] base64Payload = Base64.decode(payload);
+					if (base64Payload != null) {
+						final QRCodeParser webClientQRCodeParser = new QRCodeParserImpl();
+						webClientQRCodeParser.parse(base64Payload); // throws if QR is not valid
+						// it was a valid web client qr code, exit method
+					//	startWebClientByQRResult(base64Payload);
+						return;
+					}
+				} catch (IOException | QRCodeParser.InvalidQrCodeException x) {
+					// not a valid base64 or web client payload
+					// ignore and continue
+				}
+			}
+			Toast.makeText(this, R.string.invalid_barcode, Toast.LENGTH_SHORT).show();
+		}
+
+		finish();
+	}
+
 	@Override
 	public void onYes(String tag, Object data) {
 		finish();

+ 2 - 1
app/src/main/java/ch/threema/app/backuprestore/csv/BackupRestoreDataServiceImpl.java

@@ -40,7 +40,7 @@ import ch.threema.app.services.FileService;
 import ch.threema.base.ThreemaException;
 
 public class BackupRestoreDataServiceImpl implements BackupRestoreDataService {
-	private static final Logger logger = LoggerFactory.getLogger(BackupRestoreDataServiceImpl.class);
+	private static final Logger logger = LoggerFactory.getLogger("BackupRestoreDataServiceImpl");
 
 	private final Context context;
 	private final FileService fileService;
@@ -55,6 +55,7 @@ public class BackupRestoreDataServiceImpl implements BackupRestoreDataService {
 
 	@Override
 	public boolean deleteBackup(BackupData backupData) throws IOException, ThreemaException {
+		logger.info("Deleting backup");
 		this.fileService.remove(backupData.getFile(), true);
 		return true;
 	}

+ 29 - 15
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -117,7 +117,7 @@ import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_A
 import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS;
 
 public class RestoreService extends Service {
-	private static final Logger logger = LoggerFactory.getLogger(RestoreService.class);
+	private static final Logger logger = LoggerFactory.getLogger("RestoreService");
 
 	public static final String EXTRA_RESTORE_BACKUP_FILE = "file";
 	public static final String EXTRA_RESTORE_BACKUP_PASSWORD = "pwd";
@@ -255,7 +255,7 @@ public class RestoreService extends Service {
 
 	@Override
 	public void onCreate() {
-		logger.debug("onCreate");
+		logger.info("onCreate");
 
 		super.onCreate();
 
@@ -276,7 +276,7 @@ public class RestoreService extends Service {
 			preferenceService = serviceManager.getPreferenceService();
 			threemaConnection = serviceManager.getConnection();
 		} catch (Exception e) {
-			logger.error("Exception", e);
+			logger.error("Could not instantiate all required services", e);
 			stopSelf();
 			return;
 		}
@@ -286,7 +286,7 @@ public class RestoreService extends Service {
 
 	@Override
 	public void onDestroy() {
-		logger.debug("onDestroy success = " + restoreSuccess + " canceled = " + isCanceled);
+		logger.info("onDestroy success = {} cancelled = {}", restoreSuccess, isCanceled);
 
 		if (isCanceled) {
 			onFinished(getString(R.string.restore_data_cancelled));
@@ -297,13 +297,13 @@ public class RestoreService extends Service {
 
 	@Override
 	public void onLowMemory() {
-		logger.debug("onLowMemory");
+		logger.info("onLowMemory");
 		super.onLowMemory();
 	}
 
 	@Override
 	public void onTaskRemoved(Intent rootIntent) {
-		logger.debug("onTaskRemoved");
+		logger.info("onTaskRemoved");
 
 		Intent intent = new Intent(this, DummyActivity.class);
 		intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
@@ -369,6 +369,8 @@ public class RestoreService extends Service {
 	private boolean writeToDb = false;
 
 	public boolean restore() {
+		logger.info("Restoring data backup");
+
 		int mediaCount;
 		int messageCount;
 		String message;
@@ -384,6 +386,7 @@ public class RestoreService extends Service {
 		try {
 			// we use two passes for a restore
 			for (int nTry = 0; nTry < 2; nTry++) {
+				logger.info("Attempt {}", nTry + 1);
 				if (nTry > 0) {
 					this.writeToDb = true;
 					this.initProgress(stepSizeTotal);
@@ -404,6 +407,7 @@ public class RestoreService extends Service {
 					*/
 
 					//clear tables!!
+					logger.info("Clearing current tables");
 					databaseServiceNew.getMessageModelFactory().deleteAll();
 					databaseServiceNew.getContactModelFactory().deleteAll();
 					databaseServiceNew.getGroupMessageModelFactory().deleteAll();
@@ -419,6 +423,7 @@ public class RestoreService extends Service {
 					databaseServiceNew.getGroupRequestSyncLogModelFactory().deleteAll();
 
 					//remove all media files (don't remove recursive, tmp folder contain the restoring files
+					logger.info("Deleting current media files");
 					fileService.clearDirectory(fileService.getAppDataPath(), false);
 				}
 
@@ -442,14 +447,14 @@ public class RestoreService extends Service {
 					}
 				}
 
-				//try to restore the identity
+				// Restore the identity
+				logger.info("Restoring identity");
 				FileHeader identityHeader = Functional.select(fileHeaders, new IPredicateNonNull<FileHeader>() {
 					@Override
 					public boolean apply(@NonNull FileHeader type) {
 						return TestUtil.compare(type.getFileName(), Tags.IDENTITY_FILE_NAME);
 					}
 				});
-
 				if (identityHeader != null && this.writeToDb) {
 					//restore identity first!!
 
@@ -476,6 +481,7 @@ public class RestoreService extends Service {
 				}
 
 				//contacts, groups and distribution lists
+				logger.info("Restoring main files (contacts, groups, distribution lists)");
 				if(!this.restoreMainFiles(fileHeaders)) {
 					logger.error("restore main files failed");
 					//continue anyway!
@@ -483,12 +489,14 @@ public class RestoreService extends Service {
 
 				updateProgress(STEP_SIZE_MAIN_FILES);
 
+				logger.info("Restoring message files");
 				messageCount = this.restoreMessageFiles(fileHeaders);
 				if(messageCount == 0) {
 					logger.error("restore message files failed");
 					//continue anyway!
 				}
 
+				logger.info("Restoring group avatar files");
 				if(!this.restoreGroupAvatarFiles(fileHeaders)) {
 					logger.error("restore group avatar files failed");
 					//continue anyway!
@@ -496,15 +504,17 @@ public class RestoreService extends Service {
 
 				updateProgress(STEP_SIZE_GRPOUP_AVATARS);
 
+				logger.info("Restoring message media files");
 				mediaCount = this.restoreMessageMediaFiles(fileHeaders);
 				if (mediaCount == 0) {
 					logger.error("restore message media files failed");
 					//continue anyway!
 				} else {
-					logger.info(mediaCount + " media files found");
+					logger.info("{} media files found", mediaCount);
 				}
 
 				//restore all avatars
+				logger.info("Restoring avatars");
 				if(!this.restoreContactAvatars(fileHeaders)) {
 					logger.error("restore contact avatar files failed");
 					//continue anyway!
@@ -515,6 +525,7 @@ public class RestoreService extends Service {
 				}
 			}
 
+			logger.info("Restore successful!");
 			restoreSuccess = true;
 			onFinished(null);
 
@@ -524,11 +535,11 @@ public class RestoreService extends Service {
 			Thread.currentThread().interrupt();
 			message = "Interrupted while restoring identity";
 		} catch (RestoreCanceledException e) {
-			logger.error("Exception", e);
+			logger.error("Restore cancelled", e);
 			message = getString(R.string.restore_data_cancelled);
 		} catch (Exception x) {
 			// wrong password? no connection? throw
-			logger.error("Exception", x);
+			logger.error("Exception while restoring backup", x);
 			message = x.getMessage();
 		}
 
@@ -741,7 +752,10 @@ public class RestoreService extends Service {
 							FileHeader thumbnailFileHeader = thumbnailFileHeaders.get(thumbnailPrefix + messageUid);
 							if (thumbnailFileHeader != null) {
 								try (ZipInputStream inputStream = zipFile.getInputStream(thumbnailFileHeader)) {
-									this.fileService.writeConversationMediaThumbnail(model, IOUtils.toByteArray(inputStream));
+									byte[] thumbnailBytes = IOUtils.toByteArray(inputStream);
+									if (thumbnailBytes != null) {
+										this.fileService.writeConversationMediaThumbnail(model, thumbnailBytes);
+									}
 								}
 								//
 							}
@@ -760,7 +774,7 @@ public class RestoreService extends Service {
 								FileHeader thumbnailFileHeader = thumbnailFileHeaders.get(thumbnailPrefix + messageUid);
 
 								//if no thumbnail file exist in backup, generate one
-								if (thumbnailFileHeader == null) {
+								if (thumbnailFileHeader == null && imageData != null) {
 									this.fileService.writeConversationMediaThumbnail(model, imageData);
 								}
 							}
@@ -1604,7 +1618,7 @@ public class RestoreService extends Service {
 	}
 
 	public void onFinished(String message) {
-		logger.debug("onFinished success = " + restoreSuccess);
+		logger.info("onFinished success = {}", restoreSuccess);
 
 		cancelPersistentNotification();
 
@@ -1669,7 +1683,7 @@ public class RestoreService extends Service {
 	}
 
 	private void updatePersistentNotification(int currentStep, int steps, boolean indeterminate) {
-		logger.debug("updatePersistentNoti " + currentStep + " of " + steps);
+		logger.debug("updatePersistentNoti {} of {}", currentStep, steps);
 
 		if (currentStep != 0) {
 			final long millisPassed = System.currentTimeMillis() - startTime;

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

@@ -288,7 +288,7 @@ public interface FileService {
 	/**
 	 * write a thumbnail to disk
 	 */
-	void writeConversationMediaThumbnail(AbstractMessageModel messageModel, byte[] thumbnail) throws Exception;
+	void writeConversationMediaThumbnail(AbstractMessageModel messageModel, @NonNull byte[] thumbnail) throws Exception;
 
 	/**
 	 * return whether a thumbnail file exists for the specified message model

+ 9 - 6
app/src/main/java/ch/threema/app/services/FileServiceImpl.java

@@ -831,7 +831,7 @@ public class FileServiceImpl implements FileService {
 		return new File(getAppDataPathAbsolute(), "." + uid);
 	}
 
-	private File getMessageThumbnail(AbstractMessageModel messageModel) {
+	private File getMessageThumbnail(@Nullable AbstractMessageModel messageModel) {
 		// locations do not have a file, do not check for existing!
 		if (messageModel == null) {
 			return null;
@@ -1102,7 +1102,12 @@ public class FileServiceImpl implements FileService {
 		return false;
 	}
 
-	private void generateConversationMediaThumbnail(AbstractMessageModel messageModel, byte[] originalPicture, int pos, int length) throws Exception {
+	private void generateConversationMediaThumbnail(
+		AbstractMessageModel messageModel,
+		@NonNull byte[] originalPicture,
+		int pos,
+		int length
+	) throws Exception {
 		if (this.masterKey.isLocked()) {
 			throw new Exception("no masterkey or locked");
 		}
@@ -1119,10 +1124,8 @@ public class FileServiceImpl implements FileService {
 	}
 
 	@Override
-	public void writeConversationMediaThumbnail(AbstractMessageModel messageModel, byte[] originalPicture) throws Exception {
-		if (originalPicture != null) {
-			generateConversationMediaThumbnail(messageModel, originalPicture, 0, originalPicture.length);
-		}
+	public void writeConversationMediaThumbnail(AbstractMessageModel messageModel, @NonNull byte[] originalPicture) throws Exception {
+		generateConversationMediaThumbnail(messageModel, originalPicture, 0, originalPicture.length);
 	}
 
 	/**

+ 17 - 12
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -1080,7 +1080,8 @@ public class MessageServiceImpl implements MessageService {
 				this.save(messageModel);
 				this.fireOnModifiedMessage(messageModel);
 			} else {
-				logger.error("Illegal state transition from {} to {} (outbox={}), ignoring", messageModel.getState(), newState, messageModel.isOutbox());
+				// duplicate message state transitions (for example from SENT to SENT) are normal for group messages as we will get an ack for each message
+				logger.warn("State transition from {} to {} (outbox={}), ignoring", messageModel.getState(), newState, messageModel.isOutbox());
 			}
 		}
 	}
@@ -1714,11 +1715,13 @@ public class MessageServiceImpl implements MessageService {
 		if (thumbnailBlob != null && thumbnailBlob.length > NaCl.BOXOVERHEAD) {
 			byte[] thumbnail = NaCl.symmetricDecryptData(thumbnailBlob, encryptionKey, ProtocolDefines.THUMBNAIL_NONCE);
 
-			try {
-				fileService.writeConversationMediaThumbnail(messageModel, thumbnail);
-			} catch (Exception e) {
-				this.downloadService.error(messageModel.getId());
-				throw e;
+			if (thumbnail != null) {
+				try {
+					fileService.writeConversationMediaThumbnail(messageModel, thumbnail);
+				} catch (Exception e) {
+					this.downloadService.error(messageModel.getId());
+					throw e;
+				}
 			}
 
 			messageModel.setSaved(true);
@@ -1841,12 +1844,14 @@ public class MessageServiceImpl implements MessageService {
 
 			byte[] thumbnail = NaCl.symmetricDecryptData(thumbnailBlob, fileData.getEncryptionKey(), ProtocolDefines.FILE_THUMBNAIL_NONCE);
 
-			try {
-				fileService.writeConversationMediaThumbnail(messageModel, thumbnail);
-			} catch (Exception e) {
-				downloadService.error(messageModel.getId());
-				logger.info("Error writing thumbnail for message " + messageModel.getApiMessageId());
-				throw e;
+			if (thumbnail != null) {
+				try {
+					fileService.writeConversationMediaThumbnail(messageModel, thumbnail);
+				} catch (Exception e) {
+					downloadService.error(messageModel.getId());
+					logger.info("Error writing thumbnail for message " + messageModel.getApiMessageId());
+					throw e;
+				}
 			}
 
 			this.downloadService.complete(messageModel.getId(), fileData.getThumbnailBlobId());

+ 12 - 5
app/src/main/java/ch/threema/app/ui/IdentityPopup.java

@@ -38,15 +38,13 @@ import android.widget.TextView;
 
 import com.google.android.material.chip.Chip;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.lang.ref.WeakReference;
 
 import androidx.appcompat.widget.SwitchCompat;
 import androidx.constraintlayout.widget.Group;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.activities.AddContactActivity;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.QRCodeService;
 import ch.threema.app.services.UserService;
@@ -166,7 +164,16 @@ public class IdentityPopup extends DimmingPopupWindow {
 	}
 
 	private void scanQR() {
-		QRScannerUtil.getInstance().initiateGeneralThreemaQrScanner(activityRef.get(), context.getString(R.string.qr_scanner_id_hint));
+		if (ConfigUtils.supportsGroupLinks()) {
+			QRScannerUtil.getInstance().initiateGeneralThreemaQrScanner(activityRef.get(), context.getString(R.string.qr_scanner_id_hint));
+		} else {
+			Intent intent = new Intent(context, AddContactActivity.class);
+			intent.putExtra(AddContactActivity.EXTRA_ADD_BY_QR, true);
+			if (activityRef.get() != null) {
+				activityRef.get().startActivity(intent);
+				activityRef.get().overridePendingTransition(R.anim.fast_fade_in, R.anim.fast_fade_out);
+			}
+		}
 	}
 
 	private void zoomQR(View v) {
@@ -242,7 +249,7 @@ public class IdentityPopup extends DimmingPopupWindow {
 		WebClientListenerManager.serviceListener.remove(this.webClientServiceListener);
 	}
 
-	private WebClientServiceListener webClientServiceListener = new WebClientServiceListener() {
+	private final WebClientServiceListener webClientServiceListener = new WebClientServiceListener() {
 		@Override
 		public void onEnabled() {
 			this.setEnabled(true);

+ 3 - 3
app/src/main/java/ch/threema/app/utils/BitmapUtil.java

@@ -125,11 +125,11 @@ public class BitmapUtil {
 		return result;
 	}
 
-	static public byte[] bitmapToPngByteArray(Bitmap bitmap) {
+	static public byte[] bitmapToPngByteArray(@NonNull Bitmap bitmap) {
 		return bitmapToByteArray(bitmap, Bitmap.CompressFormat.PNG, DEFAULT_PNG_QUALITY);
 	}
 
-	static public byte[] bitmapToJpegByteArray(Bitmap bitmap) {
+	static public byte[] bitmapToJpegByteArray(@NonNull Bitmap bitmap) {
 		return bitmapToByteArray(bitmap, Bitmap.CompressFormat.JPEG, DEFAULT_JPG_QUALITY);
 	}
 
@@ -139,7 +139,7 @@ public class BitmapUtil {
 	 * @param quality
 	 * @return Byte array of bitmap
 	 */
-	public static byte[] bitmapToByteArray(Bitmap bitmap, Bitmap.CompressFormat format, int quality) {
+	public static byte[] bitmapToByteArray(@NonNull Bitmap bitmap, @NonNull Bitmap.CompressFormat format, int quality) {
 		ByteArrayOutputStream stream = new ByteArrayOutputStream();
 		bitmap.compress(format, quality, stream);
 		return stream.toByteArray();

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

@@ -128,7 +128,7 @@ import java8.util.stream.StreamSupport;
  */
 public class PeerConnectionClient {
 	// Note: Not static, because we want to set a prefix
-	private final Logger logger = LoggerFactory.getLogger(PeerConnectionClient.class);
+	private final Logger logger = LoggerFactory.getLogger("PeerConnectionClient");
 
 	private static final String AUDIO_TRACK_ID = "3MACALLa0";
 	private static final String AUDIO_CODEC_OPUS = "opus";

+ 4 - 4
app/src/main/java/ch/threema/storage/DatabaseServiceNew.java

@@ -211,7 +211,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 					try {
 						sqLiteDatabase.close();
 					} catch (Exception e) {
-						//
+						logger.error("Exception while closing database", e);
 					}
 				}
 				System.exit(2);
@@ -661,7 +661,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 			try {
 				FileUtil.createNewFileOrLog(lockfile, logger);
 			} catch (IOException e) {
-				logger.error("Exception", e);
+				logger.error("IOException when creating lockfile", e);
 			}
 
 			if (!newDatabaseFile.exists()) {
@@ -726,7 +726,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 							}
 						} catch (Exception e) {
 							logger.info("Database migration FAILED");
-							logger.error("Exception", e);
+							logger.error("Exception while migrating", e);
 							FileUtil.deleteFileOrWarn(newDatabaseFile, "New Database File", logger);
 						}
 					}
@@ -736,7 +736,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 				try {
 					migrateThread.join();
 				} catch (InterruptedException e) {
-					logger.error("Exception", e);
+					logger.error("InterruptedException while waiting for migrateThread", e);
 					migrateSuccess[0] = false;
 				}
 

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

@@ -1416,4 +1416,5 @@ sicheren Ort gesichert oder ausgedruckt haben.</string>
 	<string name="permission_contacts_sync_required">Erlauben Sie bitte den Zugriff auf die Kontakte, um sie zu synchronisieren.</string>
 	<string name="not_voted_user_list">Diese Teilnehmer haben nicht abgestimmt: %s</string>
 	<string name="invalid_onprem_id">Keine gültige Threema OnPrem-ID</string>
+	<string name="enable_unknown_sources">Installieren nicht möglich. Bitte aktivieren Sie \"Unbekannte Apps installieren\" für %s.</string>
 </resources>

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

@@ -1352,4 +1352,5 @@
 	<string name="permission_contacts_sync_required">Please allow access to your contacts in order to be able to sync them.</string>
 	<string name="not_voted_user_list">These participants did not vote: %s</string>
 	<string name="invalid_onprem_id">Not a valid Threema OnPrem-ID</string>
+	<string name="enable_unknown_sources">Unable to install. Please make sure \"Install unknown apps\" is enabled for %s.</string>
 </resources>

+ 3 - 0
app/src/store_threema/AndroidManifest.xml

@@ -1,6 +1,9 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
 	xmlns:tools="http://schemas.android.com/tools">
+
+	<!-- To install updates - Threema Shop version only -->
+	<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
 	<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
 
 	<application tools:ignore="GoogleAppIndexingWarning">

+ 49 - 18
app/src/store_threema/java/ch/threema/app/activities/DownloadApkActivity.java

@@ -22,6 +22,7 @@
 package ch.threema.app.activities;
 
 import android.app.DownloadManager;
+import android.content.ActivityNotFoundException;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -35,9 +36,15 @@ import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.preference.PreferenceManager;
+import android.provider.Settings;
 import android.text.format.DateUtils;
 import android.widget.Toast;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
@@ -53,6 +60,8 @@ import ch.threema.app.utils.DownloadUtil;
 import ch.threema.app.utils.IntentDataUtil;
 
 public class DownloadApkActivity extends AppCompatActivity implements GenericAlertDialog.DialogClickListener {
+	private static final Logger logger = LoggerFactory.getLogger(DownloadApkActivity.class);
+
 	private static final String DIALOG_TAG_DOWNLOAD_UPDATE = "cfu";
 	private static final String DIALOG_TAG_DOWNLOADING = "dtd";
 
@@ -63,6 +72,12 @@ public class DownloadApkActivity extends AppCompatActivity implements GenericAle
 
 	private SharedPreferences sharedPreferences;
 	private String downloadUrl;
+	private DownloadUtil.DownloadState downloadState;
+
+	private final ActivityResultLauncher<Intent> requestUnknownSourcesSettingsLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+		result -> {
+			installPackage();
+		});
 
 	private final BroadcastReceiver downloadApkFinishedReceiver = new BroadcastReceiver() {
 		@Override
@@ -73,7 +88,7 @@ public class DownloadApkActivity extends AppCompatActivity implements GenericAle
 			final long referenceId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
 
 			if (referenceId > 0 && context != null) {
-				DownloadUtil.DownloadState downloadState = DownloadUtil.getNewestApkDownloadState(referenceId);
+				downloadState = DownloadUtil.getNewestApkDownloadState(referenceId);
 				if (downloadState != null) {
 					int status = 0, reason = 0;
 					DownloadManager downloadManager = (DownloadManager)getSystemService(Context.DOWNLOAD_SERVICE);
@@ -93,36 +108,52 @@ public class DownloadApkActivity extends AppCompatActivity implements GenericAle
 					}
 
 					if (status == DownloadManager.STATUS_SUCCESSFUL) {
-						Uri uri;
-						Intent installIntent;
-
 						if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
-							uri = NamedFileProvider.getUriForFile(DownloadApkActivity.this, BuildConfig.APPLICATION_ID + ".fileprovider", downloadState.getDestinationFile(), null);
-							installIntent = new Intent(Intent.ACTION_INSTALL_PACKAGE);
-							installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
-							installIntent.setData(uri);
+							if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !getPackageManager().canRequestPackageInstalls()) {
+								try {
+									requestUnknownSourcesSettingsLauncher.launch(new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).setData(Uri.parse(String.format("package:%s", getPackageName()))));
+								} catch (ActivityNotFoundException e) {
+									logger.error("No activity for unknown sources", e);
+									Toast.makeText(getApplicationContext(), getString(R.string.enable_unknown_sources, getString(R.string.app_name)), Toast.LENGTH_LONG).show();
+									finishUp();
+								}
+							} else {
+								installPackage();
+							}
+							return;
 						} else {
-							uri = Uri.fromFile(downloadState.getDestinationFile());
-							installIntent = new Intent(Intent.ACTION_VIEW);
+							Uri uri = Uri.fromFile(downloadState.getDestinationFile());
+							Intent installIntent = new Intent(Intent.ACTION_VIEW);
 							installIntent.setDataAndType(uri, "application/vnd.android.package-archive");
 							installIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+							context.startActivity(installIntent);
 						}
-						context.startActivity(installIntent);
 					} else {
 						Toast.makeText(getApplicationContext(), getString(R.string.download_failed, reason), Toast.LENGTH_LONG).show();
 					}
-
-					new Handler().postDelayed(new Runnable() {
-						@Override
-						public void run() {
-							finish();
-						}
-					}, 1000);
+					finishUp();
 				}
 			}
 		}
 	};
 
+	private void finishUp() {
+		new Handler().postDelayed(this::finish, 1000);
+	}
+
+	private void installPackage() {
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !getPackageManager().canRequestPackageInstalls()) {
+			Toast.makeText(getApplicationContext(), getString(R.string.enable_unknown_sources, getString(R.string.app_name)), Toast.LENGTH_LONG).show();
+		} else {
+			Uri uri = NamedFileProvider.getUriForFile(DownloadApkActivity.this, BuildConfig.APPLICATION_ID + ".fileprovider", downloadState.getDestinationFile(), null);
+			Intent installIntent = new Intent(Intent.ACTION_INSTALL_PACKAGE);
+			installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+			installIntent.setData(uri);
+			startActivity(installIntent);
+		}
+		finishUp();
+	}
+
 	@Override
 	protected void onCreate(@Nullable Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);

+ 19 - 0
dependencyCheckSuppressions.xml

@@ -12,6 +12,25 @@
         <cve>CVE-2020-8908</cve>
     </suppress>
 
+    <!-- Ignore CVE-2021-29425: Vulnerable code (FileNameUtils.normalize) not used. -->
+    <suppress>
+        <notes><![CDATA[
+        file name: commons-io-2.6.jar
+        ]]></notes>
+        <packageUrl regex="true">^pkg:maven/commons\-io/commons\-io@.*$</packageUrl>
+        <cve>CVE-2021-29425</cve>
+    </suppress>
+
+    <!-- Ignore CVE-2018-20200: It requires hooking into the running application, CVE is disputed.
+    https://github.com/square/okhttp/issues/4967 -->
+    <suppress>
+        <notes><![CDATA[
+        file name: okhttp-3.12.0.jar
+        ]]></notes>
+        <packageUrl regex="true">^pkg:maven/com\.squareup\.okhttp3/okhttp@.*$</packageUrl>
+        <cve>CVE-2018-20200</cve>
+    </suppress>
+
     <!-- Ignore wrong matches. -->
     <suppress>
         <packageUrl regex="true">^pkg:maven/org\.saltyrtc/saltyrtc\-task\-webrtc@.*$</packageUrl>

+ 44 - 60
domain/src/main/java/ch/threema/domain/protocol/api/APIConnector.java

@@ -83,10 +83,9 @@ import ch.threema.domain.stores.TokenStoreInterface;
  * <p>
  * All calls run synchronously; if necessary the caller should dispatch a separate thread.
  */
-@SuppressWarnings("DuplicateThrows")
 public class APIConnector {
 
-	private static final Logger logger = LoggerFactory.getLogger(APIConnector.class);
+	private static final Logger logger = LoggerFactory.getLogger("APIConnector");
 
 	/* HMAC-SHA256 keys for contact matching */
 	private static final byte[] EMAIL_HMAC_KEY = new byte[]{(byte) 0x30, (byte) 0xa5, (byte) 0x50, (byte) 0x0f, (byte) 0xed, (byte) 0x97, (byte) 0x01, (byte) 0xfa, (byte) 0x6d, (byte) 0xef, (byte) 0xdb, (byte) 0x61, (byte) 0x08, (byte) 0x41, (byte) 0x90, (byte) 0x0f, (byte) 0xeb, (byte) 0xb8, (byte) 0xe4, (byte) 0x30, (byte) 0x88, (byte) 0x1f, (byte) 0x7a, (byte) 0xd8, (byte) 0x16, (byte) 0x82, (byte) 0x62, (byte) 0x64, (byte) 0xec, (byte) 0x09, (byte) 0xba, (byte) 0xd7};
@@ -125,8 +124,6 @@ public class APIConnector {
 
 	/**
 	 * Set an optional object that adds authentication information to URLConnections.
-	 *
-	 * @param authenticator
 	 */
 	public void setAuthenticator(APIAuthenticator authenticator) {
 		this.authenticator = authenticator;
@@ -183,6 +180,9 @@ public class APIConnector {
 
 		byte[] token = Base64.decode(tokenString);
 		byte[] tokenRespKeyPub = Base64.decode(p1Result.getString("tokenRespKeyPub"));
+		if (isBadToken(token)) {
+			throw new ThreemaException("Bad token");
+		}
 
 		logger.debug("Got token from server; sending response");
 
@@ -243,7 +243,7 @@ public class APIConnector {
 	 * @throws FileNotFoundException if identity not found
 	 * @throws Exception             on network error
 	 */
-	public ArrayList<FetchIdentityResult> fetchIdentities(ArrayList<String> identities) throws FileNotFoundException, Exception {
+	public ArrayList<FetchIdentityResult> fetchIdentities(ArrayList<String> identities) throws Exception {
 		if (identities == null || identities.size() < 1) {
 			throw new ThreemaException("empty identities array");
 		}
@@ -610,7 +610,7 @@ public class APIConnector {
 		logger.debug(String.format("Match identities: response from server: %s", result.toString()));
 
 		matchCheckInterval = result.getInt("checkInterval");
-		logger.debug(String.format("Server requested check interval of %d seconds", matchCheckInterval));
+		logger.debug("Server requested check interval of {} seconds", matchCheckInterval);
 
 		JSONArray identities = result.getJSONArray("identities");
 
@@ -684,7 +684,6 @@ public class APIConnector {
 	 * @param authTokenStore the token store to use for caching the token
 	 * @param forceRefresh if true, a new token is always requested even if one is currently cached
 	 * @return The authentication token
-	 * @throws Exception
 	 */
 	public String obtainAuthToken(TokenStoreInterface authTokenStore, boolean forceRefresh) throws JSONException, IOException, ThreemaException {
 		String token = null;
@@ -774,10 +773,6 @@ public class APIConnector {
 
 	/**
 	 * Check the revocation key
-	 *
-	 * @param identityStore
-	 * @return
-	 * @throws Exception
 	 */
 	public CheckRevocationKeyResult checkRevocationKey(IdentityStoreInterface identityStore) throws Exception {
 		String url = getServerUrl() + "identity/check_revocation_key";
@@ -807,11 +802,6 @@ public class APIConnector {
 
 	/**
 	 * Set the revocation key for the stored identity
-	 *
-	 * @param identityStore
-	 * @param revocationKey
-	 * @return
-	 * @throws Exception
 	 */
 	public SetRevocationKeyResult setRevocationKey(IdentityStoreInterface identityStore, String revocationKey) throws Exception {
 
@@ -845,11 +835,8 @@ public class APIConnector {
 	}
 
 	/**
-	 * This call is used to check a list of IDs and determine the status of each ID. The response contains a list of status codes, one for each ID in the same order as in the request.
-	 *
-	 * @param identities
-	 * @return
-	 * @throws Exception
+	 * This call is used to check a list of IDs and determine the status of each ID.
+	 * The response contains a list of status codes, one for each ID in the same order as in the request.
 	 */
 	public CheckIdentityStatesResult checkIdentityStates(String[] identities) throws Exception {
 		String url = getServerUrl() + "identity/check";
@@ -936,7 +923,7 @@ public class APIConnector {
 		String turnUsername = p2Result.getString("turnUsername");
 		String turnPassword = p2Result.getString("turnPassword");
 		int expiration = p2Result.getInt("expiration");
-		Date expirationDate = new Date(new Date().getTime() + expiration*1000);
+		Date expirationDate = new Date(new Date().getTime() + expiration * 1000L);
 
 		return new TurnServerInfo(turnUrls, turnUrlsDualStack, turnUsername, turnPassword, expirationDate);
 	}
@@ -1024,14 +1011,7 @@ public class APIConnector {
 	}
 
 	/**
-	 * Fetch all custom work data from work api
-	 *
-	 * @param username
-	 * @param password
-	 * @param identities (list of existing threema id
-	 * @return
-	 * @throws IOException
-	 * @throws JSONException
+	 * Fetch all custom work data from work API.
 	 */
 	public WorkData fetchWorkData(String username, String password, String[] identities) throws Exception {
 		WorkData workData = new WorkData();
@@ -1138,17 +1118,17 @@ public class APIConnector {
 	/**
 	 * Fetch work contacts from work api
 	 *
-	 * @param username (threema work license username)
-	 * @param password (threema work license password)
-	 * @param identities (list of threema id to check)
-	 * @return list of valid threema work contacts - empty list if there are no matching contacts in this package
-	 * @throws IOException
-	 * @throws JSONException
+	 * @param username Threema Work license username
+	 * @param password Threema Work license password
+	 * @param identities List of Threema IDs to check
+	 * @return List of valid threema work contacts - empty list if there are no matching contacts in this package.
 	 */
 	@NonNull
-	public List<WorkContact> fetchWorkContacts(@NonNull String username,
-											   @NonNull String password,
-											   @NonNull String[] identities) throws Exception {
+	public List<WorkContact> fetchWorkContacts(
+		@NonNull String username,
+		@NonNull String password,
+		@NonNull String[] identities
+	) throws Exception {
 
 		List<WorkContact> contactsList = new ArrayList<>();
 		JSONObject request = new JSONObject();
@@ -1193,19 +1173,14 @@ public class APIConnector {
 	}
 
 	/**
-	 * Search the threema work directory without categories
-	 *
-	 * @param username
-	 * @param password
-	 * @param filter
-	 * @return Can be null
-	 * @throws IOException
-	 * @throws JSONException
+	 * Search the threema work directory without categories.
 	 */
-	public WorkDirectory fetchWorkDirectory(String username,
-											String password,
-											IdentityStoreInterface identityStore,
-											WorkDirectoryFilter filter) throws Exception {
+	public WorkDirectory fetchWorkDirectory(
+		String username,
+		String password,
+		IdentityStoreInterface identityStore,
+		WorkDirectoryFilter filter
+	) throws Exception {
 
 		JSONObject request = new JSONObject();
 		request.put("username", username);
@@ -1227,6 +1202,7 @@ public class APIConnector {
 		JSONObject jsonSort = new JSONObject();
 
 		jsonSort.put("asc", filter.isSortAscending());
+		//noinspection SwitchStatementWithTooFewBranches
 		switch (filter.getSortBy()) {
 			case WorkDirectoryFilter.SORT_BY_LAST_NAME:
 				jsonSort.put("by", "lastName");
@@ -1473,6 +1449,9 @@ public class APIConnector {
 	private void makeTokenResponse(JSONObject p1Result, JSONObject request, IdentityStoreInterface identityStore) throws JSONException, IOException, ThreemaException {
 		byte[] token = Base64.decode(p1Result.getString("token"));
 		byte[] tokenRespKeyPub = Base64.decode(p1Result.getString("tokenRespKeyPub"));
+		if (isBadToken(token)) {
+			throw new ThreemaException("Bad token");
+		}
 
 		/* sign token with our secret key */
 		byte[] nonce = new byte[NaCl.NONCEBYTES];
@@ -1488,7 +1467,18 @@ public class APIConnector {
 		request.put("nonce", Base64.encodeBytes(nonce));
 	}
 
-	public @Nullable APIConnector.FetchIdentityResult getFetchResultByIdentity(ArrayList<APIConnector.FetchIdentityResult> results, String identity) {
+	/**
+	 * A token must start with 0xff and be longer than 32 bytes to avoid payload confusion.
+	 * @return true if the token is invalid, false otherwise.
+	 */
+	private boolean isBadToken(@Nullable byte[] token) {
+		return (token == null || token.length <= 32 || token[0] != (byte) 0xff);
+	}
+
+	public @Nullable APIConnector.FetchIdentityResult getFetchResultByIdentity(
+		ArrayList<APIConnector.FetchIdentityResult> results,
+		String identity
+	) {
 		if (identity != null) {
 			for (APIConnector.FetchIdentityResult result : results) {
 				if (identity.equals(result.identity)) {
@@ -1533,11 +1523,6 @@ public class APIConnector {
 		public Object refObjectEmail;
 	}
 
-	public static class CheckBetaResult {
-		public boolean success;
-		public String error;
-	}
-
 	public static class CheckLicenseResult {
 		public boolean success;
 		public String error;
@@ -1577,8 +1562,7 @@ public class APIConnector {
 		}
 	}
 
-
-	public class SetRevocationKeyResult {
+	public static class SetRevocationKeyResult {
 		public final boolean success;
 		public final String error;
 
@@ -1588,7 +1572,7 @@ public class APIConnector {
 		}
 	}
 
-	public class TurnServerInfo {
+	public static class TurnServerInfo {
 		public final String[] turnUrls;
 		public final String[] turnUrlsDualStack;
 		public final String turnUsername;

+ 1 - 1
domain/src/test/java/ch/threema/domain/protocol/api/APIConnectorTest.java

@@ -345,7 +345,7 @@ public class APIConnectorTest {
 		when(connector.obtainTurnServers(eq(identityStore), eq("voip"))).thenCallRealMethod();
 		when(connector.doPost(eq("https://server.url/identity/turn_cred"), ArgumentMatchers.any()))
 			.thenReturn("{"
-				+ "\"token\": \"0123456789abcdef\","
+				+ "\"token\": \"/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\","
 				+ "\"tokenRespKeyPub\": \"dummy\""
 				+ "}")
 			.thenReturn("{"