Bläddra i källkod

Replace tabs with spaces

Threema 7 månader sedan
förälder
incheckning
2503a8b982
100 ändrade filer med 5431 tillägg och 5205 borttagningar
  1. 50 50
      app/src/androidTest/java/ch/threema/app/ScreenshotTakingRule.java
  2. 33 33
      app/src/androidTest/java/ch/threema/app/TestApplication.java
  3. 8 8
      app/src/androidTest/java/ch/threema/app/ThreemaTestRunner.java
  4. 247 246
      app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java
  5. 4 1
      app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt
  6. 4 1
      app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt
  7. 20 10
      app/src/androidTest/java/ch/threema/app/edithistory/EditHistoryTest.kt
  8. 283 283
      app/src/androidTest/java/ch/threema/app/emojis/MarkupParserTest.java
  9. 14 2
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt
  10. 35 15
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  11. 15 15
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt
  12. 4 1
      app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt
  13. 50 7
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt
  14. 4 1
      app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt
  15. 226 225
      app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java
  16. 33 33
      app/src/androidTest/java/ch/threema/app/testutils/CaptureLogcatOnTestFailureRule.java
  17. 22 21
      app/src/androidTest/java/ch/threema/app/testutils/InstructionUtil.java
  18. 58 56
      app/src/androidTest/java/ch/threema/app/testutils/RecyclerViewMatcher.java
  19. 258 258
      app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java
  20. 7 7
      app/src/androidTest/java/ch/threema/app/testutils/ThreemaAssert.java
  21. 121 121
      app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java
  22. 9 3
      app/src/androidTest/java/ch/threema/app/utils/BundledMessagesSendStepsTest.kt
  23. 12 4
      app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt
  24. 22 22
      app/src/androidTest/java/ch/threema/app/utils/TextUtilTest.java
  25. 569 568
      app/src/androidTest/java/ch/threema/app/voip/SdpTest.java
  26. 194 194
      app/src/androidTest/java/ch/threema/app/voip/VoipStatusMessageTest.java
  27. 190 190
      app/src/androidTest/java/ch/threema/app/webclient/activities/SessionsActivityTest.java
  28. 54 54
      app/src/androidTest/java/ch/threema/app/webclient/converter/MessageTest.java
  29. 15 15
      app/src/androidTest/java/ch/threema/app/webclient/converter/MsgpackTest.java
  30. 1 1
      app/src/androidTest/java/ch/threema/data/TestDatabaseService.kt
  31. 8 2
      app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt
  32. 10 4
      app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt
  33. 46 46
      app/src/androidTest/java/ch/threema/logging/backend/DebugLogFileBackendTest.java
  34. 26 17
      app/src/androidTest/java/ch/threema/storage/DatabaseNonceStoreTest.kt
  35. 233 233
      app/src/androidTest/java/ch/threema/storage/SQLDHSessionStoreTest.java
  36. 2 1
      app/src/androidTest/java/ch/threema/storage/TaskArchiveFactoryTest.kt
  37. 1 1
      app/src/androidTest/java/com/azimolabs/conditionwatcher/ConditionWatcher.java
  38. 35 34
      app/src/blue/AndroidManifest.xml
  39. 9 9
      app/src/blue/java/ch/threema/app/activities/DownloadApkActivity.java
  40. 3 2
      app/src/blue/java/ch/threema/app/utils/DownloadUtil.java
  41. 31 19
      app/src/blue/res/drawable-v24/ic_launcher_foreground.xml
  42. 13 12
      app/src/blue/res/drawable-v24/ic_launcher_monochrome.xml
  43. 12 4
      app/src/blue/res/drawable/ic_badge_work.xml
  44. 24 9
      app/src/blue/res/drawable/ic_finger_with_circles.xml
  45. 0 2
      app/src/blue/res/drawable/logo_main.xml
  46. 272 272
      app/src/blue/res/layout-land/activity_verification_level.xml
  47. 151 148
      app/src/blue/res/layout/activity_enter_serial.xml
  48. 274 274
      app/src/blue/res/layout/activity_verification_level.xml
  49. 28 29
      app/src/blue/res/layout/header_contact_section_work.xml
  50. 37 38
      app/src/blue/res/layout/toolbar_home.xml
  51. 20 20
      app/src/blue/res/values-de/strings.xml
  52. 8 9
      app/src/blue/res/values/firebase_messaging.xml
  53. 20 20
      app/src/blue/res/values/strings.xml
  54. 10 10
      app/src/blue/res/xml/contacts.xml
  55. 9 3
      app/src/blue/res/xml/file_paths.xml
  56. 3 3
      app/src/foss_based/java/ch/threema/app/activities/VoiceActionActivity.java
  57. 5 4
      app/src/foss_based/java/ch/threema/app/licensing/StoreLicenseCheck.java
  58. 9 9
      app/src/foss_based/java/ch/threema/app/push/PushRegistrationWorker.java
  59. 15 15
      app/src/foss_based/java/ch/threema/app/push/PushService.java
  60. 8 8
      app/src/foss_based/java/ch/threema/app/services/VoiceActionService.java
  61. 4 4
      app/src/google_services_based/java/ch/threema/app/activities/VoiceActionActivity.java
  62. 45 45
      app/src/google_services_based/java/ch/threema/app/licensing/StoreLicenseCheck.java
  63. 22 22
      app/src/google_services_based/java/ch/threema/app/licensing/ThreemaLicensePolicy.java
  64. 51 51
      app/src/google_services_based/java/ch/threema/app/push/PushRegistrationWorker.java
  65. 91 90
      app/src/google_services_based/java/ch/threema/app/push/PushService.java
  66. 175 175
      app/src/google_services_based/java/ch/threema/app/services/VoiceActionService.java
  67. 7 7
      app/src/google_services_based/java/com/google/android/vending/licensing/AESObfuscator.java
  68. 90 87
      app/src/google_services_based/java/com/google/android/vending/licensing/ILicenseResultListener.java
  69. 90 87
      app/src/google_services_based/java/com/google/android/vending/licensing/ILicensingService.java
  70. 42 38
      app/src/google_services_based/java/com/google/android/vending/licensing/LicenseChecker.java
  71. 6 4
      app/src/google_services_based/java/com/google/android/vending/licensing/LicenseCheckerCallback.java
  72. 6 6
      app/src/google_services_based/java/com/google/android/vending/licensing/LicenseValidator.java
  73. 2 2
      app/src/google_services_based/java/com/google/android/vending/licensing/Obfuscator.java
  74. 1 1
      app/src/google_services_based/java/com/google/android/vending/licensing/Policy.java
  75. 1 1
      app/src/google_services_based/java/com/google/android/vending/licensing/PreferenceObfuscator.java
  76. 7 5
      app/src/google_services_based/java/com/google/android/vending/licensing/ResponseData.java
  77. 2 2
      app/src/google_services_based/java/com/google/android/vending/licensing/StrictPolicy.java
  78. 2 2
      app/src/google_services_based/java/com/google/android/vending/licensing/ValidationException.java
  79. 517 510
      app/src/google_services_based/java/com/google/android/vending/licensing/util/Base64.java
  80. 7 7
      app/src/google_services_based/java/com/google/android/vending/licensing/util/Base64DecoderException.java
  81. 22 22
      app/src/google_services_based/java/com/google/android/vending/licensing/util/URIQueryDecoder.java
  82. 9 9
      app/src/green/java/ch/threema/app/activities/DownloadApkActivity.java
  83. 3 2
      app/src/green/java/ch/threema/app/utils/DownloadUtil.java
  84. 31 19
      app/src/green/res/drawable-v24/ic_launcher_foreground.xml
  85. 1 2
      app/src/green/res/values/firebase_messaging.xml
  86. 40 38
      app/src/hms/AndroidManifest.xml
  87. 63 63
      app/src/hms/agconnect-services.json
  88. 9 9
      app/src/hms/java/ch/threema/app/activities/DownloadApkActivity.java
  89. 3 2
      app/src/hms/java/ch/threema/app/utils/DownloadUtil.java
  90. 3 3
      app/src/hms_services_based/java/ch/threema/app/activities/VoiceActionActivity.java
  91. 29 28
      app/src/hms_services_based/java/ch/threema/app/licensing/StoreLicenseCheck.java
  92. 4 1
      app/src/hms_services_based/java/ch/threema/app/push/HmsTokenUtil.kt
  93. 75 73
      app/src/hms_services_based/java/ch/threema/app/push/PushService.java
  94. 8 8
      app/src/hms_services_based/java/ch/threema/app/services/VoiceActionService.java
  95. 2 2
      app/src/hms_services_based/java/com/DrmSDK/DialogTrigger.java
  96. 1 0
      app/src/hms_services_based/java/com/DrmSDK/Drm.java
  97. 1 1
      app/src/hms_services_based/java/com/DrmSDK/DrmCheckCallback.java
  98. 6 6
      app/src/hms_services_based/java/com/DrmSDK/DrmDialogActivity.java
  99. 59 59
      app/src/hms_services_based/java/com/DrmSDK/DrmKernel.java
  100. 10 10
      app/src/hms_services_based/java/com/DrmSDK/EMUISupportUtil.java

+ 50 - 50
app/src/androidTest/java/ch/threema/app/ScreenshotTakingRule.java

@@ -40,64 +40,64 @@ import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePe
 
 /**
  * When a test fails, take a screenshot.
- *
+ * <p>
  * Finally, close all open UI elements by pressing back and home buttons.
  */
 public class ScreenshotTakingRule extends TestWatcher {
-	private final static String TAG = "ScreenshotTakingRule";
+    private final static String TAG = "ScreenshotTakingRule";
 
-	private ScreenshotTakingRule() { /* Use getRuleChain instead */ }
+    private ScreenshotTakingRule() { /* Use getRuleChain instead */ }
 
-	public static RuleChain getRuleChain() {
-		return RuleChain
-			.outerRule(getReadWriteExternalStoragePermissionRule())
-			.around(new ScreenshotTakingRule());
-	}
+    public static RuleChain getRuleChain() {
+        return RuleChain
+            .outerRule(getReadWriteExternalStoragePermissionRule())
+            .around(new ScreenshotTakingRule());
+    }
 
-	@Override
-	protected void failed(Throwable e, Description description) {
-		final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
-		if (device == null) {
-			Log.e(TAG, "failed: Device is null");
-			return;
-		}
+    @Override
+    protected void failed(Throwable e, Description description) {
+        final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
+        if (device == null) {
+            Log.e(TAG, "failed: Device is null");
+            return;
+        }
 
-		// Create screenshot directory
-		final File baseDir = new File("/sdcard/testfailures/screenshots/" + description.getTestClass().getSimpleName() + "/");
-		if (!baseDir.exists() && !baseDir.mkdirs()) {
-			Log.e(TAG, "failed: Could not create screenshot directory");
-			return;
-		}
-		final String basePath = baseDir.getPath() + "/" + description.getMethodName();
+        // Create screenshot directory
+        final File baseDir = new File("/sdcard/testfailures/screenshots/" + description.getTestClass().getSimpleName() + "/");
+        if (!baseDir.exists() && !baseDir.mkdirs()) {
+            Log.e(TAG, "failed: Could not create screenshot directory");
+            return;
+        }
+        final String basePath = baseDir.getPath() + "/" + description.getMethodName();
 
-		// Dump UI state
-		try {
-			try (OutputStream stream = new BufferedOutputStream(new FileOutputStream(basePath + ".uix"))) {
-				// Note: Explicitly opening and closing stream since the UiAutomator dumpWindowHierarchy(File)
-				// method leaks a file descriptor.
-				device.dumpWindowHierarchy(stream);
-			}
-		} catch (IOException ex) {
-			ex.printStackTrace();
-		}
-		device.takeScreenshot(new File(basePath + ".png"));
-	}
+        // Dump UI state
+        try {
+            try (OutputStream stream = new BufferedOutputStream(new FileOutputStream(basePath + ".uix"))) {
+                // Note: Explicitly opening and closing stream since the UiAutomator dumpWindowHierarchy(File)
+                // method leaks a file descriptor.
+                device.dumpWindowHierarchy(stream);
+            }
+        } catch (IOException ex) {
+            ex.printStackTrace();
+        }
+        device.takeScreenshot(new File(basePath + ".png"));
+    }
 
-	/**
-	 * Close any open UI elements.
-	 *
-	 * This runs after {@link #failed(Throwable, Description)}.
-	 */
-	@Override
-	protected void finished(Description description) {
-		final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
-		if (device == null) {
-			Log.e(TAG, "finished: Device is null");
-			return;
-		}
+    /**
+     * Close any open UI elements.
+     * <p>
+     * This runs after {@link #failed(Throwable, Description)}.
+     */
+    @Override
+    protected void finished(Description description) {
+        final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
+        if (device == null) {
+            Log.e(TAG, "finished: Device is null");
+            return;
+        }
 
-		// Close all UI elements
-		device.pressBack();
-		device.pressHome();
-	}
+        // Close all UI elements
+        device.pressBack();
+        device.pressHome();
+    }
 }

+ 33 - 33
app/src/androidTest/java/ch/threema/app/TestApplication.java

@@ -28,50 +28,50 @@ import android.os.Bundle;
 
 public class TestApplication extends ThreemaApplication implements Application.ActivityLifecycleCallbacks {
 
-	private Activity currentActivity;
+    private Activity currentActivity;
 
-	@Override
-	public void onCreate() {
-		super.onCreate();
-		registerActivityLifecycleCallbacks(this);
-	}
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        registerActivityLifecycleCallbacks(this);
+    }
 
-	@Override
-	public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
-		currentActivity = activity;
-	}
+    @Override
+    public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
+        currentActivity = activity;
+    }
 
-	@Override
-	public void onActivityStarted(Activity activity) {
-		currentActivity = activity;
-	}
+    @Override
+    public void onActivityStarted(Activity activity) {
+        currentActivity = activity;
+    }
 
-	@Override
-	public void onActivityResumed(Activity activity) {
-		currentActivity = activity;
-	}
+    @Override
+    public void onActivityResumed(Activity activity) {
+        currentActivity = activity;
+    }
 
-	@Override
-	public void onActivityPaused(Activity activity) {
+    @Override
+    public void onActivityPaused(Activity activity) {
 
-	}
+    }
 
-	@Override
-	public void onActivityStopped(Activity activity) {
+    @Override
+    public void onActivityStopped(Activity activity) {
 
-	}
+    }
 
-	@Override
-	public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
+    @Override
+    public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
 
-	}
+    }
 
-	@Override
-	public void onActivityDestroyed(Activity activity) {
+    @Override
+    public void onActivityDestroyed(Activity activity) {
 
-	}
+    }
 
-	public Activity getCurrentActivity() {
-		return currentActivity;
-	}
+    public Activity getCurrentActivity() {
+        return currentActivity;
+    }
 }

+ 8 - 8
app/src/androidTest/java/ch/threema/app/ThreemaTestRunner.java

@@ -28,13 +28,13 @@ import android.os.Bundle;
 import androidx.test.runner.AndroidJUnitRunner;
 
 public class ThreemaTestRunner extends AndroidJUnitRunner {
-	@Override
-	public void onCreate(Bundle arguments) {
-		super.onCreate(arguments);
-	}
+    @Override
+    public void onCreate(Bundle arguments) {
+        super.onCreate(arguments);
+    }
 
-	@Override
-	public Application newApplication(ClassLoader cl, String className, Context context) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
-		return super.newApplication(cl, TestApplication.class.getName(), context);
-	}
+    @Override
+    public Application newApplication(ClassLoader cl, String className, Context context) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
+        return super.newApplication(cl, TestApplication.class.getName(), context);
+    }
 }

+ 247 - 246
app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java

@@ -85,178 +85,179 @@ import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePe
 @RunWith(AndroidJUnit4.class)
 @LargeTest
 @DangerousTest // Deletes data and possibly identity
-@Ignore("because this test broke with API version switch introduced in 7ed52bcfedd0bdcd2924ae14afe7ccb7bdc52c7a") // TODO(ANDR-1483)
+@Ignore("because this test broke with API version switch introduced in 7ed52bcfedd0bdcd2924ae14afe7ccb7bdc52c7a")
+// TODO(ANDR-1483)
 public class BackupServiceTest {
-	private final static String PASSWORD = "ubnpwrgujioasdfi0932";
-	private static final String TAG = "BackupServiceTest";
+    private final static String PASSWORD = "ubnpwrgujioasdfi0932";
+    private static final String TAG = "BackupServiceTest";
 
-	@SuppressWarnings("NotNullFieldNotInitialized")
-	private static @NonNull String TEST_IDENTITY;
+    @SuppressWarnings("NotNullFieldNotInitialized")
+    private static @NonNull String TEST_IDENTITY;
 
-	// Services
-	private @NonNull ServiceManager serviceManager;
-	private @NonNull FileService fileService;
+    // Services
+    private @NonNull ServiceManager serviceManager;
+    private @NonNull FileService fileService;
     private @NonNull MessageService messageService;
     private @NonNull ConversationService conversationService;
     private @NonNull GroupService groupService;
     private @NonNull ContactService contactService;
     private @NonNull DistributionListService distributionListService;
     private @NonNull BallotService ballotService;
-	private @NonNull APIConnector apiConnector;
-	private @NonNull ContactModelRepository contactModelRepository;
-
-	private final @NonNull BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
-
-	@Rule
-	public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
-
-	/**
-	 * Ensure that an identity is set up, initialize static {@link #TEST_IDENTITY} variable.
-	 */
-	@BeforeClass
-	public static void ensureIdentityExists() throws Exception {
-		// Set up identity
-		final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-		TEST_IDENTITY = TestHelpers.ensureIdentity(Objects.requireNonNull(serviceManager));
-	}
-
-	/**
-	 * Load Threema services.
-	 */
-	@Before
-	public void loadServices() throws Exception {
-		this.serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
-		this.fileService = serviceManager.getFileService();
-		this.messageService = serviceManager.getMessageService();
-		this.conversationService = serviceManager.getConversationService();
-		this.groupService = serviceManager.getGroupService();
-		this.contactService = serviceManager.getContactService();
-		this.distributionListService = serviceManager.getDistributionListService();
-		this.ballotService = serviceManager.getBallotService();
-		this.apiConnector = serviceManager.getAPIConnector();
-		this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
-	}
-
-	/**
-	 * Return the list of backups for the TEST_IDENTITY identity.
-	 */
-	private @NonNull List<File> getUserBackups(@NonNull File backupPath) {
-		if (backupPath.exists() && backupPath.isDirectory()) {
-			final File[] files = backupPath.listFiles(
-				(dir, name) -> name.startsWith("threema-backup_" + TEST_IDENTITY)
-			);
-			return files == null ? new ArrayList<>() : Arrays.asList(files);
-		} else {
-			return new ArrayList<>();
-		}
-	}
-
-	/**
-	 * Helper method: Create a backup with the specified config, return backup file.
-	 */
-	private @NonNull File doBackup(BackupRestoreDataConfig config) {
-		// List old backups
-		final File backupPath = this.fileService.getBackupPath();
-		final List<File> initialBackupFiles = this.getUserBackups(backupPath);
-
-
-		// Prepare service intent
-		final Context appContext = ApplicationProvider.getApplicationContext();
-		final Intent intent = new Intent(appContext, BackupService.class);
-		intent.putExtra(BackupService.EXTRA_BACKUP_RESTORE_DATA_CONFIG, config);
-
-		// Start service
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-			appContext.startForegroundService(intent);
-		}
-
-		appContext.startService(intent);
-		Assert.assertTrue(TestHelpers.iServiceRunning(appContext, BackupService.class));
-
-		// Wait for service to stop
-		while (TestHelpers.iServiceRunning(appContext, BackupService.class)) {
-			try {
-				Thread.sleep(100);
-			} catch (InterruptedException e) {
-				// ignore
-			}
-		}
-
-		// Check that a backup file has been created
-		Assert.assertTrue(backupPath.exists());
-		Assert.assertTrue(backupPath.isDirectory());
-		File backupFile = null;
-		for (File file : getUserBackups(backupPath)) {
-			if (!initialBackupFiles.contains(file)) {
-				if (backupFile != null) {
-					Assert.fail("Found more than one new backup: " + backupFile + " and " + file);
-				}
-				backupFile = file;
-			}
-		}
-		Assert.assertNotNull("New backup file not found", backupFile);
-		Assert.assertTrue(backupFile.exists());
-		Assert.assertTrue(backupFile.isFile());
-
-		return backupFile;
-	}
-
-	/**
-	 * Unpack the backup from the specified backup file and ensure
-	 * that the specified files are contained.
-	 */
-	private ZipFile openBackupFile(
-		@NonNull File backupFile,
-		@NonNull String[] expectedFiles
-	) throws Exception {
-		// Open ZIP
-		final ZipFile zipFile = new ZipFile(backupFile, PASSWORD.toCharArray());
-		Assert.assertTrue("Generated backup ZIP is invalid", zipFile.isValidZipFile());
-
-		// Ensure list of files is correct
-		final List<FileHeader> headers = zipFile.getFileHeaders();
-		Log.d(TAG, "File headers: " + Arrays.toString(headers.toArray()));
-		final Object[] actualFiles = StreamSupport.stream(headers)
-			.map(AbstractFileHeader::getFileName)
-			.toArray();
-		Assert.assertArrayEquals(
-			"Array is " + Arrays.toString(actualFiles),
-			expectedFiles,
-			actualFiles
-		);
-
-		return zipFile;
-	}
-
-	@Test
-	public void testBackupIdentity() throws Exception {
-		// Do backup
-		final File backupFile = doBackup(new BackupRestoreDataConfig(PASSWORD)
-			.setBackupContactAndMessages(false)
-			.setBackupIdentity(true)
-			.setBackupAvatars(false)
-			.setBackupMedia(false)
-			.setBackupThumbnails(false)
-			.setBackupNonces(false));
-
-		try {
-			final ZipFile zipFile = this.openBackupFile(backupFile, new String[]{ "settings", "identity" });
-
-			// Read identity backup
-			final String identityBackup;
-			try (final ZipInputStream stream = zipFile.getInputStream(zipFile.getFileHeader("identity"))) {
-				identityBackup = IOUtils.toString(stream);
-			}
-
-			// Verify identity backup
-			final IdentityBackupDecoder identityBackupDecoder = new IdentityBackupDecoder(identityBackup);
-			Assert.assertTrue("Could not decode identity backup", identityBackupDecoder.decode(PASSWORD));
-			Assert.assertEquals(TEST_IDENTITY, identityBackupDecoder.getIdentity());
-		} finally {
-			//noinspection ResultOfMethodCallIgnored
-			backupFile.delete();
-		}
-	}
+    private @NonNull APIConnector apiConnector;
+    private @NonNull ContactModelRepository contactModelRepository;
+
+    private final @NonNull BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
+
+    @Rule
+    public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
+
+    /**
+     * Ensure that an identity is set up, initialize static {@link #TEST_IDENTITY} variable.
+     */
+    @BeforeClass
+    public static void ensureIdentityExists() throws Exception {
+        // Set up identity
+        final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+        TEST_IDENTITY = TestHelpers.ensureIdentity(Objects.requireNonNull(serviceManager));
+    }
+
+    /**
+     * Load Threema services.
+     */
+    @Before
+    public void loadServices() throws Exception {
+        this.serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
+        this.fileService = serviceManager.getFileService();
+        this.messageService = serviceManager.getMessageService();
+        this.conversationService = serviceManager.getConversationService();
+        this.groupService = serviceManager.getGroupService();
+        this.contactService = serviceManager.getContactService();
+        this.distributionListService = serviceManager.getDistributionListService();
+        this.ballotService = serviceManager.getBallotService();
+        this.apiConnector = serviceManager.getAPIConnector();
+        this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
+    }
+
+    /**
+     * Return the list of backups for the TEST_IDENTITY identity.
+     */
+    private @NonNull List<File> getUserBackups(@NonNull File backupPath) {
+        if (backupPath.exists() && backupPath.isDirectory()) {
+            final File[] files = backupPath.listFiles(
+                (dir, name) -> name.startsWith("threema-backup_" + TEST_IDENTITY)
+            );
+            return files == null ? new ArrayList<>() : Arrays.asList(files);
+        } else {
+            return new ArrayList<>();
+        }
+    }
+
+    /**
+     * Helper method: Create a backup with the specified config, return backup file.
+     */
+    private @NonNull File doBackup(BackupRestoreDataConfig config) {
+        // List old backups
+        final File backupPath = this.fileService.getBackupPath();
+        final List<File> initialBackupFiles = this.getUserBackups(backupPath);
+
+
+        // Prepare service intent
+        final Context appContext = ApplicationProvider.getApplicationContext();
+        final Intent intent = new Intent(appContext, BackupService.class);
+        intent.putExtra(BackupService.EXTRA_BACKUP_RESTORE_DATA_CONFIG, config);
+
+        // Start service
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            appContext.startForegroundService(intent);
+        }
+
+        appContext.startService(intent);
+        Assert.assertTrue(TestHelpers.iServiceRunning(appContext, BackupService.class));
+
+        // Wait for service to stop
+        while (TestHelpers.iServiceRunning(appContext, BackupService.class)) {
+            try {
+                Thread.sleep(100);
+            } catch (InterruptedException e) {
+                // ignore
+            }
+        }
+
+        // Check that a backup file has been created
+        Assert.assertTrue(backupPath.exists());
+        Assert.assertTrue(backupPath.isDirectory());
+        File backupFile = null;
+        for (File file : getUserBackups(backupPath)) {
+            if (!initialBackupFiles.contains(file)) {
+                if (backupFile != null) {
+                    Assert.fail("Found more than one new backup: " + backupFile + " and " + file);
+                }
+                backupFile = file;
+            }
+        }
+        Assert.assertNotNull("New backup file not found", backupFile);
+        Assert.assertTrue(backupFile.exists());
+        Assert.assertTrue(backupFile.isFile());
+
+        return backupFile;
+    }
+
+    /**
+     * Unpack the backup from the specified backup file and ensure
+     * that the specified files are contained.
+     */
+    private ZipFile openBackupFile(
+        @NonNull File backupFile,
+        @NonNull String[] expectedFiles
+    ) throws Exception {
+        // Open ZIP
+        final ZipFile zipFile = new ZipFile(backupFile, PASSWORD.toCharArray());
+        Assert.assertTrue("Generated backup ZIP is invalid", zipFile.isValidZipFile());
+
+        // Ensure list of files is correct
+        final List<FileHeader> headers = zipFile.getFileHeaders();
+        Log.d(TAG, "File headers: " + Arrays.toString(headers.toArray()));
+        final Object[] actualFiles = StreamSupport.stream(headers)
+            .map(AbstractFileHeader::getFileName)
+            .toArray();
+        Assert.assertArrayEquals(
+            "Array is " + Arrays.toString(actualFiles),
+            expectedFiles,
+            actualFiles
+        );
+
+        return zipFile;
+    }
+
+    @Test
+    public void testBackupIdentity() throws Exception {
+        // Do backup
+        final File backupFile = doBackup(new BackupRestoreDataConfig(PASSWORD)
+            .setBackupContactAndMessages(false)
+            .setBackupIdentity(true)
+            .setBackupAvatars(false)
+            .setBackupMedia(false)
+            .setBackupThumbnails(false)
+            .setBackupNonces(false));
+
+        try {
+            final ZipFile zipFile = this.openBackupFile(backupFile, new String[]{"settings", "identity"});
+
+            // Read identity backup
+            final String identityBackup;
+            try (final ZipInputStream stream = zipFile.getInputStream(zipFile.getFileHeader("identity"))) {
+                identityBackup = IOUtils.toString(stream);
+            }
+
+            // Verify identity backup
+            final IdentityBackupDecoder identityBackupDecoder = new IdentityBackupDecoder(identityBackup);
+            Assert.assertTrue("Could not decode identity backup", identityBackupDecoder.decode(PASSWORD));
+            Assert.assertEquals(TEST_IDENTITY, identityBackupDecoder.getIdentity());
+        } finally {
+            //noinspection ResultOfMethodCallIgnored
+            backupFile.delete();
+        }
+    }
 
     @Test
     public void testBackupContactsAndMessages() throws Exception {
@@ -269,19 +270,19 @@ public class BackupServiceTest {
         this.ballotService.removeAll();
 
         // Insert test data:
-	    // Contacts
-	    final ContactModel contact1 = createContact("CDXVZ5E4");
-	    contact1.setFirstName("Fritzli");
-	    contact1.setLastName("Bühler");
-	    this.contactService.save(contact1);
-	    final ContactModel contact2 = createContact("DRMWZP3H");
-	    createContact("ECHOECHO");
-	    // Messages contact 1
-	    this.messageService.sendText("Bonjour!", this.contactService.createReceiver(contact1));
-	    this.messageService.sendText("Phở?", this.contactService.createReceiver(contact1));
-	    this.messageService.createVoipStatus(VoipStatusDataModel.createAborted(0), this.contactService.createReceiver(contact1), true, false);
-	    // Messages contact 2
-	    this.messageService.sendText("\uD83D\uDC4B", this.contactService.createReceiver(contact2));
+        // Contacts
+        final ContactModel contact1 = createContact("CDXVZ5E4");
+        contact1.setFirstName("Fritzli");
+        contact1.setLastName("Bühler");
+        this.contactService.save(contact1);
+        final ContactModel contact2 = createContact("DRMWZP3H");
+        createContact("ECHOECHO");
+        // Messages contact 1
+        this.messageService.sendText("Bonjour!", this.contactService.createReceiver(contact1));
+        this.messageService.sendText("Phở?", this.contactService.createReceiver(contact1));
+        this.messageService.createVoipStatus(VoipStatusDataModel.createAborted(0), this.contactService.createReceiver(contact1), true, false);
+        // Messages contact 2
+        this.messageService.sendText("\uD83D\uDC4B", this.contactService.createReceiver(contact2));
 
         // Do backup
         final File backupFile = doBackup(new BackupRestoreDataConfig(PASSWORD)
@@ -290,7 +291,7 @@ public class BackupServiceTest {
             .setBackupAvatars(false)
             .setBackupMedia(false)
             .setBackupThumbnails(false)
-	        .setBackupNonces(false));
+            .setBackupNonces(false));
 
         try {
             final ZipFile zipFile = this.openBackupFile(backupFile, new String[]{
@@ -308,82 +309,82 @@ public class BackupServiceTest {
 
             // Read contacts
             try (final ZipInputStream stream = zipFile.getInputStream(zipFile.getFileHeader("contacts.csv"))) {
-	            final CSVReader csvReader = new CSVReader(new InputStreamReader(stream), true);
-	            final CSVRow row1 = csvReader.readNextRow();
-	            Assert.assertEquals("CDXVZ5E4", row1.getString("identity"));
-	            Assert.assertEquals("Fritzli", row1.getString("firstname"));
-	            Assert.assertEquals("Bühler", row1.getString("lastname"));
-	            final CSVRow row2 = csvReader.readNextRow();
-	            Assert.assertEquals("DRMWZP3H", row2.getString("identity"));
-	            final CSVRow row3 = csvReader.readNextRow();
-	            Assert.assertEquals("ECHOECHO", row3.getString("identity"));
+                final CSVReader csvReader = new CSVReader(new InputStreamReader(stream), true);
+                final CSVRow row1 = csvReader.readNextRow();
+                Assert.assertEquals("CDXVZ5E4", row1.getString("identity"));
+                Assert.assertEquals("Fritzli", row1.getString("firstname"));
+                Assert.assertEquals("Bühler", row1.getString("lastname"));
+                final CSVRow row2 = csvReader.readNextRow();
+                Assert.assertEquals("DRMWZP3H", row2.getString("identity"));
+                final CSVRow row3 = csvReader.readNextRow();
+                Assert.assertEquals("ECHOECHO", row3.getString("identity"));
             }
 
-	        // Read messages
-	        try (final ZipInputStream stream = zipFile.getInputStream(zipFile.getFileHeader("message_CDXVZ5E4.csv"))) {
-		        final CSVReader csvReader = new CSVReader(new InputStreamReader(stream), true);
-		        // First, the two text messages
-		        final CSVRow row1 = csvReader.readNextRow();
-		        final CSVRow row2 = csvReader.readNextRow();
-		        Assert.assertTrue(row1.getBoolean("isoutbox"));
-		        Assert.assertTrue(row2.getBoolean("isoutbox"));
-		        Assert.assertEquals("TEXT", row1.getString("type"));
-		        Assert.assertEquals("TEXT", row2.getString("type"));
-		        Assert.assertEquals("Bonjour!", row1.getString("body"));
-		        Assert.assertEquals("Phở?", row2.getString("body"));
-		        // …followed by the VoIPstatus message
-		        final CSVRow row3 = csvReader.readNextRow();
-		        Assert.assertEquals("VOIP_STATUS", row3.getString("type"));
-		        Assert.assertEquals("[1,{\"status\":" + VoipStatusDataModel.ABORTED + "}]", row3.getString("body"));
-		        Assert.assertNull(csvReader.readNextRow());
-	        }
+            // Read messages
+            try (final ZipInputStream stream = zipFile.getInputStream(zipFile.getFileHeader("message_CDXVZ5E4.csv"))) {
+                final CSVReader csvReader = new CSVReader(new InputStreamReader(stream), true);
+                // First, the two text messages
+                final CSVRow row1 = csvReader.readNextRow();
+                final CSVRow row2 = csvReader.readNextRow();
+                Assert.assertTrue(row1.getBoolean("isoutbox"));
+                Assert.assertTrue(row2.getBoolean("isoutbox"));
+                Assert.assertEquals("TEXT", row1.getString("type"));
+                Assert.assertEquals("TEXT", row2.getString("type"));
+                Assert.assertEquals("Bonjour!", row1.getString("body"));
+                Assert.assertEquals("Phở?", row2.getString("body"));
+                // …followed by the VoIPstatus message
+                final CSVRow row3 = csvReader.readNextRow();
+                Assert.assertEquals("VOIP_STATUS", row3.getString("type"));
+                Assert.assertEquals("[1,{\"status\":" + VoipStatusDataModel.ABORTED + "}]", row3.getString("body"));
+                Assert.assertNull(csvReader.readNextRow());
+            }
         } finally {
             //noinspection ResultOfMethodCallIgnored
             backupFile.delete();
         }
     }
 
-	@NonNull
-	@WorkerThread
-	private ContactModel createContact(@NonNull String identity) {
-		new BasicAddOrUpdateContactBackgroundTask(
-			identity,
-			ContactModel.AcquaintanceLevel.DIRECT,
-			TEST_IDENTITY,
-			apiConnector,
-			contactModelRepository,
-			AddContactRestrictionPolicy.CHECK,
-			ApplicationProvider.getApplicationContext(),
-			null
-		).runSynchronously();
-
-		ContactModel contactModel = contactService.getByIdentity(identity);
-		if (contactModel == null) {
-			throw new IllegalStateException("Contact is null after creating it");
-		}
-		return contactModel;
-	}
-
-	@NonNull
-	private DeleteAllContactsBackgroundTask getContactDeleteTask() throws ThreemaException {
-		return new DeleteAllContactsBackgroundTask(
-			serviceManager.getModelRepositories().getContacts(),
-			new DeleteContactServices(
-				serviceManager.getUserService(),
-				serviceManager.getContactService(),
-				serviceManager.getConversationService(),
-				serviceManager.getRingtoneService(),
-				serviceManager.getMutedChatsListService(),
-				serviceManager.getHiddenChatsListService(),
-				serviceManager.getProfilePicRecipientsService(),
-				serviceManager.getWallpaperService(),
-				serviceManager.getFileService(),
-				serviceManager.getExcludedSyncIdentitiesService(),
-				serviceManager.getDHSessionStore(),
-				serviceManager.getNotificationService(),
-				serviceManager.getDatabaseServiceNew()
-			)
-		);
-	}
+    @NonNull
+    @WorkerThread
+    private ContactModel createContact(@NonNull String identity) {
+        new BasicAddOrUpdateContactBackgroundTask(
+            identity,
+            ContactModel.AcquaintanceLevel.DIRECT,
+            TEST_IDENTITY,
+            apiConnector,
+            contactModelRepository,
+            AddContactRestrictionPolicy.CHECK,
+            ApplicationProvider.getApplicationContext(),
+            null
+        ).runSynchronously();
+
+        ContactModel contactModel = contactService.getByIdentity(identity);
+        if (contactModel == null) {
+            throw new IllegalStateException("Contact is null after creating it");
+        }
+        return contactModel;
+    }
+
+    @NonNull
+    private DeleteAllContactsBackgroundTask getContactDeleteTask() throws ThreemaException {
+        return new DeleteAllContactsBackgroundTask(
+            serviceManager.getModelRepositories().getContacts(),
+            new DeleteContactServices(
+                serviceManager.getUserService(),
+                serviceManager.getContactService(),
+                serviceManager.getConversationService(),
+                serviceManager.getRingtoneService(),
+                serviceManager.getMutedChatsListService(),
+                serviceManager.getHiddenChatsListService(),
+                serviceManager.getProfilePicRecipientsService(),
+                serviceManager.getWallpaperService(),
+                serviceManager.getFileService(),
+                serviceManager.getExcludedSyncIdentitiesService(),
+                serviceManager.getDHSessionStore(),
+                serviceManager.getNotificationService(),
+                serviceManager.getDatabaseServiceNew()
+            )
+        );
+    }
 
 }

+ 4 - 1
app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt

@@ -412,7 +412,10 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertTrue(it.acquaintanceLevelChanged)
                 assertTrue(it.verificationLevelChanged)
                 assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value!!.acquaintanceLevel)
-                assertEquals(VerificationLevel.FULLY_VERIFIED, contactModel.data.value!!.verificationLevel)
+                assertEquals(
+                    VerificationLevel.FULLY_VERIFIED,
+                    contactModel.data.value!!.verificationLevel
+                )
             },
             newIdentity = newIdentity,
             publicKey = ByteArray(NaCl.PUBLICKEYBYTES)

+ 4 - 1
app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt

@@ -164,7 +164,10 @@ class ReflectedContactSyncTaskTest {
             assertEquals(contact.featureMask, data.featureMask.toLong())
             assertEquals(contact.syncState.convert(), data.syncState)
             assertEquals(contact.readReceiptPolicyOverride.convert(), data.readReceiptPolicy)
-            assertEquals(contact.typingIndicatorPolicyOverride.convert(), data.typingIndicatorPolicy)
+            assertEquals(
+                contact.typingIndicatorPolicyOverride.convert(),
+                data.typingIndicatorPolicy
+            )
         }
     }
 

+ 20 - 10
app/src/androidTest/java/ch/threema/app/edithistory/EditHistoryTest.kt

@@ -329,14 +329,19 @@ class EditHistoryTest : MessageProcessorProvider() {
 
         processMessage(message, contactA.identityStore)
 
-        return messageModelFactory.getByApiMessageIdAndIdentity(message.messageId, message.fromIdentity)!!
+        return messageModelFactory.getByApiMessageIdAndIdentity(
+            message.messageId,
+            message.fromIdentity
+        )!!
     }
 
     private suspend fun MessageModel.receiveEdit() {
-        val editMessage = EditMessage(EditMessageData(
-            MessageId.fromString(apiMessageId).messageIdLong,
-            "$body Edited"
-        )).apply {
+        val editMessage = EditMessage(
+            EditMessageData(
+                MessageId.fromString(apiMessageId).messageIdLong,
+                "$body Edited"
+            )
+        ).apply {
             fromIdentity = identity
             toIdentity = myContact.identity
         }
@@ -365,14 +370,19 @@ class EditHistoryTest : MessageProcessorProvider() {
 
         processMessage(message, contactA.identityStore)
 
-        return groupMessageModelFactory.getByApiMessageIdAndIdentity(message.messageId, message.fromIdentity)!!
+        return groupMessageModelFactory.getByApiMessageIdAndIdentity(
+            message.messageId,
+            message.fromIdentity
+        )!!
     }
 
     private suspend fun GroupMessageModel.receiveEdit() {
-        val editMessage = GroupEditMessage(EditMessageData(
-            MessageId.fromString(apiMessageId).messageIdLong,
-            "$body Edited"
-        )).apply {
+        val editMessage = GroupEditMessage(
+            EditMessageData(
+                MessageId.fromString(apiMessageId).messageIdLong,
+                "$body Edited"
+            )
+        ).apply {
             apiGroupId = groupA.apiGroupId
             groupCreator = groupA.groupCreator.identity
             fromIdentity = identity

+ 283 - 283
app/src/androidTest/java/ch/threema/app/emojis/MarkupParserTest.java

@@ -39,287 +39,287 @@ import static junit.framework.Assert.assertEquals;
 @RunWith(AndroidJUnit4.class)
 public class MarkupParserTest {
 
-	static class Utils {
-		/**
-		 * Parse a String, return a SpannableStringBuilder.
-		 */
-		static SpannableStringBuilder parse(String input) {
-			final MarkupParser parser = MarkupParser.getInstance();
-			SpannableStringBuilder builder = new SpannableStringBuilder(input);
-			parser.markify(builder);
-			return builder;
-		}
-
-		/**
-		 * Expect a certain number of spans.
-		 */
-		static void expectSpanCount(SpannableStringBuilder parsed, int expectedCount) {
-			int actualCount = parsed.getSpans(0, 9999, CharacterStyle.class).length;
-			assertEquals(expectedCount, actualCount);
-		}
-
-		private static void expectBoldOrItalicAt(SpannableStringBuilder parsed, int style, int start, int end) {
-			final StyleSpan[] spans = parsed.getSpans(start, end, StyleSpan.class);
-			assertEquals(1, spans.length);
-			assertEquals("Wrong span style", style, spans[0].getStyle());
-			assertEquals("Wrong span start", start, parsed.getSpanStart(spans[0]));
-			assertEquals("Wrong span end", end, parsed.getSpanEnd(spans[0]));
-		}
-
-		/**
-		 * Expect a bold span at the specified range.
-		 */
-		static void expectBoldAt(SpannableStringBuilder parsed, int start, int end) {
-			Utils.expectBoldOrItalicAt(parsed, Typeface.BOLD, start, end);
-		}
-
-		/**
-		 * Expect an italic span at the specified range.
-		 */
-		static void expectItalicAt(SpannableStringBuilder parsed, int start, int end) {
-			Utils.expectBoldOrItalicAt(parsed, Typeface.ITALIC, start, end);
-		}
-
-		/**
-		 * Expect a strikethrough span at the specified range.
-		 */
-		static void expectStrikethroughAt(SpannableStringBuilder parsed, int start, int end) {
-			final StrikethroughSpan[] spans = parsed.getSpans(start, end, StrikethroughSpan.class);
-			assertEquals(1, spans.length);
-			assertEquals(start, parsed.getSpanStart(spans[0]));
-			assertEquals(end, parsed.getSpanEnd(spans[0]));
-		}
-
-		/**
-		 * Input and output are identical, no spans.
-		 */
-		static void expectNoSpan(String input) {
-			final SpannableStringBuilder parsed = Utils.parse(input);
-			assertEquals(input, parsed.toString());
-			Utils.expectSpanCount(parsed, 0);
-		}
-	}
-
-	@Test
-	public void parseBold() {
-		final SpannableStringBuilder parsed = Utils.parse("hello *there*");
-		assertEquals("hello there", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectBoldAt(parsed, 6, 11);
-	}
-
-	@Test
-	public void parseItalic() {
-		final SpannableStringBuilder parsed = Utils.parse("hello _there_");
-		assertEquals("hello there", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectItalicAt(parsed, 6, 11);
-	}
-
-	@Test
-	public void parseStrikethrough() {
-		final SpannableStringBuilder parsed = Utils.parse("hello ~there~");
-		assertEquals("hello there", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectStrikethroughAt(parsed, 6, 11);
-	}
-
-	@Test
-	public void parseTwoBold() {
-		final SpannableStringBuilder parsed = Utils.parse("two *bold* *parts*");
-		assertEquals("two bold parts", parsed.toString());
-		Utils.expectSpanCount(parsed, 2);
-		Utils.expectBoldAt(parsed, 4, 8);
-		Utils.expectBoldAt(parsed, 9, 14);
-	}
-
-	@Test
-	public void parseTwoItalic() {
-		final SpannableStringBuilder parsed = Utils.parse("two _italic_ _bits_");
-		assertEquals("two italic bits", parsed.toString());
-		Utils.expectSpanCount(parsed, 2);
-		Utils.expectItalicAt(parsed, 4, 10);
-		Utils.expectItalicAt(parsed, 11, 15);
-	}
-
-	@Test
-	public void parseTwoStrikethrough() {
-		final SpannableStringBuilder parsed = Utils.parse("two ~striked~ ~through~");
-		assertEquals("two striked through", parsed.toString());
-		Utils.expectSpanCount(parsed, 2);
-		Utils.expectStrikethroughAt(parsed, 4, 11);
-		Utils.expectStrikethroughAt(parsed, 12, 19);
-	}
-
-	@Test
-	public void parseMixedMarkup() {
-		final SpannableStringBuilder parsed = Utils.parse("*bold* and _italic_");
-		assertEquals("bold and italic", parsed.toString());
-		Utils.expectSpanCount(parsed, 2);
-		Utils.expectBoldAt(parsed, 0, 4);
-		Utils.expectItalicAt(parsed, 9, 15);
-	}
-
-	@Test
-	public void parseMixedMarkupNested() {
-		final SpannableStringBuilder parsed = Utils.parse("*bold with _italic_*");
-		assertEquals("bold with italic", parsed.toString());
-
-		final StyleSpan[] spans = parsed.getSpans(0, 9999, StyleSpan.class);
-		assertEquals(2, spans.length);
-		assertEquals("Wrong span start (1)", 0, parsed.getSpanStart(spans[0]));
-		assertEquals("Wrong span end (1)", 16, parsed.getSpanEnd(spans[0]));
-		assertEquals("Wrong span style (1)", Typeface.BOLD, spans[0].getStyle());
-		assertEquals("Wrong span start (2)", 10, parsed.getSpanStart(spans[1]));
-		assertEquals("Wrong span end (2)", 16, parsed.getSpanEnd(spans[1]));
-		assertEquals("Wrong span style (2)", Typeface.ITALIC, spans[1].getStyle());
-	}
-
-	@Test
-	public void atWordBoundaries1() {
-		final SpannableStringBuilder parsed = Utils.parse("(*bold*)");
-		assertEquals("(bold)", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectBoldAt(parsed, 1, 5);
-	}
-
-	@Test
-	public void atWordBoundaries2() {
-		final SpannableStringBuilder parsed = Utils.parse("¡*Threema* es fantástico!");
-		assertEquals("¡Threema es fantástico!", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectBoldAt(parsed, 1, 8);
-	}
-
-	@Test
-	public void atWordBoundaries3() {
-		final SpannableStringBuilder parsed = Utils.parse("«_great_ service»");
-		assertEquals("«great service»", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectItalicAt(parsed, 1, 6);
-	}
-
-	@Test
-	public void atWordBoundaries4() {
-		final SpannableStringBuilder parsed = Utils.parse("\"_great_ service\"");
-		assertEquals("\"great service\"", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectItalicAt(parsed, 1, 6);
-	}
-
-	@Test
-	public void atWordBoundaries5() {
-		final SpannableStringBuilder parsed = Utils.parse("*bold*…");
-		assertEquals("bold…", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectBoldAt(parsed, 0, 4);
-	}
-
-	@Test
-	public void atWordBoundaries6() {
-		final SpannableStringBuilder parsed = Utils.parse("_<a href=\"https://threema.ch\">Threema</a>_");
-		assertEquals("<a href=\"https://threema.ch\">Threema</a>", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectItalicAt(parsed, 0, 40);
-	}
-
-	@Test
-	public void onlyWordBoundaries1() {
-		Utils.expectNoSpan("so not_really_italic");
-	}
-
-	@Test
-	public void onlyWordBoundaries2() {
-		Utils.expectNoSpan("invalid*bold*stuff");
-	}
-
-	@Test
-	public void onlyWordBoundaries3() {
-		Utils.expectNoSpan("no~strike~through");
-	}
-
-	@Test
-	public void onlyWordBoundaries4() {
-		Utils.expectNoSpan("<_< >_>");
-	}
-
-	@Test
-	public void onlyWordBoundaries5() {
-		Utils.expectNoSpan("<a href=\"https://threema.ch\">_Threema_</a>");
-	}
-
-	@Test
-	public void onlyWordBoundaries6() {
-		final SpannableStringBuilder parsed = Utils.parse("*bold_but_no~strike~through*");
-		assertEquals("bold_but_no~strike~through", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectBoldAt(parsed, 0, 26);
-	}
-
-	@Test
-	public void avoidBreakingUrls1() {
-		Utils.expectNoSpan("https://example.com/_output_/");
-	}
-
-	@Test
-	public void avoidBreakingUrls2() {
-		Utils.expectNoSpan("https://example.com/*output*/");
-	}
-
-	@Test
-	public void avoidBreakingUrls3() {
-		Utils.expectNoSpan("https://example.com?__twitter_impression=true");
-	}
-
-	@Test
-	public void avoidBreakingUrls4() {
-		Utils.expectNoSpan("https://example.com?_twitter_impression=true");
-	}
-
-	@Test
-	public void avoidBreakingUrls5() {
-		final SpannableStringBuilder parsed = Utils.parse("https://en.wikipedia.org/wiki/Java_class_file *nice*");
-		assertEquals("https://en.wikipedia.org/wiki/Java_class_file nice", parsed.toString());
-		Utils.expectSpanCount(parsed, 1);
-		Utils.expectBoldAt(parsed, 46, 50);
-	}
-
-	@Test
-	public void avoidBreakingUrls6() {
-		Utils.expectNoSpan("https://example.com/image_-_1.jpg");
-	}
-
-	@Test
-	public void ignoreInvalidMarkup1() {
-		Utils.expectNoSpan("*invalid markup (do not parse)_");
-	}
-
-	@Test
-	public void ignoreInvalidMarkup2() {
-		Utils.expectNoSpan("random *asterisk");
-	}
-
-	@Test
-	public void notAcrossNewlines1() {
-		Utils.expectNoSpan("*First line\n and a new one. (do not parse)*");
-	}
-
-	@Test
-	public void notAcrossNewlines2() {
-		Utils.expectNoSpan("*\nbegins with linebreak. (do not parse)*");
-	}
-
-	@Test
-	public void notAcrossNewlines3() {
-		Utils.expectNoSpan("*Just some text. But it ends with newline (do not parse)\n*");
-	}
-
-	@Test
-	public void notAcrossNewlines4() {
-		final SpannableStringBuilder parsed = Utils.parse("_*first line*\n*second* line_");
-		assertEquals("_first line\nsecond line_", parsed.toString());
-		Utils.expectSpanCount(parsed, 2);
-		Utils.expectBoldAt(parsed, 1, 11);
-		Utils.expectBoldAt(parsed, 12, 18);
-	}
+    static class Utils {
+        /**
+         * Parse a String, return a SpannableStringBuilder.
+         */
+        static SpannableStringBuilder parse(String input) {
+            final MarkupParser parser = MarkupParser.getInstance();
+            SpannableStringBuilder builder = new SpannableStringBuilder(input);
+            parser.markify(builder);
+            return builder;
+        }
+
+        /**
+         * Expect a certain number of spans.
+         */
+        static void expectSpanCount(SpannableStringBuilder parsed, int expectedCount) {
+            int actualCount = parsed.getSpans(0, 9999, CharacterStyle.class).length;
+            assertEquals(expectedCount, actualCount);
+        }
+
+        private static void expectBoldOrItalicAt(SpannableStringBuilder parsed, int style, int start, int end) {
+            final StyleSpan[] spans = parsed.getSpans(start, end, StyleSpan.class);
+            assertEquals(1, spans.length);
+            assertEquals("Wrong span style", style, spans[0].getStyle());
+            assertEquals("Wrong span start", start, parsed.getSpanStart(spans[0]));
+            assertEquals("Wrong span end", end, parsed.getSpanEnd(spans[0]));
+        }
+
+        /**
+         * Expect a bold span at the specified range.
+         */
+        static void expectBoldAt(SpannableStringBuilder parsed, int start, int end) {
+            Utils.expectBoldOrItalicAt(parsed, Typeface.BOLD, start, end);
+        }
+
+        /**
+         * Expect an italic span at the specified range.
+         */
+        static void expectItalicAt(SpannableStringBuilder parsed, int start, int end) {
+            Utils.expectBoldOrItalicAt(parsed, Typeface.ITALIC, start, end);
+        }
+
+        /**
+         * Expect a strikethrough span at the specified range.
+         */
+        static void expectStrikethroughAt(SpannableStringBuilder parsed, int start, int end) {
+            final StrikethroughSpan[] spans = parsed.getSpans(start, end, StrikethroughSpan.class);
+            assertEquals(1, spans.length);
+            assertEquals(start, parsed.getSpanStart(spans[0]));
+            assertEquals(end, parsed.getSpanEnd(spans[0]));
+        }
+
+        /**
+         * Input and output are identical, no spans.
+         */
+        static void expectNoSpan(String input) {
+            final SpannableStringBuilder parsed = Utils.parse(input);
+            assertEquals(input, parsed.toString());
+            Utils.expectSpanCount(parsed, 0);
+        }
+    }
+
+    @Test
+    public void parseBold() {
+        final SpannableStringBuilder parsed = Utils.parse("hello *there*");
+        assertEquals("hello there", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectBoldAt(parsed, 6, 11);
+    }
+
+    @Test
+    public void parseItalic() {
+        final SpannableStringBuilder parsed = Utils.parse("hello _there_");
+        assertEquals("hello there", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectItalicAt(parsed, 6, 11);
+    }
+
+    @Test
+    public void parseStrikethrough() {
+        final SpannableStringBuilder parsed = Utils.parse("hello ~there~");
+        assertEquals("hello there", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectStrikethroughAt(parsed, 6, 11);
+    }
+
+    @Test
+    public void parseTwoBold() {
+        final SpannableStringBuilder parsed = Utils.parse("two *bold* *parts*");
+        assertEquals("two bold parts", parsed.toString());
+        Utils.expectSpanCount(parsed, 2);
+        Utils.expectBoldAt(parsed, 4, 8);
+        Utils.expectBoldAt(parsed, 9, 14);
+    }
+
+    @Test
+    public void parseTwoItalic() {
+        final SpannableStringBuilder parsed = Utils.parse("two _italic_ _bits_");
+        assertEquals("two italic bits", parsed.toString());
+        Utils.expectSpanCount(parsed, 2);
+        Utils.expectItalicAt(parsed, 4, 10);
+        Utils.expectItalicAt(parsed, 11, 15);
+    }
+
+    @Test
+    public void parseTwoStrikethrough() {
+        final SpannableStringBuilder parsed = Utils.parse("two ~striked~ ~through~");
+        assertEquals("two striked through", parsed.toString());
+        Utils.expectSpanCount(parsed, 2);
+        Utils.expectStrikethroughAt(parsed, 4, 11);
+        Utils.expectStrikethroughAt(parsed, 12, 19);
+    }
+
+    @Test
+    public void parseMixedMarkup() {
+        final SpannableStringBuilder parsed = Utils.parse("*bold* and _italic_");
+        assertEquals("bold and italic", parsed.toString());
+        Utils.expectSpanCount(parsed, 2);
+        Utils.expectBoldAt(parsed, 0, 4);
+        Utils.expectItalicAt(parsed, 9, 15);
+    }
+
+    @Test
+    public void parseMixedMarkupNested() {
+        final SpannableStringBuilder parsed = Utils.parse("*bold with _italic_*");
+        assertEquals("bold with italic", parsed.toString());
+
+        final StyleSpan[] spans = parsed.getSpans(0, 9999, StyleSpan.class);
+        assertEquals(2, spans.length);
+        assertEquals("Wrong span start (1)", 0, parsed.getSpanStart(spans[0]));
+        assertEquals("Wrong span end (1)", 16, parsed.getSpanEnd(spans[0]));
+        assertEquals("Wrong span style (1)", Typeface.BOLD, spans[0].getStyle());
+        assertEquals("Wrong span start (2)", 10, parsed.getSpanStart(spans[1]));
+        assertEquals("Wrong span end (2)", 16, parsed.getSpanEnd(spans[1]));
+        assertEquals("Wrong span style (2)", Typeface.ITALIC, spans[1].getStyle());
+    }
+
+    @Test
+    public void atWordBoundaries1() {
+        final SpannableStringBuilder parsed = Utils.parse("(*bold*)");
+        assertEquals("(bold)", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectBoldAt(parsed, 1, 5);
+    }
+
+    @Test
+    public void atWordBoundaries2() {
+        final SpannableStringBuilder parsed = Utils.parse("¡*Threema* es fantástico!");
+        assertEquals("¡Threema es fantástico!", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectBoldAt(parsed, 1, 8);
+    }
+
+    @Test
+    public void atWordBoundaries3() {
+        final SpannableStringBuilder parsed = Utils.parse("«_great_ service»");
+        assertEquals("«great service»", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectItalicAt(parsed, 1, 6);
+    }
+
+    @Test
+    public void atWordBoundaries4() {
+        final SpannableStringBuilder parsed = Utils.parse("\"_great_ service\"");
+        assertEquals("\"great service\"", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectItalicAt(parsed, 1, 6);
+    }
+
+    @Test
+    public void atWordBoundaries5() {
+        final SpannableStringBuilder parsed = Utils.parse("*bold*…");
+        assertEquals("bold…", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectBoldAt(parsed, 0, 4);
+    }
+
+    @Test
+    public void atWordBoundaries6() {
+        final SpannableStringBuilder parsed = Utils.parse("_<a href=\"https://threema.ch\">Threema</a>_");
+        assertEquals("<a href=\"https://threema.ch\">Threema</a>", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectItalicAt(parsed, 0, 40);
+    }
+
+    @Test
+    public void onlyWordBoundaries1() {
+        Utils.expectNoSpan("so not_really_italic");
+    }
+
+    @Test
+    public void onlyWordBoundaries2() {
+        Utils.expectNoSpan("invalid*bold*stuff");
+    }
+
+    @Test
+    public void onlyWordBoundaries3() {
+        Utils.expectNoSpan("no~strike~through");
+    }
+
+    @Test
+    public void onlyWordBoundaries4() {
+        Utils.expectNoSpan("<_< >_>");
+    }
+
+    @Test
+    public void onlyWordBoundaries5() {
+        Utils.expectNoSpan("<a href=\"https://threema.ch\">_Threema_</a>");
+    }
+
+    @Test
+    public void onlyWordBoundaries6() {
+        final SpannableStringBuilder parsed = Utils.parse("*bold_but_no~strike~through*");
+        assertEquals("bold_but_no~strike~through", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectBoldAt(parsed, 0, 26);
+    }
+
+    @Test
+    public void avoidBreakingUrls1() {
+        Utils.expectNoSpan("https://example.com/_output_/");
+    }
+
+    @Test
+    public void avoidBreakingUrls2() {
+        Utils.expectNoSpan("https://example.com/*output*/");
+    }
+
+    @Test
+    public void avoidBreakingUrls3() {
+        Utils.expectNoSpan("https://example.com?__twitter_impression=true");
+    }
+
+    @Test
+    public void avoidBreakingUrls4() {
+        Utils.expectNoSpan("https://example.com?_twitter_impression=true");
+    }
+
+    @Test
+    public void avoidBreakingUrls5() {
+        final SpannableStringBuilder parsed = Utils.parse("https://en.wikipedia.org/wiki/Java_class_file *nice*");
+        assertEquals("https://en.wikipedia.org/wiki/Java_class_file nice", parsed.toString());
+        Utils.expectSpanCount(parsed, 1);
+        Utils.expectBoldAt(parsed, 46, 50);
+    }
+
+    @Test
+    public void avoidBreakingUrls6() {
+        Utils.expectNoSpan("https://example.com/image_-_1.jpg");
+    }
+
+    @Test
+    public void ignoreInvalidMarkup1() {
+        Utils.expectNoSpan("*invalid markup (do not parse)_");
+    }
+
+    @Test
+    public void ignoreInvalidMarkup2() {
+        Utils.expectNoSpan("random *asterisk");
+    }
+
+    @Test
+    public void notAcrossNewlines1() {
+        Utils.expectNoSpan("*First line\n and a new one. (do not parse)*");
+    }
+
+    @Test
+    public void notAcrossNewlines2() {
+        Utils.expectNoSpan("*\nbegins with linebreak. (do not parse)*");
+    }
+
+    @Test
+    public void notAcrossNewlines3() {
+        Utils.expectNoSpan("*Just some text. But it ends with newline (do not parse)\n*");
+    }
+
+    @Test
+    public void notAcrossNewlines4() {
+        final SpannableStringBuilder parsed = Utils.parse("_*first line*\n*second* line_");
+        assertEquals("_first line\nsecond line_", parsed.toString());
+        Utils.expectSpanCount(parsed, 2);
+        Utils.expectBoldAt(parsed, 1, 11);
+        Utils.expectBoldAt(parsed, 12, 18);
+    }
 }

+ 14 - 2
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt

@@ -67,7 +67,13 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
 
         // Create group rename message
         val groupARenamed =
-            TestGroup(groupA.apiGroupId, groupA.groupCreator, groupA.members, "GroupARenamed", myContact.identity)
+            TestGroup(
+                groupA.apiGroupId,
+                groupA.groupCreator,
+                groupA.members,
+                "GroupARenamed",
+                myContact.identity
+            )
 
         val renameTracker = GroupRenameTracker(groupARenamed).apply { start() }
 
@@ -103,7 +109,13 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
 
         // Create group rename message (from wrong sender)
         val groupARenamed =
-            TestGroup(groupA.apiGroupId, groupA.groupCreator, groupA.members, "GroupARenamed", myContact.identity)
+            TestGroup(
+                groupA.apiGroupId,
+                groupA.groupCreator,
+                groupA.members,
+                "GroupARenamed",
+                myContact.identity
+            )
 
         val renameTracker = GroupRenameTracker(null).apply { start() }
 

+ 35 - 15
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt

@@ -181,7 +181,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         // Assert that one message is for contact A and the other for contact B
         assertTrue(
             (first.toIdentity == contactA.identity && second.toIdentity == contactB.identity)
-                    || (first.toIdentity == contactB.identity && second.toIdentity == contactA.identity)
+                || (first.toIdentity == contactB.identity && second.toIdentity == contactA.identity)
         )
 
         // Assert that no action has been triggered
@@ -332,20 +332,39 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         setupTracker.stop()
 
         // Assert that the group has the correct members
-        val group = serviceManager.groupService.getByApiGroupIdAndCreator(newGroup.apiGroupId, newGroup.groupCreator.identity)
+        val group = serviceManager.groupService.getByApiGroupIdAndCreator(
+            newGroup.apiGroupId,
+            newGroup.groupCreator.identity
+        )
         assertNotNull(group!!)
         val expectedMemberCount = newGroup.members.size
         // Assert that there is one more member than member models (as the user is not stored into
         // the database).
-        assertEquals(expectedMemberCount, serviceManager.databaseServiceNew.groupMemberModelFactory.getByGroupId(group.id).size + 1)
-        assertEquals(expectedMemberCount, serviceManager.databaseServiceNew.groupMemberModelFactory.countMembersWithoutUser(group.id).toInt() + 1)
+        assertEquals(
+            expectedMemberCount,
+            serviceManager.databaseServiceNew.groupMemberModelFactory.getByGroupId(group.id).size + 1
+        )
+        assertEquals(
+            expectedMemberCount,
+            serviceManager.databaseServiceNew.groupMemberModelFactory.countMembersWithoutUser(group.id)
+                .toInt() + 1
+        )
 
         // Assert that the group service returns the member lists including the user
         assertEquals(expectedMemberCount, serviceManager.groupService.getMembers(group).size)
-        assertEquals(expectedMemberCount, serviceManager.groupService.getGroupIdentities(group).size)
-        assertEquals(expectedMemberCount, serviceManager.groupService.getMembersWithoutUser(group).size + 1)
+        assertEquals(
+            expectedMemberCount,
+            serviceManager.groupService.getGroupIdentities(group).size
+        )
+        assertEquals(
+            expectedMemberCount,
+            serviceManager.groupService.getMembersWithoutUser(group).size + 1
+        )
         assertEquals(expectedMemberCount, serviceManager.groupService.countMembers(group))
-        assertEquals(expectedMemberCount, serviceManager.groupService.countMembersWithoutUser(group) + 1)
+        assertEquals(
+            expectedMemberCount,
+            serviceManager.groupService.countMembersWithoutUser(group) + 1
+        )
     }
 
     /**
@@ -445,14 +464,15 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
     private fun createGroupSetupMessage(testGroup: TestGroup) = GroupSetupMessage()
         .apply {
-        apiGroupId = testGroup.apiGroupId
-        groupCreator = testGroup.groupCreator.identity
-        fromIdentity = testGroup.groupCreator.identity
-        toIdentity = myContact.identity
-        members =
-            testGroup.members.map { it.identity }.filter { it != testGroup.groupCreator.identity }
-                .toTypedArray()
-    }
+            apiGroupId = testGroup.apiGroupId
+            groupCreator = testGroup.groupCreator.identity
+            fromIdentity = testGroup.groupCreator.identity
+            toIdentity = myContact.identity
+            members =
+                testGroup.members.map { it.identity }
+                    .filter { it != testGroup.groupCreator.identity }
+                    .toTypedArray()
+        }
 
     private class GroupSetupTracker(
         private val group: TestGroup?,

+ 15 - 15
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt

@@ -109,11 +109,11 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
         // Create group sync request message
         val groupSyncRequestMessage = GroupSyncRequestMessage()
             .apply {
-            fromIdentity = contact.identity
-            toIdentity = myContact.identity
-            apiGroupId = group.apiGroupId
-            groupCreator = group.groupCreator.identity
-        }
+                fromIdentity = contact.identity
+                toIdentity = myContact.identity
+                apiGroupId = group.apiGroupId
+                groupCreator = group.groupCreator.identity
+            }
 
         // Process sync request message
         processMessage(groupSyncRequestMessage, contact.identityStore)
@@ -152,11 +152,11 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
         // Create group sync request message
         val groupSyncRequestMessage = GroupSyncRequestMessage()
             .apply {
-            fromIdentity = contact.identity
-            toIdentity = myContact.identity
-            apiGroupId = group.apiGroupId
-            groupCreator = group.groupCreator.identity
-        }
+                fromIdentity = contact.identity
+                toIdentity = myContact.identity
+                apiGroupId = group.apiGroupId
+                groupCreator = group.groupCreator.identity
+            }
 
         processMessage(groupSyncRequestMessage, contact.identityStore)
 
@@ -169,11 +169,11 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
         // Create group sync request message
         val groupSyncRequestMessage = GroupSyncRequestMessage()
             .apply {
-            fromIdentity = contact.identity
-            toIdentity = myContact.identity
-            apiGroupId = group.apiGroupId
-            groupCreator = group.groupCreator.identity
-        }
+                fromIdentity = contact.identity
+                toIdentity = myContact.identity
+                apiGroupId = group.apiGroupId
+                groupCreator = group.groupCreator.identity
+            }
 
         processMessage(groupSyncRequestMessage, contact.identityStore)
 

+ 4 - 1
app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt

@@ -230,7 +230,10 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         if (expectDeliveryReceiptSent) {
             val deliveryReceiptMessage = sentMessagesInsideTask.poll()
             if (deliveryReceiptMessage is DeliveryReceiptMessage) {
-                assertArrayEquals(messageId.messageId, deliveryReceiptMessage.receiptMessageIds[0].messageId)
+                assertArrayEquals(
+                    messageId.messageId,
+                    deliveryReceiptMessage.receiptMessageIds[0].messageId
+                )
                 assertEquals(DELIVERYRECEIPT_MSGRECEIVED, deliveryReceiptMessage.receiptType)
             } else {
                 fail("Instead of delivery receipt we got $deliveryReceiptMessage")

+ 50 - 7
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt

@@ -95,7 +95,13 @@ open class MessageProcessorProvider {
     protected val contactB = TestContact("ABCDEFGH")
     protected val contactC = TestContact("TESTTEST")
 
-    protected val myGroup = TestGroup(GroupId(0), myContact, listOf(myContact, contactA, contactB), "MyGroup", myContact.identity)
+    protected val myGroup = TestGroup(
+        GroupId(0),
+        myContact,
+        listOf(myContact, contactA, contactB),
+        "MyGroup",
+        myContact.identity
+    )
     protected val myGroupWithProfilePicture =
         TestGroup(
             GroupId(1),
@@ -110,17 +116,47 @@ open class MessageProcessorProvider {
     protected val groupB =
         TestGroup(GroupId(3), contactB, listOf(myContact, contactB), "GroupB", myContact.identity)
     protected val groupAB =
-        TestGroup(GroupId(4), contactA, listOf(myContact, contactA, contactB), "GroupAB", myContact.identity)
+        TestGroup(
+            GroupId(4),
+            contactA,
+            listOf(myContact, contactA, contactB),
+            "GroupAB",
+            myContact.identity
+        )
     protected val groupAUnknown =
-        TestGroup(GroupId(5), contactA, listOf(myContact, contactA, contactB), "GroupAUnknown", myContact.identity)
+        TestGroup(
+            GroupId(5),
+            contactA,
+            listOf(myContact, contactA, contactB),
+            "GroupAUnknown",
+            myContact.identity
+        )
     protected val groupALeft =
-        TestGroup(GroupId(6), contactA, listOf(contactA, contactB), "GroupALeft", myContact.identity)
+        TestGroup(
+            GroupId(6),
+            contactA,
+            listOf(contactA, contactB),
+            "GroupALeft",
+            myContact.identity
+        )
     protected val myUnknownGroup =
-        TestGroup(GroupId(7), myContact, listOf(myContact, contactA), "MyUnknownGroup", myContact.identity)
+        TestGroup(
+            GroupId(7),
+            myContact,
+            listOf(myContact, contactA),
+            "MyUnknownGroup",
+            myContact.identity
+        )
     protected val myLeftGroup =
         TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup", myContact.identity)
     protected val newAGroup =
-        TestGroup(GroupId(9), contactA, listOf(myContact, contactA, contactB), "NewAGroup", myContact.identity)
+        TestGroup(
+            GroupId(9),
+            contactA,
+            listOf(myContact, contactA, contactB),
+            "NewAGroup",
+            myContact.identity
+        )
 
     protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
     private val contactStore: ContactStore = InMemoryContactStore().apply {
@@ -551,7 +587,14 @@ open class MessageProcessorProvider {
             override fun store(scope: NonceScope, nonce: Nonce) = true
             override fun getAllHashedNonces(scope: NonceScope) = listOf<HashedNonce>()
             override fun getCount(scope: NonceScope) = 0L
-            override fun addHashedNoncesChunk(scope: NonceScope, chunkSize: Int, offset: Int, nonces: MutableList<HashedNonce>) {}
+            override fun addHashedNoncesChunk(
+                scope: NonceScope,
+                chunkSize: Int,
+                offset: Int,
+                nonces: MutableList<HashedNonce>
+            ) {
+            }
+
             override fun insertHashedNonces(scope: NonceScope, nonces: List<HashedNonce>) = true
         })
 

+ 4 - 1
app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt

@@ -128,7 +128,10 @@ class IdentityBlockedStepsTest {
         )
         assertEquals(
             BlockState.EXPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(explicitlyBlockedContact.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(
+                explicitlyBlockedContact.identity,
+                blockUnknownPreferenceService
+            )
         )
     }
 

+ 226 - 225
app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java

@@ -54,51 +54,52 @@ import ch.threema.storage.models.group.GroupInviteModel;
 
 public class GroupInviteServiceTest {
 
-	private GroupService groupService;
-	private GroupInviteService groupInviteService;
-
-	static final String TEST_GROUP_NAME = "A nice little group";
-	static final String TEST_INVITE_NAME = "New unnamed link";
-	static String TEST_IDENTITY = "ECHOECHO";
-	static final GroupInvite.ConfirmationMode TEST_CONFIRMATION_MODE_AUTOMATIC = GroupInvite.ConfirmationMode.AUTOMATIC;
-	static GroupInviteToken TEST_TOKEN_VALID;
-	static GroupInviteModel TEST_INVITE_MODEL;
-	static String TEST_ENCODED_INVITE = "RUNIT0VDSE86MDAwMTAyMDMwNDA1MDYwNzA4MDkwYTBiMGMwZDBlMGY6QSBuaWNlIGxpdHRsZSBncm91cDow";
-
-	static {
-		try {
-			TEST_TOKEN_VALID = new GroupInviteToken(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15});
-			TEST_INVITE_MODEL = new GroupInviteModel.Builder()
-				.withGroupName(TEST_GROUP_NAME)
-				.withInviteName(TEST_INVITE_NAME)
-				.withToken(TEST_TOKEN_VALID)
-				.withManualConfirmation(false)
-				.build();
-		} catch (GroupInviteToken.InvalidGroupInviteTokenException | GroupInviteModel.MissingRequiredArgumentsException e) {
-			e.printStackTrace();
-		}
-	}
-
-	private final GroupInviteData TEST_INVITE_DATA = new GroupInviteData(
-		TEST_IDENTITY,
-		TEST_TOKEN_VALID,
-		TEST_GROUP_NAME,
-		TEST_CONFIRMATION_MODE_AUTOMATIC
-	);
-
-	@Before
-	public void setUp() {
-		// create new implementation while only implementing getIdentity with the TEST_IDENTITY because Powermock cannot be used in androidTest scope
-		UserService userService = new UserService() {
-			@Override
-			public void createIdentity(byte[] newRandomSeed) throws Exception {
-
-			}
-
-			@Override
-			public void removeIdentity() throws Exception {
-
-			}
+    private GroupService groupService;
+    private GroupInviteService groupInviteService;
+
+    static final String TEST_GROUP_NAME = "A nice little group";
+    static final String TEST_INVITE_NAME = "New unnamed link";
+    static String TEST_IDENTITY = "ECHOECHO";
+    static final GroupInvite.ConfirmationMode TEST_CONFIRMATION_MODE_AUTOMATIC = GroupInvite.ConfirmationMode.AUTOMATIC;
+    static GroupInviteToken TEST_TOKEN_VALID;
+    static GroupInviteModel TEST_INVITE_MODEL;
+    static String TEST_ENCODED_INVITE = "RUNIT0VDSE86MDAwMTAyMDMwNDA1MDYwNzA4MDkwYTBiMGMwZDBlMGY6QSBuaWNlIGxpdHRsZSBncm91cDow";
+
+    static {
+        try {
+            TEST_TOKEN_VALID = new GroupInviteToken(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15});
+            TEST_INVITE_MODEL = new GroupInviteModel.Builder()
+                .withGroupName(TEST_GROUP_NAME)
+                .withInviteName(TEST_INVITE_NAME)
+                .withToken(TEST_TOKEN_VALID)
+                .withManualConfirmation(false)
+                .build();
+        } catch (GroupInviteToken.InvalidGroupInviteTokenException |
+                 GroupInviteModel.MissingRequiredArgumentsException e) {
+            e.printStackTrace();
+        }
+    }
+
+    private final GroupInviteData TEST_INVITE_DATA = new GroupInviteData(
+        TEST_IDENTITY,
+        TEST_TOKEN_VALID,
+        TEST_GROUP_NAME,
+        TEST_CONFIRMATION_MODE_AUTOMATIC
+    );
+
+    @Before
+    public void setUp() {
+        // create new implementation while only implementing getIdentity with the TEST_IDENTITY because Powermock cannot be used in androidTest scope
+        UserService userService = new UserService() {
+            @Override
+            public void createIdentity(byte[] newRandomSeed) throws Exception {
+
+            }
+
+            @Override
+            public void removeIdentity() throws Exception {
+
+            }
 
             @Nullable
             @Override
@@ -128,212 +129,212 @@ public class GroupInviteServiceTest {
             }
 
             @Override
-			public Account getAccount() {
-				return null;
-			}
+            public Account getAccount() {
+                return null;
+            }
+
+            @Override
+            public Account getAccount(boolean createIfNotExists) {
+                return null;
+            }
+
+            @Override
+            public boolean checkAccount() {
+                return false;
+            }
+
+            @Override
+            public boolean enableAccountAutoSync(boolean enable) {
+                return false;
+            }
+
+            @Override
+            public void removeAccount() {
+
+            }
+
+            @Override
+            public boolean removeAccount(AccountManagerCallback<Boolean> callback) {
+                return false;
+            }
+
+            @Override
+            public boolean hasIdentity() {
+                return false;
+            }
+
+            @Override
+            public String getIdentity() {
+                return TEST_IDENTITY;
+            }
+
+            @Override
+            public boolean isMe(String identity) {
+                return false;
+            }
+
+            @Override
+            public byte[] getPublicKey() {
+                return new byte[0];
+            }
+
+            @Override
+            public byte[] getPrivateKey() {
+                return new byte[0];
+            }
+
+            @Override
+            public String getLinkedEmail() {
+                return null;
+            }
+
+            @Override
+            public String getLinkedMobileE164() {
+                return null;
+            }
+
+            @Override
+            public String getLinkedMobile() {
+                return null;
+            }
+
+            @Override
+            public String getLinkedMobile(boolean returnPendingNumber) {
+                return null;
+            }
 
-			@Override
-			public Account getAccount(boolean createIfNotExists) {
-				return null;
-			}
+            @Override
+            public void linkWithEmail(String email) throws Exception {
 
-			@Override
-			public boolean checkAccount() {
-				return false;
-			}
+            }
 
-			@Override
-			public boolean enableAccountAutoSync(boolean enable) {
-				return false;
-			}
+            @Override
+            public void unlinkEmail() throws Exception {
 
-			@Override
-			public void removeAccount() {
+            }
 
-			}
+            @Override
+            public int getEmailLinkingState() {
+                return 0;
+            }
 
-			@Override
-			public boolean removeAccount(AccountManagerCallback<Boolean> callback) {
-				return false;
-			}
+            @Override
+            public void checkEmailLinkState() {
 
-			@Override
-			public boolean hasIdentity() {
-				return false;
-			}
+            }
 
-			@Override
-			public String getIdentity() {
-				return TEST_IDENTITY;
-			}
+            @Override
+            public Date linkWithMobileNumber(String number) throws Exception {
+                return null;
+            }
 
-			@Override
-			public boolean isMe(String identity) {
-				return false;
-			}
+            @Override
+            public void makeMobileLinkCall() throws Exception {
+
+            }
 
-			@Override
-			public byte[] getPublicKey() {
-				return new byte[0];
-			}
+            @Override
+            public void unlinkMobileNumber() throws Exception {
 
-			@Override
-			public byte[] getPrivateKey() {
-				return new byte[0];
-			}
+            }
+
+            @Override
+            public boolean verifyMobileNumber(String code) throws Exception {
+                return false;
+            }
 
-			@Override
-			public String getLinkedEmail() {
-				return null;
-			}
+            @Override
+            public int getMobileLinkingState() {
+                return 0;
+            }
 
-			@Override
-			public String getLinkedMobileE164() {
-				return null;
-			}
+            @Override
+            public long getMobileLinkingTime() {
+                return 0;
+            }
 
-			@Override
-			public String getLinkedMobile() {
-				return null;
-			}
+            @Override
+            public String getPublicNickname() {
+                return null;
+            }
 
-			@Override
-			public String getLinkedMobile(boolean returnPendingNumber) {
-				return null;
-			}
+            @Nullable
+            @Override
+            public String setPublicNickname(String publicNickname, @NonNull TriggerSource triggerSource) {
+                return null;
+            }
 
-			@Override
-			public void linkWithEmail(String email) throws Exception {
+            @Override
+            public boolean restoreIdentity(String backupString, String password) throws Exception {
+                return false;
+            }
 
-			}
+            @Override
+            public boolean restoreIdentity(String identity, byte[] privateKey, byte[] publicKey) throws Exception {
+                return false;
+            }
 
-			@Override
-			public void unlinkEmail() throws Exception {
+            @Override
+            public void setPolicyResponse(String responseData, String signature, int policyErrorCode) {
 
-			}
+            }
 
-			@Override
-			public int getEmailLinkingState() {
-				return 0;
-			}
+            @Override
+            public void setCredentials(LicenseService.Credentials credentials) {
 
-			@Override
-			public void checkEmailLinkState() {
+            }
 
-			}
+            @Override
+            public boolean sendFeatureMask() {
+                return false;
+            }
 
-			@Override
-			public Date linkWithMobileNumber(String number) throws Exception {
-				return null;
-			}
+            @Override
+            public boolean setRevocationKey(String revocationKey) {
+                return false;
+            }
 
-			@Override
-			public void makeMobileLinkCall() throws Exception {
-
-			}
-
-			@Override
-			public void unlinkMobileNumber() throws Exception {
-
-			}
-
-			@Override
-			public boolean verifyMobileNumber(String code) throws Exception {
-				return false;
-			}
-
-			@Override
-			public int getMobileLinkingState() {
-				return 0;
-			}
-
-			@Override
-			public long getMobileLinkingTime() {
-				return 0;
-			}
-
-			@Override
-			public String getPublicNickname() {
-				return null;
-			}
-
-			@Nullable
-			@Override
-			public String setPublicNickname(String publicNickname, @NonNull TriggerSource triggerSource) {
-				return null;
-			}
-
-			@Override
-			public boolean restoreIdentity(String backupString, String password) throws Exception {
-				return false;
-			}
-
-			@Override
-			public boolean restoreIdentity(String identity, byte[] privateKey, byte[] publicKey) throws Exception {
-				return false;
-			}
-
-			@Override
-			public void setPolicyResponse(String responseData, String signature, int policyErrorCode) {
-
-			}
-
-			@Override
-			public void setCredentials(LicenseService.Credentials credentials) {
-
-			}
-
-			@Override
-			public boolean sendFeatureMask() {
-				return false;
-			}
-
-			@Override
-			public boolean setRevocationKey(String revocationKey) {
-				return false;
-			}
-
-			@Override
-			public Date getLastRevocationKeySet() {
-				return null;
-			}
+            @Override
+            public Date getLastRevocationKeySet() {
+                return null;
+            }
 
-			@Override
-			public void checkRevocationKey(boolean force) {
+            @Override
+            public void checkRevocationKey(boolean force) {
 
-			}
+            }
 
-			@Override
-			public void setForwardSecurityEnabled(boolean isFsEnabled) {
+            @Override
+            public void setForwardSecurityEnabled(boolean isFsEnabled) {
 
-			}
-		};
-		try {
-			this.groupService = ThreemaApplication.getServiceManager().getGroupService();
-		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
-			e.printStackTrace();
-		}
-		DatabaseServiceNew databaseServiceNew = ThreemaApplication.getServiceManager().getDatabaseServiceNew();
-		this.groupInviteService = new GroupInviteServiceImpl(userService, this.groupService, databaseServiceNew);
-	}
-
-	@Test
-	public void testEncodeDecodeGroupInvite() {
-		Uri encodedGroupInvite = groupInviteService.encodeGroupInviteLink(TEST_INVITE_MODEL);
-
-		Assert.assertEquals("https", encodedGroupInvite.getScheme());
-		Assert.assertEquals(BuildConfig.groupLinkActionUrl, encodedGroupInvite.getAuthority());
-		Assert.assertEquals("/join", encodedGroupInvite.getPath());
-		Assert.assertEquals(TEST_ENCODED_INVITE, encodedGroupInvite.getEncodedFragment());
-	}
-
-	@Test
-	public void testDecodeGroupInvite() throws IOException, GroupInviteToken.InvalidGroupInviteTokenException {
-		GroupInviteData inviteDataFromDecodedUri = groupInviteService.decodeGroupInviteLink(TEST_ENCODED_INVITE);
-
-		Assert.assertEquals(TEST_INVITE_DATA.getAdminIdentity(),  inviteDataFromDecodedUri.getAdminIdentity());
-		Assert.assertEquals(TEST_INVITE_DATA.getToken(), inviteDataFromDecodedUri.getToken());
-		Assert.assertEquals(TEST_INVITE_DATA.getGroupName(), inviteDataFromDecodedUri.getGroupName());
-		Assert.assertEquals(TEST_INVITE_DATA.getConfirmationMode(), inviteDataFromDecodedUri.getConfirmationMode());
-	}
+            }
+        };
+        try {
+            this.groupService = ThreemaApplication.getServiceManager().getGroupService();
+        } catch (MasterKeyLockedException | FileSystemNotPresentException e) {
+            e.printStackTrace();
+        }
+        DatabaseServiceNew databaseServiceNew = ThreemaApplication.getServiceManager().getDatabaseServiceNew();
+        this.groupInviteService = new GroupInviteServiceImpl(userService, this.groupService, databaseServiceNew);
+    }
+
+    @Test
+    public void testEncodeDecodeGroupInvite() {
+        Uri encodedGroupInvite = groupInviteService.encodeGroupInviteLink(TEST_INVITE_MODEL);
+
+        Assert.assertEquals("https", encodedGroupInvite.getScheme());
+        Assert.assertEquals(BuildConfig.groupLinkActionUrl, encodedGroupInvite.getAuthority());
+        Assert.assertEquals("/join", encodedGroupInvite.getPath());
+        Assert.assertEquals(TEST_ENCODED_INVITE, encodedGroupInvite.getEncodedFragment());
+    }
+
+    @Test
+    public void testDecodeGroupInvite() throws IOException, GroupInviteToken.InvalidGroupInviteTokenException {
+        GroupInviteData inviteDataFromDecodedUri = groupInviteService.decodeGroupInviteLink(TEST_ENCODED_INVITE);
+
+        Assert.assertEquals(TEST_INVITE_DATA.getAdminIdentity(), inviteDataFromDecodedUri.getAdminIdentity());
+        Assert.assertEquals(TEST_INVITE_DATA.getToken(), inviteDataFromDecodedUri.getToken());
+        Assert.assertEquals(TEST_INVITE_DATA.getGroupName(), inviteDataFromDecodedUri.getGroupName());
+        Assert.assertEquals(TEST_INVITE_DATA.getConfirmationMode(), inviteDataFromDecodedUri.getConfirmationMode());
+    }
 }

+ 33 - 33
app/src/androidTest/java/ch/threema/app/testutils/CaptureLogcatOnTestFailureRule.java

@@ -28,44 +28,44 @@ import org.junit.runners.model.Statement;
 
 /**
  * Capture adb logcat on test failure.
- *
+ * <p>
  * Based on https://www.braze.com/resources/articles/logcat-junit-android-tests
  */
 public class CaptureLogcatOnTestFailureRule implements TestRule {
-	private static final String LOGCAT_HEADER = "\n================ Logcat Output ================\n";
-	private static final String STACKTRACE_HEADER = "\n================ Stacktrace ================\n";
-	private static final String ORIGINAL_CLASS_HEADER = "\nOriginal class: ";
+    private static final String LOGCAT_HEADER = "\n================ Logcat Output ================\n";
+    private static final String STACKTRACE_HEADER = "\n================ Stacktrace ================\n";
+    private static final String ORIGINAL_CLASS_HEADER = "\nOriginal class: ";
 
-	@Override
-	public Statement apply(Statement base, Description description) {
-		return new Statement() {
-			@Override
-			public void evaluate() throws Throwable {
-				// Before test, clear logcat
-				TestHelpers.clearLogcat();
+    @Override
+    public Statement apply(Statement base, Description description) {
+        return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+                // Before test, clear logcat
+                TestHelpers.clearLogcat();
 
-				try {
-					// Run statement
-					base.evaluate();
-				} catch (Throwable originalThrowable) {
-					if (originalThrowable instanceof AssumptionViolatedException) {
-						throw originalThrowable;
-					}
+                try {
+                    // Run statement
+                    base.evaluate();
+                } catch (Throwable originalThrowable) {
+                    if (originalThrowable instanceof AssumptionViolatedException) {
+                        throw originalThrowable;
+                    }
 
-					// Fetch logcat logs
-					final String testName = description.getMethodName() + "(" + description.getClassName() + ")";
-					final String logcatLogs = TestHelpers.getTestLogs(testName);
+                    // Fetch logcat logs
+                    final String testName = description.getMethodName() + "(" + description.getClassName() + ")";
+                    final String logcatLogs = TestHelpers.getTestLogs(testName);
 
-					// Throw updated throwable
-					final String thrownMessage = originalThrowable.getMessage()
-						+ ORIGINAL_CLASS_HEADER + originalThrowable.getClass().getName()
-						+ LOGCAT_HEADER + logcatLogs
-						+ STACKTRACE_HEADER;
-					final Throwable modifiedThrowable = new Throwable(thrownMessage);
-					modifiedThrowable.setStackTrace(originalThrowable.getStackTrace());
-					throw modifiedThrowable;
-				}
-			}
-		};
-	}
+                    // Throw updated throwable
+                    final String thrownMessage = originalThrowable.getMessage()
+                        + ORIGINAL_CLASS_HEADER + originalThrowable.getClass().getName()
+                        + LOGCAT_HEADER + logcatLogs
+                        + STACKTRACE_HEADER;
+                    final Throwable modifiedThrowable = new Throwable(thrownMessage);
+                    modifiedThrowable.setStackTrace(originalThrowable.getStackTrace());
+                    throw modifiedThrowable;
+                }
+            }
+        };
+    }
 }

+ 22 - 21
app/src/androidTest/java/ch/threema/app/testutils/InstructionUtil.java

@@ -29,26 +29,27 @@ import androidx.test.InstrumentationRegistry;
 import ch.threema.app.TestApplication;
 
 public class InstructionUtil {
-	private InstructionUtil(){}
-
-	public static Instruction waitForView(final int resourceId) {
-		return new Instruction() {
-			@Override
-			public String getDescription() {
-				return "wait for " + resourceId;
-			}
-
-			@Override
-			public boolean checkCondition() {
-				Activity activity = ((TestApplication)
-						InstrumentationRegistry.getTargetContext().getApplicationContext()).getCurrentActivity();
-				if (activity == null) {
-					return false;
-				}
-
-				return activity.findViewById(resourceId) != null;
-			}
-		};
-	}
+    private InstructionUtil() {
+    }
+
+    public static Instruction waitForView(final int resourceId) {
+        return new Instruction() {
+            @Override
+            public String getDescription() {
+                return "wait for " + resourceId;
+            }
+
+            @Override
+            public boolean checkCondition() {
+                Activity activity = ((TestApplication)
+                    InstrumentationRegistry.getTargetContext().getApplicationContext()).getCurrentActivity();
+                if (activity == null) {
+                    return false;
+                }
+
+                return activity.findViewById(resourceId) != null;
+            }
+        };
+    }
 
 }

+ 58 - 56
app/src/androidTest/java/ch/threema/app/testutils/RecyclerViewMatcher.java

@@ -22,8 +22,11 @@
 package ch.threema.app.testutils;
 
 import android.content.res.Resources;
+
 import androidx.recyclerview.widget.RecyclerView;
+
 import android.view.View;
+
 import org.hamcrest.Description;
 import org.hamcrest.Matcher;
 import org.hamcrest.TypeSafeMatcher;
@@ -32,60 +35,59 @@ import org.hamcrest.TypeSafeMatcher;
  * https://github.com/dannyroa/espresso-samples/blob/master/RecyclerView/app/src/androidTest/java/com/dannyroa/espresso_samples/recyclerview/RecyclerViewMatcher.java
  */
 public class RecyclerViewMatcher {
-	private final int recyclerViewId;
-
-	public RecyclerViewMatcher(int recyclerViewId) {
-		this.recyclerViewId = recyclerViewId;
-	}
-
-	public Matcher<View> atPosition(final int position) {
-		return atPositionOnView(position, -1);
-	}
-
-	public Matcher<View> atPositionOnView(final int position, final int targetViewId) {
-
-		return new TypeSafeMatcher<View>() {
-			Resources resources = null;
-			View childView;
-
-			public void describeTo(Description description) {
-				String idDescription = Integer.toString(recyclerViewId);
-				if (this.resources != null) {
-					try {
-						idDescription = this.resources.getResourceName(recyclerViewId);
-					} catch (Resources.NotFoundException var4) {
-						idDescription = String.format("%s (resource name not found)",
-								new Object[] { Integer.valueOf
-										(recyclerViewId) });
-					}
-				}
-
-				description.appendText("with id: " + idDescription);
-			}
-
-			public boolean matchesSafely(View view) {
-
-				this.resources = view.getResources();
-
-				if (childView == null) {
-					RecyclerView recyclerView =
-							(RecyclerView) view.getRootView().findViewById(recyclerViewId);
-					if (recyclerView != null && recyclerView.getId() == recyclerViewId) {
-						childView = recyclerView.findViewHolderForAdapterPosition(position).itemView;
-					}
-					else {
-						return false;
-					}
-				}
-
-				if (targetViewId == -1) {
-					return view == childView;
-				} else {
-					View targetView = childView.findViewById(targetViewId);
-					return view == targetView;
-				}
-
-			}
-		};
-	}
+    private final int recyclerViewId;
+
+    public RecyclerViewMatcher(int recyclerViewId) {
+        this.recyclerViewId = recyclerViewId;
+    }
+
+    public Matcher<View> atPosition(final int position) {
+        return atPositionOnView(position, -1);
+    }
+
+    public Matcher<View> atPositionOnView(final int position, final int targetViewId) {
+
+        return new TypeSafeMatcher<View>() {
+            Resources resources = null;
+            View childView;
+
+            public void describeTo(Description description) {
+                String idDescription = Integer.toString(recyclerViewId);
+                if (this.resources != null) {
+                    try {
+                        idDescription = this.resources.getResourceName(recyclerViewId);
+                    } catch (Resources.NotFoundException var4) {
+                        idDescription = String.format("%s (resource name not found)",
+                            new Object[]{Integer.valueOf
+                                (recyclerViewId)});
+                    }
+                }
+
+                description.appendText("with id: " + idDescription);
+            }
+
+            public boolean matchesSafely(View view) {
+
+                this.resources = view.getResources();
+
+                if (childView == null) {
+                    RecyclerView recyclerView =
+                        (RecyclerView) view.getRootView().findViewById(recyclerViewId);
+                    if (recyclerView != null && recyclerView.getId() == recyclerViewId) {
+                        childView = recyclerView.findViewHolderForAdapterPosition(position).itemView;
+                    } else {
+                        return false;
+                    }
+                }
+
+                if (targetViewId == -1) {
+                    return view == childView;
+                } else {
+                    View targetView = childView.findViewById(targetViewId);
+                    return view == targetView;
+                }
+
+            }
+        };
+    }
 }

+ 258 - 258
app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java

@@ -58,265 +58,265 @@ import ch.threema.storage.models.GroupModel;
 import static org.junit.Assert.assertNotNull;
 
 public class TestHelpers {
-	private static final String TAG = "TestHelpers";
-
-	public static final TestContact TEST_CONTACT = new TestContact(
-		"XERCUKNS",
-		Utils.hexStringToByteArray("2bbc16092ff45ffcd0045c00f2f5e1e9597621f89360bbca23a2a2956b3c3b36"),
-		Utils.hexStringToByteArray("977aba4ab367041f6137afef69ab9676d445011ca7aca0455a5c64805b80b77a")
-	);
-
-	public static final class TestContact {
-		@NonNull
-		public final String identity;
-		@NonNull
-		public final byte[] publicKey;
-		@NonNull
-		public final byte[] privateKey;
-
-		public TestContact(@NonNull String identity) {
-			this.identity = identity;
-			publicKey = new byte[NaCl.PUBLICKEYBYTES];
-			privateKey = new byte[NaCl.SECRETKEYBYTES];
-
-			NaCl.genkeypair(publicKey, privateKey);
-		}
-
-		public TestContact(@NonNull String identity, @NonNull byte[] publicKey, @NonNull byte[] privateKey) {
-			this.identity = identity;
-			this.publicKey = publicKey;
-			this.privateKey = privateKey;
-		}
-
-		@NonNull
-		public Contact getContact() {
-			return new Contact(this.identity, this.publicKey, VerificationLevel.UNVERIFIED);
-		}
-
-		@NonNull
-		public ContactModel getContactModel() {
-			return new ContactModel(this.identity, this.publicKey);
-		}
-
-		@NonNull
-		public IdentityStoreInterface getIdentityStore() {
-			return new InMemoryIdentityStore(
-				this.identity,
-				"",
-				this.privateKey,
-				null
-			);
-		}
-
-		@NonNull
-		public BasicContact toBasicContact() {
-			return BasicContact.javaCreate(
-				identity,
-				publicKey,
-				new ThreemaFeature.Builder()
-					.audio(true)
-					.group(true)
-					.ballot(true)
-					.file(true)
-					.voip(true)
-					.videocalls(true)
-					.forwardSecurity(true)
-					.groupCalls(true)
-					.editMessages(true)
-					.deleteMessages(true)
-					.build(),
-				IdentityState.ACTIVE,
-				IdentityType.NORMAL
-			);
-		}
-	}
-
-	public static final class TestGroup {
-		private int localGroupId = -1;
-
-		@NonNull
-		public final GroupId apiGroupId;
-
-		@NonNull
-		public final TestContact groupCreator;
-
-		@NonNull
-		public final List<TestContact> members;
-
-		@NonNull
-		public final String groupName;
-
-		@Nullable
-		public final byte[] profilePicture;
-
-		/**
-		 * Note that the user identity is used to set the correct group user state.
-		 */
-		@NonNull
-		public final String userIdentity;
-
-		public TestGroup(
-			@NonNull GroupId apiGroupId,
-			@NonNull TestContact groupCreator,
-			@NonNull List<TestContact> members,
-			@NonNull String groupName,
-			@NonNull String userIdentity
-		) {
-			this(apiGroupId, groupCreator, members, groupName, null, userIdentity);
-		}
-
-		public TestGroup(
-			@NonNull GroupId apiGroupId,
-			@NonNull TestContact groupCreator,
-			@NonNull List<TestContact> members,
-			@NonNull String groupName,
-			@Nullable byte[] profilePicture,
-			@NonNull String userIdentity
-		) {
-			this.apiGroupId = apiGroupId;
-			this.groupCreator = groupCreator;
-			this.members = members;
-			this.groupName = groupName;
-			this.profilePicture = profilePicture;
-			this.userIdentity = userIdentity;
-		}
-
-		@NonNull
-		public GroupModel getGroupModel() {
-			boolean isMember = false;
-			for (TestContact member : members) {
-				if (member.identity.equals(userIdentity)) {
-					isMember = true;
-					break;
-				}
-			}
-			return getGroupModel(isMember ? GroupModel.UserState.MEMBER : GroupModel.UserState.LEFT);
-		}
-
-		@NonNull
-		private GroupModel getGroupModel(@NonNull GroupModel.UserState userState) {
-			return new GroupModel()
-				.setApiGroupId(apiGroupId)
-				.setCreatedAt(new Date())
-				.setName(this.groupName)
-				.setCreatorIdentity(this.groupCreator.identity)
-				.setId(localGroupId)
-				.setUserState(userState);
-		}
-
-		public void setLocalGroupId(int localGroupId) {
-			this.localGroupId = localGroupId;
-		}
-	}
-
-	/**
-	 * Open the notification area and wait for the notifications to become visible.
-	 *
-	 * @param device UiDevice instance
-	 */
-	public static void openNotificationArea(@NonNull UiDevice device) throws AssertionError {
-		device.openNotification();
-
-		// Wait for notifications to appear
-		final BySelector selector = By.res("android:id/status_bar_latest_event_content");
-		assertNotNull(
-			"Notification bar latest event content not found",
-			device.wait(Until.findObject(selector), 1000)
-		);
-	}
-
-	/**
-	 * Source: https://stackoverflow.com/a/5921190/284318
-	 */
-	public static boolean iServiceRunning(@NonNull Context appContext, @NonNull Class<?> serviceClass) {
-		ActivityManager manager = (ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE);
-		assert manager != null;
-		for (RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) {
-			if (serviceClass.getName().equals(service.service.getClassName())) {
-				return true;
-			}
-		}
-		return false;
-	}
-
-	/**
-	 * Ensure that an identity is set up.
-	 */
-	public static String ensureIdentity(@NonNull ServiceManager serviceManager) throws Exception {
-		// Check whether identity already exists
-		final UserService userService = serviceManager.getUserService();
-		if (userService.hasIdentity()) {
+    private static final String TAG = "TestHelpers";
+
+    public static final TestContact TEST_CONTACT = new TestContact(
+        "XERCUKNS",
+        Utils.hexStringToByteArray("2bbc16092ff45ffcd0045c00f2f5e1e9597621f89360bbca23a2a2956b3c3b36"),
+        Utils.hexStringToByteArray("977aba4ab367041f6137afef69ab9676d445011ca7aca0455a5c64805b80b77a")
+    );
+
+    public static final class TestContact {
+        @NonNull
+        public final String identity;
+        @NonNull
+        public final byte[] publicKey;
+        @NonNull
+        public final byte[] privateKey;
+
+        public TestContact(@NonNull String identity) {
+            this.identity = identity;
+            publicKey = new byte[NaCl.PUBLICKEYBYTES];
+            privateKey = new byte[NaCl.SECRETKEYBYTES];
+
+            NaCl.genkeypair(publicKey, privateKey);
+        }
+
+        public TestContact(@NonNull String identity, @NonNull byte[] publicKey, @NonNull byte[] privateKey) {
+            this.identity = identity;
+            this.publicKey = publicKey;
+            this.privateKey = privateKey;
+        }
+
+        @NonNull
+        public Contact getContact() {
+            return new Contact(this.identity, this.publicKey, VerificationLevel.UNVERIFIED);
+        }
+
+        @NonNull
+        public ContactModel getContactModel() {
+            return new ContactModel(this.identity, this.publicKey);
+        }
+
+        @NonNull
+        public IdentityStoreInterface getIdentityStore() {
+            return new InMemoryIdentityStore(
+                this.identity,
+                "",
+                this.privateKey,
+                null
+            );
+        }
+
+        @NonNull
+        public BasicContact toBasicContact() {
+            return BasicContact.javaCreate(
+                identity,
+                publicKey,
+                new ThreemaFeature.Builder()
+                    .audio(true)
+                    .group(true)
+                    .ballot(true)
+                    .file(true)
+                    .voip(true)
+                    .videocalls(true)
+                    .forwardSecurity(true)
+                    .groupCalls(true)
+                    .editMessages(true)
+                    .deleteMessages(true)
+                    .build(),
+                IdentityState.ACTIVE,
+                IdentityType.NORMAL
+            );
+        }
+    }
+
+    public static final class TestGroup {
+        private int localGroupId = -1;
+
+        @NonNull
+        public final GroupId apiGroupId;
+
+        @NonNull
+        public final TestContact groupCreator;
+
+        @NonNull
+        public final List<TestContact> members;
+
+        @NonNull
+        public final String groupName;
+
+        @Nullable
+        public final byte[] profilePicture;
+
+        /**
+         * Note that the user identity is used to set the correct group user state.
+         */
+        @NonNull
+        public final String userIdentity;
+
+        public TestGroup(
+            @NonNull GroupId apiGroupId,
+            @NonNull TestContact groupCreator,
+            @NonNull List<TestContact> members,
+            @NonNull String groupName,
+            @NonNull String userIdentity
+        ) {
+            this(apiGroupId, groupCreator, members, groupName, null, userIdentity);
+        }
+
+        public TestGroup(
+            @NonNull GroupId apiGroupId,
+            @NonNull TestContact groupCreator,
+            @NonNull List<TestContact> members,
+            @NonNull String groupName,
+            @Nullable byte[] profilePicture,
+            @NonNull String userIdentity
+        ) {
+            this.apiGroupId = apiGroupId;
+            this.groupCreator = groupCreator;
+            this.members = members;
+            this.groupName = groupName;
+            this.profilePicture = profilePicture;
+            this.userIdentity = userIdentity;
+        }
+
+        @NonNull
+        public GroupModel getGroupModel() {
+            boolean isMember = false;
+            for (TestContact member : members) {
+                if (member.identity.equals(userIdentity)) {
+                    isMember = true;
+                    break;
+                }
+            }
+            return getGroupModel(isMember ? GroupModel.UserState.MEMBER : GroupModel.UserState.LEFT);
+        }
+
+        @NonNull
+        private GroupModel getGroupModel(@NonNull GroupModel.UserState userState) {
+            return new GroupModel()
+                .setApiGroupId(apiGroupId)
+                .setCreatedAt(new Date())
+                .setName(this.groupName)
+                .setCreatorIdentity(this.groupCreator.identity)
+                .setId(localGroupId)
+                .setUserState(userState);
+        }
+
+        public void setLocalGroupId(int localGroupId) {
+            this.localGroupId = localGroupId;
+        }
+    }
+
+    /**
+     * Open the notification area and wait for the notifications to become visible.
+     *
+     * @param device UiDevice instance
+     */
+    public static void openNotificationArea(@NonNull UiDevice device) throws AssertionError {
+        device.openNotification();
+
+        // Wait for notifications to appear
+        final BySelector selector = By.res("android:id/status_bar_latest_event_content");
+        assertNotNull(
+            "Notification bar latest event content not found",
+            device.wait(Until.findObject(selector), 1000)
+        );
+    }
+
+    /**
+     * Source: https://stackoverflow.com/a/5921190/284318
+     */
+    public static boolean iServiceRunning(@NonNull Context appContext, @NonNull Class<?> serviceClass) {
+        ActivityManager manager = (ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE);
+        assert manager != null;
+        for (RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) {
+            if (serviceClass.getName().equals(service.service.getClassName())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Ensure that an identity is set up.
+     */
+    public static String ensureIdentity(@NonNull ServiceManager serviceManager) throws Exception {
+        // Check whether identity already exists
+        final UserService userService = serviceManager.getUserService();
+        if (userService.hasIdentity()) {
             final String identity = userService.getIdentity();
             Log.i(TAG, "Identity already exists: " + identity);
             return identity;
-		}
-
-		// Otherwise, create identity
-		userService.restoreIdentity(
-			TEST_CONTACT.identity,
-			TEST_CONTACT.privateKey,
-			TEST_CONTACT.publicKey
-		);
-		Log.i(TAG, "Test identity restored: " + TEST_CONTACT.identity);
-		return TEST_CONTACT.identity;
-	}
-
-	public static void clearLogcat() {
-		try {
-			Runtime.getRuntime().exec(new String[] { "logcat", "-c" });
-		} catch (IOException e) {
-			Log.e(TAG, "Could not clear logcat", e);
-		}
-	}
-
-	/**
-	 * Return adb logs since the start of the specified test.
-	 *
-	 * Based on https://www.braze.com/resources/articles/logcat-junit-android-tests
-	 */
-	public static String getTestLogs(@NonNull String testName) {
-		final StringBuilder logLines = new StringBuilder();
-
-		// Process id is used to filter messages
-		final String currentProcessId = Integer.toString(android.os.Process.myPid());
-
-		// A snippet of text that uniquely determines where the relevant logs start in the logcat
-		final String testStartMessage = "TestRunner: started: " + testName;
-
-		// When true, write every line from the logcat buffer to the string builder
-		boolean recording = false;
-
-		// Logcat command:
-		//   -d asks the command to completely dump to our buffer, then return
-		//   -v threadtime sets the output log format
-		final String[] command = new String[] { "logcat", "-d", "-v", "threadtime" };
-
-		BufferedReader bufferedReader = null;
-		try {
-			final Process process = Runtime.getRuntime().exec(command);
-			bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
-			String line;
-			while ((line = bufferedReader.readLine()) != null) {
-				if (line.contains(testStartMessage)) {
-					recording = true;
-				}
-				if (recording) {
-					logLines.append(line);
-					logLines.append('\n');
-				}
-			}
-		} catch (IOException e) {
-			Log.e(TAG, "Failed to run logcat command", e);
-		} finally {
-			if (bufferedReader != null) {
-				try {
-					bufferedReader.close();
-				} catch (IOException e) {
-					Log.e(TAG, "Failed to close buffered reader", e);
-				}
-			}
-		}
-
-		return logLines.toString();
-	}
+        }
+
+        // Otherwise, create identity
+        userService.restoreIdentity(
+            TEST_CONTACT.identity,
+            TEST_CONTACT.privateKey,
+            TEST_CONTACT.publicKey
+        );
+        Log.i(TAG, "Test identity restored: " + TEST_CONTACT.identity);
+        return TEST_CONTACT.identity;
+    }
+
+    public static void clearLogcat() {
+        try {
+            Runtime.getRuntime().exec(new String[]{"logcat", "-c"});
+        } catch (IOException e) {
+            Log.e(TAG, "Could not clear logcat", e);
+        }
+    }
+
+    /**
+     * Return adb logs since the start of the specified test.
+     * <p>
+     * Based on https://www.braze.com/resources/articles/logcat-junit-android-tests
+     */
+    public static String getTestLogs(@NonNull String testName) {
+        final StringBuilder logLines = new StringBuilder();
+
+        // Process id is used to filter messages
+        final String currentProcessId = Integer.toString(android.os.Process.myPid());
+
+        // A snippet of text that uniquely determines where the relevant logs start in the logcat
+        final String testStartMessage = "TestRunner: started: " + testName;
+
+        // When true, write every line from the logcat buffer to the string builder
+        boolean recording = false;
+
+        // Logcat command:
+        //   -d asks the command to completely dump to our buffer, then return
+        //   -v threadtime sets the output log format
+        final String[] command = new String[]{"logcat", "-d", "-v", "threadtime"};
+
+        BufferedReader bufferedReader = null;
+        try {
+            final Process process = Runtime.getRuntime().exec(command);
+            bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
+            String line;
+            while ((line = bufferedReader.readLine()) != null) {
+                if (line.contains(testStartMessage)) {
+                    recording = true;
+                }
+                if (recording) {
+                    logLines.append(line);
+                    logLines.append('\n');
+                }
+            }
+        } catch (IOException e) {
+            Log.e(TAG, "Failed to run logcat command", e);
+        } finally {
+            if (bufferedReader != null) {
+                try {
+                    bufferedReader.close();
+                } catch (IOException e) {
+                    Log.e(TAG, "Failed to close buffered reader", e);
+                }
+            }
+        }
+
+        return logLines.toString();
+    }
 }

+ 7 - 7
app/src/androidTest/java/ch/threema/app/testutils/ThreemaAssert.java

@@ -29,12 +29,12 @@ import androidx.annotation.NonNull;
  * Better assertions.
  */
 public class ThreemaAssert {
-	private final static String START = "=== Start ===\n";
-	private final static String END = "\n=== End ===";
+    private final static String START = "=== Start ===\n";
+    private final static String END = "\n=== End ===";
 
-	public static void assertContains(@NonNull String haystack, @NonNull CharSequence needle) {
-		if (!haystack.contains(needle)) {
-			Assert.fail("Substring '" + needle + "' not found in the following string:\n" + START + haystack + END);
-		}
-	}
+    public static void assertContains(@NonNull String haystack, @NonNull CharSequence needle) {
+        if (!haystack.contains(needle)) {
+            Assert.fail("Substring '" + needle + "' not found in the following string:\n" + START + haystack + END);
+        }
+    }
 }

+ 121 - 121
app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java

@@ -60,125 +60,125 @@ import static org.junit.Assert.assertTrue;
 @LargeTest
 @RunWith(AndroidJUnit4.class)
 public class BackgroundErrorNotificationTest {
-	private UiDevice mDevice;
-
-	@Rule
-	public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain().around(
-		getNotificationPermissionRule()
-	);
-
-	@Before
-	public void getDevice() {
-		// Get device instance
-		mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
-	}
-
-	/**
-	 * Dump the UI state (screenshot + UI XML) to the /sdcard/ directory.
-	 */
-	@SuppressWarnings("unused") // Used for manual debugging
-	private static void dumpState(@NonNull UiDevice device) throws IOException {
-		device.takeScreenshot(new File("/sdcard/screenshot.png"));
-		try (OutputStream stream = new BufferedOutputStream(new FileOutputStream("/sdcard/screenshot.uix"))) {
-			// Note: Explicitly opening and closing stream since the UiAutomator dumpWindowHierarchy(File)
-			// method leaks a file descriptor.
-			device.dumpWindowHierarchy(stream);
-		}
-	}
-
-	/**
-	 * Ensure that a notification is shown, without a "send to support" action.
-	 */
-	@Test
-	public void testNotificationWithoutAction() {
-		// Go to home screen
-		mDevice.pressHome();
-
-		// Show notification
-		final Context context = ApplicationProvider.getApplicationContext();
-		BackgroundErrorNotification.showNotification(
-			context,
-			"T1tl3",
-			"The body of the notification",
-			"BackgroundErrorNotificationTest",
-			false,
-			null
-		);
-
-		// Get notification area object
-		TestHelpers.openNotificationArea(mDevice);
-
-		// Verify notification contents
-		final BySelector titleSelector = By.res("android:id/title").text(context.getString(R.string.error) + ": T1tl3");
-		final BySelector bodySelector = By.text("The body of the notification");
-		assertNotNull("Notification title not found", mDevice.wait(Until.findObject(titleSelector), 1000));
-		assertNotNull("Notification text not found", mDevice.wait(Until.findObject(bodySelector), 1000));
-
-		// Ensure that no notifications are visible
-		assertNull(
-			"Actions found, but they shouldn't be there",
-			mDevice.findObject(
-				By
-					.pkg("com.android.systemui")
-					.res("com.android.systemui:id/notification_stack_scroller")
-					.hasDescendant(By.text(context.getString(R.string.send_to_support)))
-			)
-		);
-	}
-
-	/**
-	 * Ensure that a notification with "send to support" action works.
-	 */
-	//@Test TODO danilo: Disabled until we have an empty test database
-	public void testNotificationWithAction() {
-		// Go to home screen
-		mDevice.pressHome();
-
-		// Show notification
-		final Context context = ApplicationProvider.getApplicationContext();
-		final String scope = "BackgroundErrorNotificationTest";
-		final String notificationBody = "The body of the notification";
-		BackgroundErrorNotification.showNotification(
-			context,
-			"T1tl3",
-			notificationBody,
-			scope,
-			true,
-			null
-		);
-
-		// Find notification
-		TestHelpers.openNotificationArea(mDevice);
-
-		// Find action
-		final BySelector actionSelector = By
-			.res("android:id/action0")
-			.text(Pattern.compile(context.getString(R.string.send_to_support), Pattern.CASE_INSENSITIVE));
-		final UiObject2 action = mDevice.findObject(actionSelector);
-		assertNotNull("Action not found", action);
-
-		// Click action
-		action.click();
-
-		// Wait for app to appear
-		final BySelector chatPartnerSelector = By
-			.pkg("ch.threema.app")
-			.res("ch.threema.app:id/title");
-		mDevice.wait(Until.findObject(chatPartnerSelector), 3000);
-		final UiObject2 chatPartner = mDevice.findObject(chatPartnerSelector);
-
-		// Ensure that we're talking to the support user
-		assertEquals("*SUPPORT", chatPartner.getText());
-
-		// Validate message
-		final BySelector messageSelector = By
-			.pkg("ch.threema.app")
-			.res("ch.threema.app:id/embedded_text_editor");
-		final String message = mDevice.findObject(
-			messageSelector
-		).getText();
-		assertTrue(message.contains("An error occurred in " + scope));
-		assertTrue(message.contains(notificationBody));
-		assertTrue(message.contains("My phone model"));
-	}
+    private UiDevice mDevice;
+
+    @Rule
+    public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain().around(
+        getNotificationPermissionRule()
+    );
+
+    @Before
+    public void getDevice() {
+        // Get device instance
+        mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
+    }
+
+    /**
+     * Dump the UI state (screenshot + UI XML) to the /sdcard/ directory.
+     */
+    @SuppressWarnings("unused") // Used for manual debugging
+    private static void dumpState(@NonNull UiDevice device) throws IOException {
+        device.takeScreenshot(new File("/sdcard/screenshot.png"));
+        try (OutputStream stream = new BufferedOutputStream(new FileOutputStream("/sdcard/screenshot.uix"))) {
+            // Note: Explicitly opening and closing stream since the UiAutomator dumpWindowHierarchy(File)
+            // method leaks a file descriptor.
+            device.dumpWindowHierarchy(stream);
+        }
+    }
+
+    /**
+     * Ensure that a notification is shown, without a "send to support" action.
+     */
+    @Test
+    public void testNotificationWithoutAction() {
+        // Go to home screen
+        mDevice.pressHome();
+
+        // Show notification
+        final Context context = ApplicationProvider.getApplicationContext();
+        BackgroundErrorNotification.showNotification(
+            context,
+            "T1tl3",
+            "The body of the notification",
+            "BackgroundErrorNotificationTest",
+            false,
+            null
+        );
+
+        // Get notification area object
+        TestHelpers.openNotificationArea(mDevice);
+
+        // Verify notification contents
+        final BySelector titleSelector = By.res("android:id/title").text(context.getString(R.string.error) + ": T1tl3");
+        final BySelector bodySelector = By.text("The body of the notification");
+        assertNotNull("Notification title not found", mDevice.wait(Until.findObject(titleSelector), 1000));
+        assertNotNull("Notification text not found", mDevice.wait(Until.findObject(bodySelector), 1000));
+
+        // Ensure that no notifications are visible
+        assertNull(
+            "Actions found, but they shouldn't be there",
+            mDevice.findObject(
+                By
+                    .pkg("com.android.systemui")
+                    .res("com.android.systemui:id/notification_stack_scroller")
+                    .hasDescendant(By.text(context.getString(R.string.send_to_support)))
+            )
+        );
+    }
+
+    /**
+     * Ensure that a notification with "send to support" action works.
+     */
+    //@Test TODO danilo: Disabled until we have an empty test database
+    public void testNotificationWithAction() {
+        // Go to home screen
+        mDevice.pressHome();
+
+        // Show notification
+        final Context context = ApplicationProvider.getApplicationContext();
+        final String scope = "BackgroundErrorNotificationTest";
+        final String notificationBody = "The body of the notification";
+        BackgroundErrorNotification.showNotification(
+            context,
+            "T1tl3",
+            notificationBody,
+            scope,
+            true,
+            null
+        );
+
+        // Find notification
+        TestHelpers.openNotificationArea(mDevice);
+
+        // Find action
+        final BySelector actionSelector = By
+            .res("android:id/action0")
+            .text(Pattern.compile(context.getString(R.string.send_to_support), Pattern.CASE_INSENSITIVE));
+        final UiObject2 action = mDevice.findObject(actionSelector);
+        assertNotNull("Action not found", action);
+
+        // Click action
+        action.click();
+
+        // Wait for app to appear
+        final BySelector chatPartnerSelector = By
+            .pkg("ch.threema.app")
+            .res("ch.threema.app:id/title");
+        mDevice.wait(Until.findObject(chatPartnerSelector), 3000);
+        final UiObject2 chatPartner = mDevice.findObject(chatPartnerSelector);
+
+        // Ensure that we're talking to the support user
+        assertEquals("*SUPPORT", chatPartner.getText());
+
+        // Validate message
+        final BySelector messageSelector = By
+            .pkg("ch.threema.app")
+            .res("ch.threema.app:id/embedded_text_editor");
+        final String message = mDevice.findObject(
+            messageSelector
+        ).getText();
+        assertTrue(message.contains("An error occurred in " + scope));
+        assertTrue(message.contains(notificationBody));
+        assertTrue(message.contains("My phone model"));
+    }
 }

+ 9 - 3
app/src/androidTest/java/ch/threema/app/utils/BundledMessagesSendStepsTest.kt

@@ -109,7 +109,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
                     group.groupModel,
                 ) { GroupTextMessage().apply { text = "Test" } },
                 { hasBeenMarkedAsSent = true },
-                { stateMap -> forwardSecurityModes = stateMap},
+                { stateMap -> forwardSecurityModes = stateMap },
             )
 
             handle.runBundledMessagesSendSteps(
@@ -197,7 +197,10 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
         assertEquals(1, sentDates.toSet().size)
     }
 
-    private fun assertMessageHandleSent(messageHandle: OutgoingCspMessageHandle, assertMessage: (AbstractMessage) -> Unit) {
+    private fun assertMessageHandleSent(
+        messageHandle: OutgoingCspMessageHandle,
+        assertMessage: (AbstractMessage) -> Unit
+    ) {
         val expectedReceivers = messageHandle.receivers
             .map { it.identity }
             .filter { it != myContact.identity }
@@ -210,7 +213,10 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
             .sortedBy { it.toIdentity }
             .onEach {
                 assertMessage(it)
-                assertEquals(messageHandle.messageCreator.messageId.messageIdLong, it.messageId.messageIdLong)
+                assertEquals(
+                    messageHandle.messageCreator.messageId.messageIdLong,
+                    it.messageId.messageIdLong
+                )
                 assertEquals(messageHandle.messageCreator.createdAt.time, it.date.time)
             }
             .map { it.toIdentity }

+ 12 - 4
app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt

@@ -34,10 +34,13 @@ class LinkifyUtilTest {
      * Get the spannable and a list of the URL spans as a pair. If there is no spannable, a pair
      * containing of null and an empty list is returned.
      */
-    private fun getSpanPair(text: String, includePhoneNumbers: Boolean = true): Pair<Spanned?, List<URLSpan>> {
+    private fun getSpanPair(
+        text: String,
+        includePhoneNumbers: Boolean = true
+    ): Pair<Spanned?, List<URLSpan>> {
         val textView = TextView(InstrumentationRegistry.getInstrumentation().context)
         textView.text = text
-        InstrumentationRegistry.getInstrumentation().runOnMainSync{
+        InstrumentationRegistry.getInstrumentation().runOnMainSync {
             LinkifyUtil.getInstance().linkifyText(textView, includePhoneNumbers)
         }
         val spannableText = textView.text
@@ -51,10 +54,15 @@ class LinkifyUtilTest {
     /**
      * Expects that there are the spans as defined by the given set of span starts and ends.
      */
-    private fun assertSpans(text: String, spanPoints: Set<Pair<Int, Int>>, includePhoneNumbers: Boolean = true) {
+    private fun assertSpans(
+        text: String,
+        spanPoints: Set<Pair<Int, Int>>,
+        includePhoneNumbers: Boolean = true
+    ) {
         val (spannable, spans) = getSpanPair(text, includePhoneNumbers)
         assert(spannable != null || spans.isEmpty())
-        val actualSpanPoints = spans.map { spannable!!.getSpanStart(it) to spannable.getSpanEnd(it) }.toSet()
+        val actualSpanPoints =
+            spans.map { spannable!!.getSpanStart(it) to spannable.getSpanEnd(it) }.toSet()
         assertEquals(spanPoints, actualSpanPoints)
     }
 

+ 22 - 22
app/src/androidTest/java/ch/threema/app/utils/TextUtilTest.java

@@ -44,28 +44,28 @@ import static junit.framework.Assert.assertTrue;
 @RunWith(AndroidJUnit4.class)
 public class TextUtilTest {
 
-	@Test
-	public void testCheckBadPasswordNumericOnly() {
-		final Context context = ThreemaApplication.getAppContext();
-		assertTrue(TextUtil.checkBadPassword(context, "1234"));
-		assertTrue(TextUtil.checkBadPassword(context, "1234567890"));
-		assertTrue(TextUtil.checkBadPassword(context, "123456789012345"));
-		assertFalse(TextUtil.checkBadPassword(context, "1234567890123456"));
-		assertFalse(TextUtil.checkBadPassword(context, "12345678901234567890"));
-	}
+    @Test
+    public void testCheckBadPasswordNumericOnly() {
+        final Context context = ThreemaApplication.getAppContext();
+        assertTrue(TextUtil.checkBadPassword(context, "1234"));
+        assertTrue(TextUtil.checkBadPassword(context, "1234567890"));
+        assertTrue(TextUtil.checkBadPassword(context, "123456789012345"));
+        assertFalse(TextUtil.checkBadPassword(context, "1234567890123456"));
+        assertFalse(TextUtil.checkBadPassword(context, "12345678901234567890"));
+    }
 
-	@Test
-	public void testCheckBadPasswordSameCharacter() {
-		final Context context = ThreemaApplication.getAppContext();
-		assertTrue(TextUtil.checkBadPassword(context, "aaaaaaaaaaaa"));
-		assertFalse(TextUtil.checkBadPassword(context, "aaaaaaaaaaab"));
-	}
+    @Test
+    public void testCheckBadPasswordSameCharacter() {
+        final Context context = ThreemaApplication.getAppContext();
+        assertTrue(TextUtil.checkBadPassword(context, "aaaaaaaaaaaa"));
+        assertFalse(TextUtil.checkBadPassword(context, "aaaaaaaaaaab"));
+    }
 
-	@Test
-	public void testCheckBadPasswordWarnList() {
-		final Context context = ThreemaApplication.getAppContext();
-		assertTrue(TextUtil.checkBadPassword(context, "1Rainbow"));
-		assertTrue(TextUtil.checkBadPassword(context, "apples123"));
-		assertFalse(TextUtil.checkBadPassword(context, "kajsdlfkjalskdjflkajsdfl"));
-	}
+    @Test
+    public void testCheckBadPasswordWarnList() {
+        final Context context = ThreemaApplication.getAppContext();
+        assertTrue(TextUtil.checkBadPassword(context, "1Rainbow"));
+        assertTrue(TextUtil.checkBadPassword(context, "apples123"));
+        assertFalse(TextUtil.checkBadPassword(context, "kajsdlfkjalskdjflkajsdfl"));
+    }
 }

+ 569 - 568
app/src/androidTest/java/ch/threema/app/voip/SdpTest.java

@@ -50,578 +50,579 @@ import static junit.framework.Assert.assertTrue;
 /**
  * Ensure the Call SDP does not contain any "funny" easter eggs such as silly header extensions
  * that are not encrypted and contain sensitive information.
- *
+ * <p>
  * This may need updating from time to time, so if it breaks, you will have to do some
  * research on what changed and why.
  */
 @MediumTest
 @RunWith(AndroidJUnit4.class)
 public class SdpTest {
-	final private static String TAG = "SdpTest";
-	final private static long CALL_ID = 123;
-
-	private PeerConnectionClient.PeerConnectionParameters getParameters(boolean videoEnabled) {
-		// Return sane default parameters used for calls
-		return new PeerConnectionClient.PeerConnectionParameters(
-			false,
-			true, true,
-			videoEnabled, videoEnabled, true, true,
-			videoEnabled
-				? SdpPatcher.RtpHeaderExtensionConfig.ENABLE_WITH_ONE_AND_TWO_BYTE_HEADER
-				: SdpPatcher.RtpHeaderExtensionConfig.DISABLE,
-			false, true, true
-		);
-	}
-
-	abstract class PeerConnectionClientEvents implements PeerConnectionClient.Events {
-		@NonNull public final Semaphore done;
-		@Nullable
-		public SessionDescription localSdp = null;
-
-		public PeerConnectionClientEvents() throws InterruptedException {
-			this.done = new Semaphore(1);
-			this.done.acquire();
-		}
-
-		@Override
-		public void onLocalDescription(long callId, SessionDescription sdp) {
-			Log.d(TAG,"onLocalDescription");
-			this.localSdp = sdp;
-		}
-
-		@Override
-		public void onRemoteDescriptionSet(long callId) {
-			Log.d(TAG,"onRemoteDescriptionSet");
-		}
-
-		@Override
-		public void onIceCandidate(long callId, IceCandidate candidate) {
-			Log.d(TAG,"onIceCandidate");
-		}
-
-		@Override
-		public void onTransportConnecting(long callId) {
-			Log.d(TAG,"onTransportConnecting");
-		}
-
-		@Override
-		public void onTransportConnected(long callId) {
-			Log.d(TAG,"onTransportConnected");
-		}
-
-		@Override
-		public void onTransportDisconnected(long callId) {
-			Log.d(TAG,"onTransportDisconnected");
-		}
-
-		@Override
-		public void onTransportFailed(long callId) {
-			Log.d(TAG,"onTransportFailed");
-		}
-
-		@Override
-		public void onIceGatheringStateChange(long callId, PeerConnection.IceGatheringState newState) {
-			Log.d(TAG,"onIceGatheringStateChange");
-		}
-
-		@Override
-		public void onPeerConnectionClosed(long callId) {
-			Log.d(TAG,"onPeerConnectionClosed");
-			this.done.release();
-		}
-
-		@Override
-		public void onError(long callId, @NonNull String description, boolean abortCall) {
-			Log.d(TAG, String.format("onError: %s (abortCall: %s)", description, abortCall));
-		}
-	}
-
-	@Nullable
-	private SessionDescription generateFakeOffer(boolean videoEnabled) {
-		if (videoEnabled) {
-			return new SessionDescription(SessionDescription.Type.OFFER, "" +
-				"v=0\r\n" +
-				"o=- 72507000979779968 2 IN IP4 127.0.0.1\r\n" +
-				"s=-\r\n" +
-				"t=0 0\r\n" +
-				"a=group:BUNDLE 0 1 2\r\n" +
-				"a=extmap-allow-mixed\r\n" +
-				"a=msid-semantic: WMS 3MACALL\r\n" +
-				"m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 102 0 8 106 105 13 110 112 113 126\r\n" +
-				"c=IN IP4 0.0.0.0\r\n" +
-				"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
-				"a=ice-ufrag:f30j\r\n" +
-				"a=ice-pwd:G9GzFLlk1gthsg9uVhI3OyGv\r\n" +
-				"a=ice-options:trickle renomination\r\n" +
-				"a=fingerprint:sha-256 AE:86:73:4B:8A:55:BE:F1:2F:A2:8E:AA:98:8D:42:A4:D6:F8:2D:1C:CC:CD:12:C5:8E:14:BD:34:62:DA:35:8E\r\n" +
-				"a=setup:actpass\r\n" +
-				"a=mid:0\r\n" +
-				"a=extmap:10 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
-				"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
-				"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
-				"a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
-				"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
-				"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
-				"a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
-				"a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
-				"a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
-				"a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
-				"a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
-				"a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
-				"a=sendrecv\r\n" +
-				"a=msid:3MACALL 3MACALLa0\r\n" +
-				"a=rtcp-mux\r\n" +
-				"a=rtpmap:111 opus/48000/2\r\n" +
-				"a=rtcp-fb:111 transport-cc\r\n" +
-				"a=fmtp:111 minptime=10;useinbandfec=1\r\n" +
-				"a=rtpmap:103 ISAC/16000\r\n" +
-				"a=rtpmap:104 ISAC/32000\r\n" +
-				"a=rtpmap:9 G722/8000\r\n" +
-				"a=rtpmap:102 ILBC/8000\r\n" +
-				"a=rtpmap:0 PCMU/8000\r\n" +
-				"a=rtpmap:8 PCMA/8000\r\n" +
-				"a=rtpmap:106 CN/32000\r\n" +
-				"a=rtpmap:105 CN/16000\r\n" +
-				"a=rtpmap:13 CN/8000\r\n" +
-				"a=rtpmap:110 telephone-event/48000\r\n" +
-				"a=rtpmap:112 telephone-event/32000\r\n" +
-				"a=rtpmap:113 telephone-event/16000\r\n" +
-				"a=rtpmap:126 telephone-event/8000\r\n" +
-				"a=ssrc:3148626149 cname:xmp2nT2LrKeffKAn\r\n" +
-				"a=ssrc:3148626149 msid:3MACALL 3MACALLa0\r\n" +
-				"a=ssrc:3148626149 mslabel:3MACALL\r\n" +
-				"a=ssrc:3148626149 label:3MACALLa0\r\n" +
-				"m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 35 36 127 123 125 37\r\n" +
-				"c=IN IP4 0.0.0.0\r\n" +
-				"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
-				"a=ice-ufrag:f30j\r\n" +
-				"a=ice-pwd:G9GzFLlk1gthsg9uVhI3OyGv\r\n" +
-				"a=ice-options:trickle renomination\r\n" +
-				"a=fingerprint:sha-256 AE:86:73:4B:8A:55:BE:F1:2F:A2:8E:AA:98:8D:42:A4:D6:F8:2D:1C:CC:CD:12:C5:8E:14:BD:34:62:DA:35:8E\r\n" +
-				"a=setup:actpass\r\n" +
-				"a=mid:1\r\n" +
-				"a=extmap:25 urn:ietf:params:rtp-hdrext:encrypt http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07\r\n" +
-				"a=extmap:26 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\n" +
-				"a=extmap:14 urn:ietf:params:rtp-hdrext:toffset\r\n" +
-				"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
-				"a=extmap:13 urn:3gpp:video-orientation\r\n" +
-				"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
-				"a=extmap:12 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\n" +
-				"a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\n" +
-				"a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
-				"a=extmap:8 http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07\r\n" +
-				"a=extmap:9 http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\n" +
-				"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
-				"a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
-				"a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
-				"a=extmap:20 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset\r\n" +
-				"a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
-				"a=extmap:21 urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation\r\n" +
-				"a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
-				"a=extmap:22 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\n" +
-				"a=extmap:23 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\n" +
-				"a=extmap:24 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\n" +
-				"a=extmap:11 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\n" +
-				"a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
-				"a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
-				"a=sendrecv\r\n" +
-				"a=msid:3MACALL 3MACALLv0\r\n" +
-				"a=rtcp-mux\r\n" +
-				"a=rtcp-rsize\r\n" +
-				"a=rtpmap:96 VP8/90000\r\n" +
-				"a=rtcp-fb:96 goog-remb\r\n" +
-				"a=rtcp-fb:96 transport-cc\r\n" +
-				"a=rtcp-fb:96 ccm fir\r\n" +
-				"a=rtcp-fb:96 nack\r\n" +
-				"a=rtcp-fb:96 nack pli\r\n" +
-				"a=rtpmap:97 rtx/90000\r\n" +
-				"a=fmtp:97 apt=96\r\n" +
-				"a=rtpmap:98 VP9/90000\r\n" +
-				"a=rtcp-fb:98 goog-remb\r\n" +
-				"a=rtcp-fb:98 transport-cc\r\n" +
-				"a=rtcp-fb:98 ccm fir\r\n" +
-				"a=rtcp-fb:98 nack\r\n" +
-				"a=rtcp-fb:98 nack pli\r\n" +
-				"a=rtpmap:99 rtx/90000\r\n" +
-				"a=fmtp:99 apt=98\r\n" +
-				"a=rtpmap:100 H264/90000\r\n" +
-				"a=rtcp-fb:100 goog-remb\r\n" +
-				"a=rtcp-fb:100 transport-cc\r\n" +
-				"a=rtcp-fb:100 ccm fir\r\n" +
-				"a=rtcp-fb:100 nack\r\n" +
-				"a=rtcp-fb:100 nack pli\r\n" +
-				"a=fmtp:100 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\n" +
-				"a=rtpmap:101 rtx/90000\r\n" +
-				"a=fmtp:101 apt=100\r\n" +
-				"a=rtpmap:35 AV1/90000\r\n" +
-				"a=rtcp-fb:35 goog-remb\r\n" +
-				"a=rtcp-fb:35 transport-cc\r\n" +
-				"a=rtcp-fb:35 ccm fir\r\n" +
-				"a=rtcp-fb:35 nack\r\n" +
-				"a=rtcp-fb:35 nack pli\r\n" +
-				"a=rtpmap:36 rtx/90000\r\n" +
-				"a=fmtp:36 apt=35\r\n" +
-				"a=rtpmap:127 red/90000\r\n" +
-				"a=rtpmap:123 rtx/90000\r\n" +
-				"a=fmtp:123 apt=127\r\n" +
-				"a=rtpmap:125 ulpfec/90000\r\n" +
-				"a=rtpmap:37 flexfec-03/90000\r\n" +
-				"a=rtcp-fb:37 goog-remb\r\n" +
-				"a=rtcp-fb:37 transport-cc\r\n" +
-				"a=fmtp:37 repair-window=10000000\r\n" +
-				"a=ssrc-group:FID 2961420724 927121398\r\n" +
-				"a=ssrc:2961420724 cname:xmp2nT2LrKeffKAn\r\n" +
-				"a=ssrc:2961420724 msid:3MACALL 3MACALLv0\r\n" +
-				"a=ssrc:2961420724 mslabel:3MACALL\r\n" +
-				"a=ssrc:2961420724 label:3MACALLv0\r\n" +
-				"a=ssrc:927121398 cname:xmp2nT2LrKeffKAn\r\n" +
-				"a=ssrc:927121398 msid:3MACALL 3MACALLv0\r\n" +
-				"a=ssrc:927121398 mslabel:3MACALL\r\n" +
-				"a=ssrc:927121398 label:3MACALLv0\r\n" +
-				"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
-				"c=IN IP4 0.0.0.0\r\n" +
-				"a=ice-ufrag:f30j\r\n" +
-				"a=ice-pwd:G9GzFLlk1gthsg9uVhI3OyGv\r\n" +
-				"a=ice-options:trickle renomination\r\n" +
-				"a=fingerprint:sha-256 AE:86:73:4B:8A:55:BE:F1:2F:A2:8E:AA:98:8D:42:A4:D6:F8:2D:1C:CC:CD:12:C5:8E:14:BD:34:62:DA:35:8E\r\n" +
-				"a=setup:actpass\r\n" +
-				"a=mid:2\r\n" +
-				"a=sctp-port:5000\r\n" +
-				"a=max-message-size:262144\r\n"
-			);
-		} else {
-			return new SessionDescription(SessionDescription.Type.OFFER, "" +
-				"v=0\r\n" +
-				"o=- 8329341859617817285 2 IN IP4 127.0.0.1\r\n" +
-				"s=-\r\n" +
-				"t=0 0\r\n" +
-				"a=group:BUNDLE 0\r\n" +
-				"a=extmap-allow-mixed\r\n" +
-				"a=msid-semantic: WMS 3MACALL\r\n" +
-				"m=audio 9 UDP/TLS/RTP/SAVPF 111 103 9 102 0 8 105 13 110 113 126\r\n" +
-				"c=IN IP4 0.0.0.0\r\n" +
-				"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
-				"a=ice-ufrag:hFGR\r\n" +
-				"a=ice-pwd:HPszOFM6RDZWdhZ3PpPQ7w1H\r\n" +
-				"a=ice-options:renomination\r\n" +
-				"a=fingerprint:sha-256 F7:3A:7C:0C:A0:1E:EA:C5:2E:33:ED:90:61:55:0E:DF:59:8E:EA:EF:A6:E3:01:6E:A5:9E:34:78:5E:E3:8E:44\r\n" +
-				"a=setup:actpass\r\n" +
-				"a=mid:0\r\n" +
-				"a=extmap:10 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
-				"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
-				"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
-				"a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
-				"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
-				"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
-				"a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
-				"a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
-				"a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
-				"a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
-				"a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
-				"a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
-				"a=sendrecv\r\n" +
-				"a=msid:3MACALL 3MACALLa0\r\n" +
-				"a=rtcp-mux\r\n" +
-				"a=rtpmap:111 opus/48000/2\r\n" +
-				"a=rtcp-fb:111 transport-cc\r\n" +
-				"a=fmtp:111 minptime=10;useinbandfec=1\r\n" +
-				"a=rtpmap:103 ISAC/16000\r\n" +
-				"a=rtpmap:9 G722/8000\r\n" +
-				"a=rtpmap:102 ILBC/8000\r\n" +
-				"a=rtpmap:0 PCMU/8000\r\n" +
-				"a=rtpmap:8 PCMA/8000\r\n" +
-				"a=rtpmap:105 CN/16000\r\n" +
-				"a=rtpmap:13 CN/8000\r\n" +
-				"a=rtpmap:110 telephone-event/48000\r\n" +
-				"a=rtpmap:113 telephone-event/16000\r\n" +
-				"a=rtpmap:126 telephone-event/8000\r\n" +
-				"a=ssrc:2080079676 cname:Jb5aR24iJnFDp6OS\r\n" +
-				"a=ssrc:2080079676 msid:3MACALL 3MACALLa0\r\n" +
-				"a=ssrc:2080079676 mslabel:3MACALL\r\n" +
-				"a=ssrc:2080079676 label:3MACALLa0\r\n"
-			);
-		}
-	}
-
-	private void validateDescription(@NonNull SessionDescription sdp, boolean videoEnabled, boolean isOffer) {
-		final List<String> actualLines = Arrays.asList(sdp.description.split("\r\n"));
-		Log.d(TAG, "SDP:\n" + sdp.description);
-		final List<String> matches = new ArrayList<>();
-		int lineOffset = 0;
-
-		// Session lines
-		matches.add("^v=0$");
-		matches.add("^o=- \\d+ \\d IN IP4 127.0.0.1$");
-		matches.add("^s=-$");
-		matches.add("^t=0 0$");
-		matches.add("^a=group:BUNDLE( \\d+)+");
-		if (videoEnabled) {
-			matches.add("^a=extmap-allow-mixed$");
-		}
-		matches.add("^a=msid-semantic: WMS 3MACALL$");
-		lineOffset += matchEachLine(matches, actualLines, lineOffset);
-
-		// Audio lines
-		matches.add("^m=audio 9 UDP/TLS/RTP/SAVPF \\d+$");
-		matches.add("^c=IN IP4 0.0.0.0$");
-		matches.add("^a=rtcp:9 IN IP4 0.0.0.0$");
-		matches.add("^a=ice-ufrag:[^ ]+$");
-		matches.add("^a=ice-pwd:[^ ]+$");
-		matches.add("^a=ice-options:trickle renomination$");
-		matches.add("^a=fingerprint:sha-256 [^ ]+$");
-		matches.add("^a=setup:(actpass|active)");
-		matches.add("^a=mid:0");
-		if (videoEnabled) {
-			if (isOffer) {
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
-			} else {
-				matches.add("^a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
-				matches.add("^a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
-				matches.add("^a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
-			}
-		}
-		matches.add("^a=sendrecv$");
-		matches.add("^a=msid:3MACALL 3MACALLa0");
-		matches.add("^a=rtcp-mux$");
-		matches.add("^a=rtpmap:\\d+ opus/48000/2$");
-		matches.add("^a=rtcp-fb:\\d+ transport-cc$");
-		matches.add("^a=fmtp:\\d+ minptime=10;useinbandfec=1;stereo=0;sprop-stereo=0;cbr=1$");
-		matches.add("^a=ssrc:\\d+ cname:[^ ]+$");
-		if (isOffer) {
-			matches.add("^a=ssrc:\\d+ msid:3MACALL 3MACALLa0$");
-		}
-		lineOffset += matchEachLine(matches, actualLines, lineOffset);
-
-		// Video lines
-		if (videoEnabled) {
-			matches.add("^m=video 9 UDP/TLS/RTP/SAVPF( \\d+)+$");
-			matches.add("^c=IN IP4 0.0.0.0$");
-			matches.add("^a=rtcp:9 IN IP4 0.0.0.0$");
-			matches.add("^a=ice-ufrag:[^ ]+$");
-			matches.add("^a=ice-pwd:[^ ]+$");
-			matches.add("^a=ice-options:trickle renomination$");
-			matches.add("^a=fingerprint:sha-256 [^ ]+$");
-			matches.add("^a=setup:(actpass|active)");
-			matches.add("^a=mid:1$");
-			if (isOffer) {
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id$");
-				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id$");
-			} else {
-				matches.add("^a=extmap:20 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset$");
-				matches.add("^a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
-				matches.add("^a=extmap:21 urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation$");
-				matches.add("^a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
-				matches.add("^a=extmap:22 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay$");
-				matches.add("^a=extmap:23 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type$");
-				matches.add("^a=extmap:24 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing$");
-				matches.add("^a=extmap:26 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space$");
-				matches.add("^a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
-				matches.add("^a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id$");
-				matches.add("^a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id$");
-			}
-			// TODO(SE-63): Ehh, dirty hack... it should create a transceiver instead
-			matches.add("^a=recvonly");
-//			expectedMatchesPart1.add("^a=sendrecv");
-//			expectedMatchesPart1.add("^a=msid:3MACALL 3MACALLv0");
-			matches.add("^a=rtcp-mux$");
-			matches.add("^a=rtcp-rsize$");
-
-			matches.add("^a=rtpmap:\\d+ VP8/90000$");
-			matches.add("^a=rtcp-fb:\\d+ goog-remb$");
-			matches.add("^a=rtcp-fb:\\d+ transport-cc$");
-			matches.add("^a=rtcp-fb:\\d+ ccm fir$");
-			matches.add("^a=rtcp-fb:\\d+ nack$");
-			matches.add("^a=rtcp-fb:\\d+ nack pli$");
-			matches.add("^a=rtpmap:\\d+ rtx/90000$");
-			matches.add("^a=fmtp:\\d+ apt=\\d+$");
-
-			// Since M110 we will generate a bunch of different VP9 profiles.
-			// For now, we're lenient and just accept these even though it likely makes no sense
-			// for our use case.
-			lineOffset += matchEachLine(matches, actualLines, lineOffset);
-			for (int i = 0; i < 4; ++i) {
-				String line = actualLines.get(lineOffset);
-				if (line == null || !line.matches("^a=rtpmap:\\d+ VP9/90000$")) {
-					assertTrue("At least one VP9 codec profile is expected", i > 0);
-					break;
-				}
-
-				matches.add("^a=rtpmap:\\d+ VP9/90000$");
-				matches.add("^a=rtcp-fb:\\d+ goog-remb$");
-				matches.add("^a=rtcp-fb:\\d+ transport-cc$");
-				matches.add("^a=rtcp-fb:\\d+ ccm fir$");
-				matches.add("^a=rtcp-fb:\\d+ nack$");
-				matches.add("^a=rtcp-fb:\\d+ nack pli$");
-				matches.add("^a=fmtp:\\d+ profile-id=\\d$");
-				matches.add("^a=rtpmap:\\d+ rtx/90000$");
-				matches.add("^a=fmtp:\\d+ apt=\\d+$");
-				lineOffset += matchEachLine(matches, actualLines, lineOffset);
-			}
-
-			// Other video codec lines (dynamically detected HW codec support, e.g. H264)
-			lineOffset += matchEachLine(matches, actualLines, lineOffset);
-			for (;;) {
-				String line = actualLines.get(lineOffset);
-				if (line == null || line.matches("^a=rtpmap:\\d+ red/90000")) {
-					break;
-				}
-				lineOffset++;
-			}
-
-			matches.add("^a=rtpmap:\\d+ red/90000");
-			matches.add("^a=rtpmap:\\d+ rtx/90000");
-			matches.add("^a=fmtp:\\d+ apt=\\d+$");
-
-			matches.add("^a=rtpmap:\\d+ ulpfec/90000");
-
-			matches.add("^a=rtpmap:\\d+ flexfec-03/90000");
-			matches.add("^a=rtcp-fb:\\d+ goog-remb$");
-			matches.add("^a=rtcp-fb:\\d+ transport-cc$");
-			matches.add("^a=fmtp:\\d+ repair-window=\\d+$");
-
-			lineOffset += matchEachLine(matches, actualLines, lineOffset);
-		}
-
-		if (isOffer || videoEnabled) {
-			// Data channel lines
-			matches.add("^m=application 9 UDP/DTLS/SCTP webrtc-datachannel$");
-			matches.add("^c=IN IP4 0.0.0.0$");
-			matches.add("^a=ice-ufrag:[^ ]+$");
-			matches.add("^a=ice-pwd:[^ ]+$");
-			matches.add("^a=ice-options:trickle renomination$");
-			matches.add("^a=fingerprint:sha-256 [^ ]+$");
-			matches.add("^a=setup:(actpass|active)$");
-			matches.add("^a=mid:[^ ]+$");
-			matches.add("^a=sctp-port:5000$");
-			matches.add("^a=max-message-size:262144$");
-			lineOffset += matchEachLine(matches, actualLines, lineOffset);
-		}
-
-		// Lines must be equal
-		assertEquals(lineOffset, actualLines.size());
-	}
-
-	/**
-	 * Helper for validateDescription
-	 */
-	private int matchEachLine(List<String> expectedMatches, List<String> actualLines, int offset) {
-		int expectedLength = expectedMatches.size();
-		for (int i = 0; i < expectedLength; ++i) {
-			final String expected = expectedMatches.get(i);
-			final String actual = i < actualLines.size() ? actualLines.get(i + offset) : null;
-			Log.d(TAG, "Validating \"" + actual + "\" against \"" + expected + "\"");
-			assertNotNull(actual);
-			assertTrue("Line \"" + actual + "\" did not match \"" + expected + "\"", actual.matches(expected));
-		}
-		expectedMatches.clear();
-		return expectedLength;
-	}
-
-	public void testOffer(boolean videoEnabled) throws InterruptedException, ExecutionException {
-		final PeerConnectionClient pc = new PeerConnectionClient(
-			ApplicationProvider.getApplicationContext(),
-			this.getParameters(videoEnabled),
-			null,
-			CALL_ID
-		);
-		pc.setEnableIceServers(false);
-
-		final PeerConnectionClientEvents events = new PeerConnectionClientEvents() {
-			@Override
-			public void onLocalDescription(long callId, SessionDescription sdp) {
-				super.onLocalDescription(callId, sdp);
-				pc.close();
-			}
-		};
-		pc.setEventHandler(events);
-
-		// Create peer connection & offer
-		final boolean factoryCreateSuccess = pc.createPeerConnectionFactory().get();
-		assertTrue(factoryCreateSuccess);
-		pc.createPeerConnection();
-		pc.createOffer();
-
-		// Wait until local description (offer) available
-		assertTrue(events.done.tryAcquire(10, TimeUnit.SECONDS));
-
-		// Compare SDP
-		assertNotNull(events.localSdp);
-		assertEquals(events.localSdp.type, SessionDescription.Type.OFFER);
-		this.validateDescription(events.localSdp, videoEnabled, true);
-	}
-
-	@Test
-	public void testOfferAudioOnly() throws InterruptedException, ExecutionException {
-		this.testOffer(false);
-	}
-
-	@Test
-	public void testOfferVideo() throws InterruptedException, ExecutionException {
-		this.testOffer(true);
-	}
-
-
-	private void testAnswer(boolean videoEnabled) throws InterruptedException, ExecutionException {
-		final PeerConnectionClient pc = new PeerConnectionClient(
-			ApplicationProvider.getApplicationContext(),
-			this.getParameters(videoEnabled),
-			null,
-			1
-		);
-		pc.setEnableIceServers(false);
-
-		final PeerConnectionClientEvents events = new PeerConnectionClientEvents() {
-			@Override
-			public void onLocalDescription(long callId, SessionDescription sdp) {
-				super.onLocalDescription(callId, sdp);
-				pc.close();
-			}
-
-			@Override
-			public void onRemoteDescriptionSet(long callId) {
-				pc.createAnswer();
-			}
-		};
-		pc.setEventHandler(events);
-
-		// Create factory
-		final boolean factoryCreateSuccess = pc.createPeerConnectionFactory().get();
-		assertTrue(factoryCreateSuccess);
-
-		// Create fake offer
-		final SessionDescription fakeOffer = this.generateFakeOffer(videoEnabled);
-
-		// Create peer connection & set fake offer
-		pc.createPeerConnection();
-		pc.setRemoteDescription(fakeOffer);
-
-		// Wait until local description (answer) available
-		assertTrue(events.done.tryAcquire(10, TimeUnit.SECONDS));
-
-		// Compare SDP
-		assertNotNull(events.localSdp);
-		assertEquals(events.localSdp.type, SessionDescription.Type.ANSWER);
-		this.validateDescription(events.localSdp, videoEnabled, false);
-	}
-
-	@Test
-	public void testAnswerAudioOnly() throws InterruptedException, ExecutionException {
-		this.testAnswer(false);
-	}
-
-	@Test
-	public void testAnswerVideo() throws InterruptedException, ExecutionException {
-		this.testAnswer(true);
-	}
+    final private static String TAG = "SdpTest";
+    final private static long CALL_ID = 123;
+
+    private PeerConnectionClient.PeerConnectionParameters getParameters(boolean videoEnabled) {
+        // Return sane default parameters used for calls
+        return new PeerConnectionClient.PeerConnectionParameters(
+            false,
+            true, true,
+            videoEnabled, videoEnabled, true, true,
+            videoEnabled
+                ? SdpPatcher.RtpHeaderExtensionConfig.ENABLE_WITH_ONE_AND_TWO_BYTE_HEADER
+                : SdpPatcher.RtpHeaderExtensionConfig.DISABLE,
+            false, true, true
+        );
+    }
+
+    abstract class PeerConnectionClientEvents implements PeerConnectionClient.Events {
+        @NonNull
+        public final Semaphore done;
+        @Nullable
+        public SessionDescription localSdp = null;
+
+        public PeerConnectionClientEvents() throws InterruptedException {
+            this.done = new Semaphore(1);
+            this.done.acquire();
+        }
+
+        @Override
+        public void onLocalDescription(long callId, SessionDescription sdp) {
+            Log.d(TAG, "onLocalDescription");
+            this.localSdp = sdp;
+        }
+
+        @Override
+        public void onRemoteDescriptionSet(long callId) {
+            Log.d(TAG, "onRemoteDescriptionSet");
+        }
+
+        @Override
+        public void onIceCandidate(long callId, IceCandidate candidate) {
+            Log.d(TAG, "onIceCandidate");
+        }
+
+        @Override
+        public void onTransportConnecting(long callId) {
+            Log.d(TAG, "onTransportConnecting");
+        }
+
+        @Override
+        public void onTransportConnected(long callId) {
+            Log.d(TAG, "onTransportConnected");
+        }
+
+        @Override
+        public void onTransportDisconnected(long callId) {
+            Log.d(TAG, "onTransportDisconnected");
+        }
+
+        @Override
+        public void onTransportFailed(long callId) {
+            Log.d(TAG, "onTransportFailed");
+        }
+
+        @Override
+        public void onIceGatheringStateChange(long callId, PeerConnection.IceGatheringState newState) {
+            Log.d(TAG, "onIceGatheringStateChange");
+        }
+
+        @Override
+        public void onPeerConnectionClosed(long callId) {
+            Log.d(TAG, "onPeerConnectionClosed");
+            this.done.release();
+        }
+
+        @Override
+        public void onError(long callId, @NonNull String description, boolean abortCall) {
+            Log.d(TAG, String.format("onError: %s (abortCall: %s)", description, abortCall));
+        }
+    }
+
+    @Nullable
+    private SessionDescription generateFakeOffer(boolean videoEnabled) {
+        if (videoEnabled) {
+            return new SessionDescription(SessionDescription.Type.OFFER, "" +
+                "v=0\r\n" +
+                "o=- 72507000979779968 2 IN IP4 127.0.0.1\r\n" +
+                "s=-\r\n" +
+                "t=0 0\r\n" +
+                "a=group:BUNDLE 0 1 2\r\n" +
+                "a=extmap-allow-mixed\r\n" +
+                "a=msid-semantic: WMS 3MACALL\r\n" +
+                "m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 102 0 8 106 105 13 110 112 113 126\r\n" +
+                "c=IN IP4 0.0.0.0\r\n" +
+                "a=rtcp:9 IN IP4 0.0.0.0\r\n" +
+                "a=ice-ufrag:f30j\r\n" +
+                "a=ice-pwd:G9GzFLlk1gthsg9uVhI3OyGv\r\n" +
+                "a=ice-options:trickle renomination\r\n" +
+                "a=fingerprint:sha-256 AE:86:73:4B:8A:55:BE:F1:2F:A2:8E:AA:98:8D:42:A4:D6:F8:2D:1C:CC:CD:12:C5:8E:14:BD:34:62:DA:35:8E\r\n" +
+                "a=setup:actpass\r\n" +
+                "a=mid:0\r\n" +
+                "a=extmap:10 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
+                "a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
+                "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
+                "a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
+                "a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
+                "a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
+                "a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
+                "a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
+                "a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
+                "a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
+                "a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
+                "a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
+                "a=sendrecv\r\n" +
+                "a=msid:3MACALL 3MACALLa0\r\n" +
+                "a=rtcp-mux\r\n" +
+                "a=rtpmap:111 opus/48000/2\r\n" +
+                "a=rtcp-fb:111 transport-cc\r\n" +
+                "a=fmtp:111 minptime=10;useinbandfec=1\r\n" +
+                "a=rtpmap:103 ISAC/16000\r\n" +
+                "a=rtpmap:104 ISAC/32000\r\n" +
+                "a=rtpmap:9 G722/8000\r\n" +
+                "a=rtpmap:102 ILBC/8000\r\n" +
+                "a=rtpmap:0 PCMU/8000\r\n" +
+                "a=rtpmap:8 PCMA/8000\r\n" +
+                "a=rtpmap:106 CN/32000\r\n" +
+                "a=rtpmap:105 CN/16000\r\n" +
+                "a=rtpmap:13 CN/8000\r\n" +
+                "a=rtpmap:110 telephone-event/48000\r\n" +
+                "a=rtpmap:112 telephone-event/32000\r\n" +
+                "a=rtpmap:113 telephone-event/16000\r\n" +
+                "a=rtpmap:126 telephone-event/8000\r\n" +
+                "a=ssrc:3148626149 cname:xmp2nT2LrKeffKAn\r\n" +
+                "a=ssrc:3148626149 msid:3MACALL 3MACALLa0\r\n" +
+                "a=ssrc:3148626149 mslabel:3MACALL\r\n" +
+                "a=ssrc:3148626149 label:3MACALLa0\r\n" +
+                "m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 35 36 127 123 125 37\r\n" +
+                "c=IN IP4 0.0.0.0\r\n" +
+                "a=rtcp:9 IN IP4 0.0.0.0\r\n" +
+                "a=ice-ufrag:f30j\r\n" +
+                "a=ice-pwd:G9GzFLlk1gthsg9uVhI3OyGv\r\n" +
+                "a=ice-options:trickle renomination\r\n" +
+                "a=fingerprint:sha-256 AE:86:73:4B:8A:55:BE:F1:2F:A2:8E:AA:98:8D:42:A4:D6:F8:2D:1C:CC:CD:12:C5:8E:14:BD:34:62:DA:35:8E\r\n" +
+                "a=setup:actpass\r\n" +
+                "a=mid:1\r\n" +
+                "a=extmap:25 urn:ietf:params:rtp-hdrext:encrypt http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07\r\n" +
+                "a=extmap:26 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\n" +
+                "a=extmap:14 urn:ietf:params:rtp-hdrext:toffset\r\n" +
+                "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
+                "a=extmap:13 urn:3gpp:video-orientation\r\n" +
+                "a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
+                "a=extmap:12 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\n" +
+                "a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\n" +
+                "a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
+                "a=extmap:8 http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07\r\n" +
+                "a=extmap:9 http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\n" +
+                "a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
+                "a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
+                "a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
+                "a=extmap:20 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset\r\n" +
+                "a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
+                "a=extmap:21 urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation\r\n" +
+                "a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
+                "a=extmap:22 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\n" +
+                "a=extmap:23 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\n" +
+                "a=extmap:24 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\n" +
+                "a=extmap:11 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\n" +
+                "a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
+                "a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
+                "a=sendrecv\r\n" +
+                "a=msid:3MACALL 3MACALLv0\r\n" +
+                "a=rtcp-mux\r\n" +
+                "a=rtcp-rsize\r\n" +
+                "a=rtpmap:96 VP8/90000\r\n" +
+                "a=rtcp-fb:96 goog-remb\r\n" +
+                "a=rtcp-fb:96 transport-cc\r\n" +
+                "a=rtcp-fb:96 ccm fir\r\n" +
+                "a=rtcp-fb:96 nack\r\n" +
+                "a=rtcp-fb:96 nack pli\r\n" +
+                "a=rtpmap:97 rtx/90000\r\n" +
+                "a=fmtp:97 apt=96\r\n" +
+                "a=rtpmap:98 VP9/90000\r\n" +
+                "a=rtcp-fb:98 goog-remb\r\n" +
+                "a=rtcp-fb:98 transport-cc\r\n" +
+                "a=rtcp-fb:98 ccm fir\r\n" +
+                "a=rtcp-fb:98 nack\r\n" +
+                "a=rtcp-fb:98 nack pli\r\n" +
+                "a=rtpmap:99 rtx/90000\r\n" +
+                "a=fmtp:99 apt=98\r\n" +
+                "a=rtpmap:100 H264/90000\r\n" +
+                "a=rtcp-fb:100 goog-remb\r\n" +
+                "a=rtcp-fb:100 transport-cc\r\n" +
+                "a=rtcp-fb:100 ccm fir\r\n" +
+                "a=rtcp-fb:100 nack\r\n" +
+                "a=rtcp-fb:100 nack pli\r\n" +
+                "a=fmtp:100 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\n" +
+                "a=rtpmap:101 rtx/90000\r\n" +
+                "a=fmtp:101 apt=100\r\n" +
+                "a=rtpmap:35 AV1/90000\r\n" +
+                "a=rtcp-fb:35 goog-remb\r\n" +
+                "a=rtcp-fb:35 transport-cc\r\n" +
+                "a=rtcp-fb:35 ccm fir\r\n" +
+                "a=rtcp-fb:35 nack\r\n" +
+                "a=rtcp-fb:35 nack pli\r\n" +
+                "a=rtpmap:36 rtx/90000\r\n" +
+                "a=fmtp:36 apt=35\r\n" +
+                "a=rtpmap:127 red/90000\r\n" +
+                "a=rtpmap:123 rtx/90000\r\n" +
+                "a=fmtp:123 apt=127\r\n" +
+                "a=rtpmap:125 ulpfec/90000\r\n" +
+                "a=rtpmap:37 flexfec-03/90000\r\n" +
+                "a=rtcp-fb:37 goog-remb\r\n" +
+                "a=rtcp-fb:37 transport-cc\r\n" +
+                "a=fmtp:37 repair-window=10000000\r\n" +
+                "a=ssrc-group:FID 2961420724 927121398\r\n" +
+                "a=ssrc:2961420724 cname:xmp2nT2LrKeffKAn\r\n" +
+                "a=ssrc:2961420724 msid:3MACALL 3MACALLv0\r\n" +
+                "a=ssrc:2961420724 mslabel:3MACALL\r\n" +
+                "a=ssrc:2961420724 label:3MACALLv0\r\n" +
+                "a=ssrc:927121398 cname:xmp2nT2LrKeffKAn\r\n" +
+                "a=ssrc:927121398 msid:3MACALL 3MACALLv0\r\n" +
+                "a=ssrc:927121398 mslabel:3MACALL\r\n" +
+                "a=ssrc:927121398 label:3MACALLv0\r\n" +
+                "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
+                "c=IN IP4 0.0.0.0\r\n" +
+                "a=ice-ufrag:f30j\r\n" +
+                "a=ice-pwd:G9GzFLlk1gthsg9uVhI3OyGv\r\n" +
+                "a=ice-options:trickle renomination\r\n" +
+                "a=fingerprint:sha-256 AE:86:73:4B:8A:55:BE:F1:2F:A2:8E:AA:98:8D:42:A4:D6:F8:2D:1C:CC:CD:12:C5:8E:14:BD:34:62:DA:35:8E\r\n" +
+                "a=setup:actpass\r\n" +
+                "a=mid:2\r\n" +
+                "a=sctp-port:5000\r\n" +
+                "a=max-message-size:262144\r\n"
+            );
+        } else {
+            return new SessionDescription(SessionDescription.Type.OFFER, "" +
+                "v=0\r\n" +
+                "o=- 8329341859617817285 2 IN IP4 127.0.0.1\r\n" +
+                "s=-\r\n" +
+                "t=0 0\r\n" +
+                "a=group:BUNDLE 0\r\n" +
+                "a=extmap-allow-mixed\r\n" +
+                "a=msid-semantic: WMS 3MACALL\r\n" +
+                "m=audio 9 UDP/TLS/RTP/SAVPF 111 103 9 102 0 8 105 13 110 113 126\r\n" +
+                "c=IN IP4 0.0.0.0\r\n" +
+                "a=rtcp:9 IN IP4 0.0.0.0\r\n" +
+                "a=ice-ufrag:hFGR\r\n" +
+                "a=ice-pwd:HPszOFM6RDZWdhZ3PpPQ7w1H\r\n" +
+                "a=ice-options:renomination\r\n" +
+                "a=fingerprint:sha-256 F7:3A:7C:0C:A0:1E:EA:C5:2E:33:ED:90:61:55:0E:DF:59:8E:EA:EF:A6:E3:01:6E:A5:9E:34:78:5E:E3:8E:44\r\n" +
+                "a=setup:actpass\r\n" +
+                "a=mid:0\r\n" +
+                "a=extmap:10 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
+                "a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
+                "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
+                "a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
+                "a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
+                "a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
+                "a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
+                "a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
+                "a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
+                "a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
+                "a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
+                "a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
+                "a=sendrecv\r\n" +
+                "a=msid:3MACALL 3MACALLa0\r\n" +
+                "a=rtcp-mux\r\n" +
+                "a=rtpmap:111 opus/48000/2\r\n" +
+                "a=rtcp-fb:111 transport-cc\r\n" +
+                "a=fmtp:111 minptime=10;useinbandfec=1\r\n" +
+                "a=rtpmap:103 ISAC/16000\r\n" +
+                "a=rtpmap:9 G722/8000\r\n" +
+                "a=rtpmap:102 ILBC/8000\r\n" +
+                "a=rtpmap:0 PCMU/8000\r\n" +
+                "a=rtpmap:8 PCMA/8000\r\n" +
+                "a=rtpmap:105 CN/16000\r\n" +
+                "a=rtpmap:13 CN/8000\r\n" +
+                "a=rtpmap:110 telephone-event/48000\r\n" +
+                "a=rtpmap:113 telephone-event/16000\r\n" +
+                "a=rtpmap:126 telephone-event/8000\r\n" +
+                "a=ssrc:2080079676 cname:Jb5aR24iJnFDp6OS\r\n" +
+                "a=ssrc:2080079676 msid:3MACALL 3MACALLa0\r\n" +
+                "a=ssrc:2080079676 mslabel:3MACALL\r\n" +
+                "a=ssrc:2080079676 label:3MACALLa0\r\n"
+            );
+        }
+    }
+
+    private void validateDescription(@NonNull SessionDescription sdp, boolean videoEnabled, boolean isOffer) {
+        final List<String> actualLines = Arrays.asList(sdp.description.split("\r\n"));
+        Log.d(TAG, "SDP:\n" + sdp.description);
+        final List<String> matches = new ArrayList<>();
+        int lineOffset = 0;
+
+        // Session lines
+        matches.add("^v=0$");
+        matches.add("^o=- \\d+ \\d IN IP4 127.0.0.1$");
+        matches.add("^s=-$");
+        matches.add("^t=0 0$");
+        matches.add("^a=group:BUNDLE( \\d+)+");
+        if (videoEnabled) {
+            matches.add("^a=extmap-allow-mixed$");
+        }
+        matches.add("^a=msid-semantic: WMS 3MACALL$");
+        lineOffset += matchEachLine(matches, actualLines, lineOffset);
+
+        // Audio lines
+        matches.add("^m=audio 9 UDP/TLS/RTP/SAVPF \\d+$");
+        matches.add("^c=IN IP4 0.0.0.0$");
+        matches.add("^a=rtcp:9 IN IP4 0.0.0.0$");
+        matches.add("^a=ice-ufrag:[^ ]+$");
+        matches.add("^a=ice-pwd:[^ ]+$");
+        matches.add("^a=ice-options:trickle renomination$");
+        matches.add("^a=fingerprint:sha-256 [^ ]+$");
+        matches.add("^a=setup:(actpass|active)");
+        matches.add("^a=mid:0");
+        if (videoEnabled) {
+            if (isOffer) {
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
+            } else {
+                matches.add("^a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
+                matches.add("^a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
+                matches.add("^a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
+            }
+        }
+        matches.add("^a=sendrecv$");
+        matches.add("^a=msid:3MACALL 3MACALLa0");
+        matches.add("^a=rtcp-mux$");
+        matches.add("^a=rtpmap:\\d+ opus/48000/2$");
+        matches.add("^a=rtcp-fb:\\d+ transport-cc$");
+        matches.add("^a=fmtp:\\d+ minptime=10;useinbandfec=1;stereo=0;sprop-stereo=0;cbr=1$");
+        matches.add("^a=ssrc:\\d+ cname:[^ ]+$");
+        if (isOffer) {
+            matches.add("^a=ssrc:\\d+ msid:3MACALL 3MACALLa0$");
+        }
+        lineOffset += matchEachLine(matches, actualLines, lineOffset);
+
+        // Video lines
+        if (videoEnabled) {
+            matches.add("^m=video 9 UDP/TLS/RTP/SAVPF( \\d+)+$");
+            matches.add("^c=IN IP4 0.0.0.0$");
+            matches.add("^a=rtcp:9 IN IP4 0.0.0.0$");
+            matches.add("^a=ice-ufrag:[^ ]+$");
+            matches.add("^a=ice-pwd:[^ ]+$");
+            matches.add("^a=ice-options:trickle renomination$");
+            matches.add("^a=fingerprint:sha-256 [^ ]+$");
+            matches.add("^a=setup:(actpass|active)");
+            matches.add("^a=mid:1$");
+            if (isOffer) {
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id$");
+                matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id$");
+            } else {
+                matches.add("^a=extmap:20 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset$");
+                matches.add("^a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
+                matches.add("^a=extmap:21 urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation$");
+                matches.add("^a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
+                matches.add("^a=extmap:22 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay$");
+                matches.add("^a=extmap:23 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type$");
+                matches.add("^a=extmap:24 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing$");
+                matches.add("^a=extmap:26 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space$");
+                matches.add("^a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
+                matches.add("^a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id$");
+                matches.add("^a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id$");
+            }
+            // TODO(SE-63): Ehh, dirty hack... it should create a transceiver instead
+            matches.add("^a=recvonly");
+//          expectedMatchesPart1.add("^a=sendrecv");
+//          expectedMatchesPart1.add("^a=msid:3MACALL 3MACALLv0");
+            matches.add("^a=rtcp-mux$");
+            matches.add("^a=rtcp-rsize$");
+
+            matches.add("^a=rtpmap:\\d+ VP8/90000$");
+            matches.add("^a=rtcp-fb:\\d+ goog-remb$");
+            matches.add("^a=rtcp-fb:\\d+ transport-cc$");
+            matches.add("^a=rtcp-fb:\\d+ ccm fir$");
+            matches.add("^a=rtcp-fb:\\d+ nack$");
+            matches.add("^a=rtcp-fb:\\d+ nack pli$");
+            matches.add("^a=rtpmap:\\d+ rtx/90000$");
+            matches.add("^a=fmtp:\\d+ apt=\\d+$");
+
+            // Since M110 we will generate a bunch of different VP9 profiles.
+            // For now, we're lenient and just accept these even though it likely makes no sense
+            // for our use case.
+            lineOffset += matchEachLine(matches, actualLines, lineOffset);
+            for (int i = 0; i < 4; ++i) {
+                String line = actualLines.get(lineOffset);
+                if (line == null || !line.matches("^a=rtpmap:\\d+ VP9/90000$")) {
+                    assertTrue("At least one VP9 codec profile is expected", i > 0);
+                    break;
+                }
+
+                matches.add("^a=rtpmap:\\d+ VP9/90000$");
+                matches.add("^a=rtcp-fb:\\d+ goog-remb$");
+                matches.add("^a=rtcp-fb:\\d+ transport-cc$");
+                matches.add("^a=rtcp-fb:\\d+ ccm fir$");
+                matches.add("^a=rtcp-fb:\\d+ nack$");
+                matches.add("^a=rtcp-fb:\\d+ nack pli$");
+                matches.add("^a=fmtp:\\d+ profile-id=\\d$");
+                matches.add("^a=rtpmap:\\d+ rtx/90000$");
+                matches.add("^a=fmtp:\\d+ apt=\\d+$");
+                lineOffset += matchEachLine(matches, actualLines, lineOffset);
+            }
+
+            // Other video codec lines (dynamically detected HW codec support, e.g. H264)
+            lineOffset += matchEachLine(matches, actualLines, lineOffset);
+            for (; ; ) {
+                String line = actualLines.get(lineOffset);
+                if (line == null || line.matches("^a=rtpmap:\\d+ red/90000")) {
+                    break;
+                }
+                lineOffset++;
+            }
+
+            matches.add("^a=rtpmap:\\d+ red/90000");
+            matches.add("^a=rtpmap:\\d+ rtx/90000");
+            matches.add("^a=fmtp:\\d+ apt=\\d+$");
+
+            matches.add("^a=rtpmap:\\d+ ulpfec/90000");
+
+            matches.add("^a=rtpmap:\\d+ flexfec-03/90000");
+            matches.add("^a=rtcp-fb:\\d+ goog-remb$");
+            matches.add("^a=rtcp-fb:\\d+ transport-cc$");
+            matches.add("^a=fmtp:\\d+ repair-window=\\d+$");
+
+            lineOffset += matchEachLine(matches, actualLines, lineOffset);
+        }
+
+        if (isOffer || videoEnabled) {
+            // Data channel lines
+            matches.add("^m=application 9 UDP/DTLS/SCTP webrtc-datachannel$");
+            matches.add("^c=IN IP4 0.0.0.0$");
+            matches.add("^a=ice-ufrag:[^ ]+$");
+            matches.add("^a=ice-pwd:[^ ]+$");
+            matches.add("^a=ice-options:trickle renomination$");
+            matches.add("^a=fingerprint:sha-256 [^ ]+$");
+            matches.add("^a=setup:(actpass|active)$");
+            matches.add("^a=mid:[^ ]+$");
+            matches.add("^a=sctp-port:5000$");
+            matches.add("^a=max-message-size:262144$");
+            lineOffset += matchEachLine(matches, actualLines, lineOffset);
+        }
+
+        // Lines must be equal
+        assertEquals(lineOffset, actualLines.size());
+    }
+
+    /**
+     * Helper for validateDescription
+     */
+    private int matchEachLine(List<String> expectedMatches, List<String> actualLines, int offset) {
+        int expectedLength = expectedMatches.size();
+        for (int i = 0; i < expectedLength; ++i) {
+            final String expected = expectedMatches.get(i);
+            final String actual = i < actualLines.size() ? actualLines.get(i + offset) : null;
+            Log.d(TAG, "Validating \"" + actual + "\" against \"" + expected + "\"");
+            assertNotNull(actual);
+            assertTrue("Line \"" + actual + "\" did not match \"" + expected + "\"", actual.matches(expected));
+        }
+        expectedMatches.clear();
+        return expectedLength;
+    }
+
+    public void testOffer(boolean videoEnabled) throws InterruptedException, ExecutionException {
+        final PeerConnectionClient pc = new PeerConnectionClient(
+            ApplicationProvider.getApplicationContext(),
+            this.getParameters(videoEnabled),
+            null,
+            CALL_ID
+        );
+        pc.setEnableIceServers(false);
+
+        final PeerConnectionClientEvents events = new PeerConnectionClientEvents() {
+            @Override
+            public void onLocalDescription(long callId, SessionDescription sdp) {
+                super.onLocalDescription(callId, sdp);
+                pc.close();
+            }
+        };
+        pc.setEventHandler(events);
+
+        // Create peer connection & offer
+        final boolean factoryCreateSuccess = pc.createPeerConnectionFactory().get();
+        assertTrue(factoryCreateSuccess);
+        pc.createPeerConnection();
+        pc.createOffer();
+
+        // Wait until local description (offer) available
+        assertTrue(events.done.tryAcquire(10, TimeUnit.SECONDS));
+
+        // Compare SDP
+        assertNotNull(events.localSdp);
+        assertEquals(events.localSdp.type, SessionDescription.Type.OFFER);
+        this.validateDescription(events.localSdp, videoEnabled, true);
+    }
+
+    @Test
+    public void testOfferAudioOnly() throws InterruptedException, ExecutionException {
+        this.testOffer(false);
+    }
+
+    @Test
+    public void testOfferVideo() throws InterruptedException, ExecutionException {
+        this.testOffer(true);
+    }
+
+
+    private void testAnswer(boolean videoEnabled) throws InterruptedException, ExecutionException {
+        final PeerConnectionClient pc = new PeerConnectionClient(
+            ApplicationProvider.getApplicationContext(),
+            this.getParameters(videoEnabled),
+            null,
+            1
+        );
+        pc.setEnableIceServers(false);
+
+        final PeerConnectionClientEvents events = new PeerConnectionClientEvents() {
+            @Override
+            public void onLocalDescription(long callId, SessionDescription sdp) {
+                super.onLocalDescription(callId, sdp);
+                pc.close();
+            }
+
+            @Override
+            public void onRemoteDescriptionSet(long callId) {
+                pc.createAnswer();
+            }
+        };
+        pc.setEventHandler(events);
+
+        // Create factory
+        final boolean factoryCreateSuccess = pc.createPeerConnectionFactory().get();
+        assertTrue(factoryCreateSuccess);
+
+        // Create fake offer
+        final SessionDescription fakeOffer = this.generateFakeOffer(videoEnabled);
+
+        // Create peer connection & set fake offer
+        pc.createPeerConnection();
+        pc.setRemoteDescription(fakeOffer);
+
+        // Wait until local description (answer) available
+        assertTrue(events.done.tryAcquire(10, TimeUnit.SECONDS));
+
+        // Compare SDP
+        assertNotNull(events.localSdp);
+        assertEquals(events.localSdp.type, SessionDescription.Type.ANSWER);
+        this.validateDescription(events.localSdp, videoEnabled, false);
+    }
+
+    @Test
+    public void testAnswerAudioOnly() throws InterruptedException, ExecutionException {
+        this.testAnswer(false);
+    }
+
+    @Test
+    public void testAnswerVideo() throws InterruptedException, ExecutionException {
+        this.testAnswer(true);
+    }
 }

+ 194 - 194
app/src/androidTest/java/ch/threema/app/voip/VoipStatusMessageTest.java

@@ -46,198 +46,198 @@ import static org.junit.Assert.assertEquals;
  */
 @RunWith(AndroidJUnit4.class)
 public class VoipStatusMessageTest {
-	private static int ICON_OUTGOING = R.drawable.ic_call_missed_outgoing_black_24dp;
-	private static int ICON_INCOMING = R.drawable.ic_call_missed_black_24dp;
-	private static int COLOR_RED = R.color.material_red;
-	private static int COLOR_ORANGE = R.color.material_orange;
-
-	/**
-	 * Return a context where the locale has been overriden to "en".
-	 */
-	private Context getContext() {
-		final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
-		final Configuration config = new Configuration();
-		config.setLocale(new Locale("en"));
-		return context.createConfigurationContext(config);
-	}
-
-	class TestCase {
-		private Context context;
-		private AbstractMessageModel messageModel;
-		private int expectedIcon;
-		private int expectedColor;
-		private String expectedPlaceholder;
-		private String expectedText;
-
-		public TestCase(
-			Context context,
-			boolean outgoing,
-			VoipStatusDataModel dataModel,
-			int expectedIcon,
-			int expectedColor,
-			String expectedPlaceholder,
-			String expectedText
-		) {
-			this.context = context;
-			final MessageModel messageModel = new MessageModel(true);
-			messageModel.setOutbox(outgoing);
-			messageModel.setVoipStatusData(dataModel);
-			this.messageModel = messageModel;
-			this.expectedIcon = expectedIcon;
-			this.expectedColor = expectedColor;
-			this.expectedPlaceholder = expectedPlaceholder;
-			this.expectedText = expectedText;
-		}
-
-		public void test() {
-			final MessageUtil.MessageViewElement element = MessageUtil.getViewElement(this.context, this.messageModel);
-			assertEquals((Integer) this.expectedIcon, element.icon);
-			assertEquals((Integer) this.expectedColor, element.color);
-			assertEquals(this.expectedPlaceholder, element.placeholder);
-			assertEquals(this.expectedText, element.text);
-		}
-	}
-
-	@Test
-	public void testIncomingMissed() {
-		new TestCase(
-			this.getContext(),
-			false,
-			VoipStatusDataModel.createMissed(NO_CALL_ID, null),
-			ICON_INCOMING,
-			COLOR_RED,
-			"Missed call",
-			"Missed call"
-		).test();
-	}
-
-	@Test
-	public void testIncomingRejectedUnknown() {
-		new TestCase(
-			this.getContext(),
-			false,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.UNKNOWN),
-			ICON_INCOMING,
-			COLOR_RED,
-			"Missed call",
-			"Missed call"
-		).test();
-	}
-
-	@Test
-	public void testIncomingRejectedBusy() {
-		new TestCase(
-			this.getContext(),
-			false,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.BUSY),
-			ICON_INCOMING,
-			COLOR_RED,
-			"Missed call (Busy)",
-			"Missed call (Busy)"
-		).test();
-	}
-
-	@Test
-	public void testIncomingRejectedTimeout() {
-		new TestCase(
-			this.getContext(),
-			false,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.TIMEOUT),
-			ICON_INCOMING,
-			COLOR_RED,
-			"Missed call",
-			"Missed call"
-		).test();
-	}
-
-	@Test
-	public void testIncomingRejectedRejected() {
-		new TestCase(
-			this.getContext(),
-			false,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.REJECTED),
-			ICON_INCOMING,
-			COLOR_ORANGE,
-			"Call declined",
-			"Call declined"
-		).test();
-	}
-
-	@Test
-	public void testIncomingRejectedDisabled() {
-		new TestCase(
-			this.getContext(),
-			false,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.DISABLED),
-			ICON_INCOMING,
-			COLOR_ORANGE,
-			"Call declined",
-			"Call declined"
-		).test();
-	}
-
-	@Test
-	public void testOutgoingRejectedUnknown() {
-		new TestCase(
-			this.getContext(),
-			true,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.UNKNOWN),
-			ICON_OUTGOING,
-			COLOR_RED,
-			"Call declined",
-			"Call declined"
-		).test();
-	}
-
-	@Test
-	public void testOutgoingRejectedBusy() {
-		new TestCase(
-			this.getContext(),
-			true,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.BUSY),
-			ICON_OUTGOING,
-			COLOR_RED,
-			"Call recipient is busy",
-			"Call recipient is busy"
-		).test();
-	}
-
-	@Test
-	public void testOutgoingRejectedTimeout() {
-		new TestCase(
-			this.getContext(),
-			true,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.TIMEOUT),
-			ICON_OUTGOING,
-			COLOR_RED,
-			"Call recipient is unavailable",
-			"Call recipient is unavailable"
-		).test();
-	}
-
-	@Test
-	public void testOutgoingRejectedRejected() {
-		new TestCase(
-			this.getContext(),
-			true,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.REJECTED),
-			ICON_OUTGOING,
-			COLOR_RED,
-			"Call declined",
-			"Call declined"
-		).test();
-	}
-
-	@Test
-	public void testOutgoingRejectedDisabled() {
-		new TestCase(
-			this.getContext(),
-			true,
-			VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.DISABLED),
-			ICON_OUTGOING,
-			COLOR_RED,
-			"Threema calls disabled by recipient",
-			"Threema calls disabled by recipient"
-		).test();
-	}
+    private static int ICON_OUTGOING = R.drawable.ic_call_missed_outgoing_black_24dp;
+    private static int ICON_INCOMING = R.drawable.ic_call_missed_black_24dp;
+    private static int COLOR_RED = R.color.material_red;
+    private static int COLOR_ORANGE = R.color.material_orange;
+
+    /**
+     * Return a context where the locale has been overriden to "en".
+     */
+    private Context getContext() {
+        final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+        final Configuration config = new Configuration();
+        config.setLocale(new Locale("en"));
+        return context.createConfigurationContext(config);
+    }
+
+    class TestCase {
+        private Context context;
+        private AbstractMessageModel messageModel;
+        private int expectedIcon;
+        private int expectedColor;
+        private String expectedPlaceholder;
+        private String expectedText;
+
+        public TestCase(
+            Context context,
+            boolean outgoing,
+            VoipStatusDataModel dataModel,
+            int expectedIcon,
+            int expectedColor,
+            String expectedPlaceholder,
+            String expectedText
+        ) {
+            this.context = context;
+            final MessageModel messageModel = new MessageModel(true);
+            messageModel.setOutbox(outgoing);
+            messageModel.setVoipStatusData(dataModel);
+            this.messageModel = messageModel;
+            this.expectedIcon = expectedIcon;
+            this.expectedColor = expectedColor;
+            this.expectedPlaceholder = expectedPlaceholder;
+            this.expectedText = expectedText;
+        }
+
+        public void test() {
+            final MessageUtil.MessageViewElement element = MessageUtil.getViewElement(this.context, this.messageModel);
+            assertEquals((Integer) this.expectedIcon, element.icon);
+            assertEquals((Integer) this.expectedColor, element.color);
+            assertEquals(this.expectedPlaceholder, element.placeholder);
+            assertEquals(this.expectedText, element.text);
+        }
+    }
+
+    @Test
+    public void testIncomingMissed() {
+        new TestCase(
+            this.getContext(),
+            false,
+            VoipStatusDataModel.createMissed(NO_CALL_ID, null),
+            ICON_INCOMING,
+            COLOR_RED,
+            "Missed call",
+            "Missed call"
+        ).test();
+    }
+
+    @Test
+    public void testIncomingRejectedUnknown() {
+        new TestCase(
+            this.getContext(),
+            false,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.UNKNOWN),
+            ICON_INCOMING,
+            COLOR_RED,
+            "Missed call",
+            "Missed call"
+        ).test();
+    }
+
+    @Test
+    public void testIncomingRejectedBusy() {
+        new TestCase(
+            this.getContext(),
+            false,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.BUSY),
+            ICON_INCOMING,
+            COLOR_RED,
+            "Missed call (Busy)",
+            "Missed call (Busy)"
+        ).test();
+    }
+
+    @Test
+    public void testIncomingRejectedTimeout() {
+        new TestCase(
+            this.getContext(),
+            false,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.TIMEOUT),
+            ICON_INCOMING,
+            COLOR_RED,
+            "Missed call",
+            "Missed call"
+        ).test();
+    }
+
+    @Test
+    public void testIncomingRejectedRejected() {
+        new TestCase(
+            this.getContext(),
+            false,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.REJECTED),
+            ICON_INCOMING,
+            COLOR_ORANGE,
+            "Call declined",
+            "Call declined"
+        ).test();
+    }
+
+    @Test
+    public void testIncomingRejectedDisabled() {
+        new TestCase(
+            this.getContext(),
+            false,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.DISABLED),
+            ICON_INCOMING,
+            COLOR_ORANGE,
+            "Call declined",
+            "Call declined"
+        ).test();
+    }
+
+    @Test
+    public void testOutgoingRejectedUnknown() {
+        new TestCase(
+            this.getContext(),
+            true,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.UNKNOWN),
+            ICON_OUTGOING,
+            COLOR_RED,
+            "Call declined",
+            "Call declined"
+        ).test();
+    }
+
+    @Test
+    public void testOutgoingRejectedBusy() {
+        new TestCase(
+            this.getContext(),
+            true,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.BUSY),
+            ICON_OUTGOING,
+            COLOR_RED,
+            "Call recipient is busy",
+            "Call recipient is busy"
+        ).test();
+    }
+
+    @Test
+    public void testOutgoingRejectedTimeout() {
+        new TestCase(
+            this.getContext(),
+            true,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.TIMEOUT),
+            ICON_OUTGOING,
+            COLOR_RED,
+            "Call recipient is unavailable",
+            "Call recipient is unavailable"
+        ).test();
+    }
+
+    @Test
+    public void testOutgoingRejectedRejected() {
+        new TestCase(
+            this.getContext(),
+            true,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.REJECTED),
+            ICON_OUTGOING,
+            COLOR_RED,
+            "Call declined",
+            "Call declined"
+        ).test();
+    }
+
+    @Test
+    public void testOutgoingRejectedDisabled() {
+        new TestCase(
+            this.getContext(),
+            true,
+            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.DISABLED),
+            ICON_OUTGOING,
+            COLOR_RED,
+            "Threema calls disabled by recipient",
+            "Threema calls disabled by recipient"
+        ).test();
+    }
 }

+ 190 - 190
app/src/androidTest/java/ch/threema/app/webclient/activities/SessionsActivityTest.java

@@ -61,202 +61,202 @@ import static ch.threema.app.services.BrowserDetectionService.Browser;
 
 /**
  * Sessions activity UI tests.
- *
+ * <p>
  * Prerequisites:
- *
+ * <p>
  * - Device with English locale and animations turned off (developer settings)
  */
 @RunWith(AndroidJUnit4.class)
 @LargeTest
 @DangerousTest // Modifies webclient sessions
 public class SessionsActivityTest {
-	@Rule
-	public ActivityTestRule<SessionsActivity> activityTestRule
-			= new ActivityTestRule<>(SessionsActivity.class, false, false);
-
-	@Rule
-	public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain();
-
-	/**
-	 * Mark the welcome screen as already shown.
-	 */
-	private static void showWelcomeScreen(Context context, boolean show) {
-		final SharedPreferences sharedPreferences =
-				PreferenceManager.getDefaultSharedPreferences(context);
-		final String welcome_shown = context.getString(R.string.preferences__web_client_welcome_shown);
-		sharedPreferences.edit().putBoolean(welcome_shown, !show).commit();
-	}
-
-	/**
-	 * Enable or disable webclient.
-	 */
-	private static void enableWebclient(Context context, boolean enable) {
-		final SharedPreferences sharedPreferences =
-			PreferenceManager.getDefaultSharedPreferences(context);
-		final String enabled = context.getString(R.string.preferences__web_client_enabled);
-		sharedPreferences.edit().putBoolean(enabled, enable).commit();
-	}
-
-	/**
-	 * Clear all sessions.
-	 */
-	private static void clearSessions() {
-		final DatabaseServiceNew databaseService = ThreemaApplication
-			.getServiceManager()
-			.getDatabaseServiceNew();
-		databaseService.getWebClientSessionModelFactory().deleteAll();
-	}
-
-	/**
-	 * Create a new database session.
-	 */
-	private static void createSession(
-			String label,
-	        WebClientSessionModel.State state,
-	        boolean persistent,
-	        Date created,
-	        Date lastConnection,
-	        Browser browser
-	) {
-		final DatabaseServiceNew databaseService = ThreemaApplication
-				.getServiceManager()
-				.getDatabaseServiceNew();
-
-		final WebClientSessionModel model = new WebClientSessionModel();
-
-		model.setLabel(label);
-		model.setState(state);
-		model.setPersistent(persistent);
-		model.setCreated(created);
-		model.setLastConnection(lastConnection);
-		switch (browser) {
-			case FIREFOX:
-				model.setClientDescription("Mozilla/5.0 (X11; Linux i686; rv:10.0) Gecko/20100101 Firefox/10.0");
-				break;
-			case CHROME:
-				model.setClientDescription("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36");
-				break;
-			case SAFARI:
-				model.setClientDescription("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Safari/604.1.38");
-				break;
-			case EDGE:
-				model.setClientDescription("edge");
-				break;
-			case OPERA:
-				model.setClientDescription("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36 OPR/54.0.2952.46\n");
-				break;
-		}
-		model.setSaltyRtcHost("saltyrtc.threema.example");
-		model.setSaltyRtcPort(8080);
-
-		databaseService.getWebClientSessionModelFactory().createOrUpdate(model);
-	}
-
-	@Before
-	public void setUp() {
-		final Context context = InstrumentationRegistry.getTargetContext();
-
-		// By default, don't show welcome screen
-		showWelcomeScreen(context, false);
-
-		// Clear all sessions
-		clearSessions();
-
-		// Disable webclient
-		enableWebclient(context, false);
-	}
-
-	/**
-	 * Ensure that the welcome screen is shown
-	 * the first time the activity is started.
-	 */
-	@Test
-	public void testWelcomeScreen() {
-		showWelcomeScreen(InstrumentationRegistry.getTargetContext(), true);
-
-		final Instrumentation.ActivityMonitor monitor = getInstrumentation()
-				.addMonitor(SessionsIntroActivity.class.getName(), null, false);
-
-		activityTestRule.launchActivity(null);
-
-		final Activity activity = getInstrumentation()
-				.waitForMonitorWithTimeout(monitor, 500);
-		Assert.assertNotNull(activity);
-	}
-
-	/**
-	 * Test the session list. Show two sessions, and delete one of them.
-	 */
-	@Test
-	public void testSessionList() {
-		// Create two sessions
-		createSession("Feuerfuchs", WebClientSessionModel.State.AUTHORIZED,
-				true, new Date(), new Date(), Browser.FIREFOX);
-		createSession("Googlebrowser", WebClientSessionModel.State.ERROR,
-				true,new Date(System.currentTimeMillis() - 3600),
-				new Date(System.currentTimeMillis() - 3500), Browser.CHROME);
-
-		// Start activty
-		activityTestRule.launchActivity(null);
-
-		// Assert that the two sessions are listed
-		onView(withText("Feuerfuchs"))
-				.check(matches(isDisplayed()));
-		onView(withText("Googlebrowser"))
-				.check(matches(isDisplayed()));
-
-		// Delete Chrome session
-		onView(withText("Googlebrowser"))
-				.perform(click());
-		onView(withText(R.string.webclient_session_remove))
-				.perform(click());
-		onView(withText(R.string.ok))
-				.inRoot(isDialog())
-				.perform(click());
-
-		// Assert that the session is gone
-		onView(withText("Googlebrowser"))
-				.check(doesNotExist());
-	}
-
-	/**
-	 * Test the cleaning of non persistent sessions on start.
-	 */
-	@Test
-	public void testCleanOnStart() throws Exception {
-		final long hours = 3600000;
-
-		final Date now = new Date();
-		final Date hours23ago = new Date(System.currentTimeMillis() - hours * 23);
-		final Date hours25ago = new Date(System.currentTimeMillis() - hours * 25);
-
-		createSession("Persistent now", WebClientSessionModel.State.AUTHORIZED,
-				true, now, now, Browser.FIREFOX);
-		createSession("Persistent old", WebClientSessionModel.State.AUTHORIZED,
-				true, hours25ago, hours25ago, Browser.CHROME);
-		createSession("Disposable now", WebClientSessionModel.State.AUTHORIZED,
-				false, now, now, Browser.SAFARI);
-		createSession("Disposable fresh", WebClientSessionModel.State.AUTHORIZED,
-			false, now, null, Browser.SAFARI);
-		createSession("Disposable still valid", WebClientSessionModel.State.AUTHORIZED,
-				false, hours23ago, hours23ago, Browser.OPERA);
-		createSession("Disposable expired", WebClientSessionModel.State.AUTHORIZED,
-				false, hours25ago, hours25ago, Browser.EDGE);
-
-		activityTestRule.launchActivity(null);
-
-		onView(withText("Persistent now"))
-				.check(matches(isDisplayed()));
-		onView(withText("Persistent old"))
-				.check(matches(isDisplayed()));
-		onView(withText("Disposable now"))
-				.check(matches(isDisplayed()));
-		onView(withText("Disposable fresh"))
-			.check(matches(isDisplayed()));
-		onView(withText("Disposable still valid"))
-				.check(matches(isDisplayed()));
-		onView(withText("Disposable expired"))
-				.check(doesNotExist());
-	}
+    @Rule
+    public ActivityTestRule<SessionsActivity> activityTestRule
+        = new ActivityTestRule<>(SessionsActivity.class, false, false);
+
+    @Rule
+    public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain();
+
+    /**
+     * Mark the welcome screen as already shown.
+     */
+    private static void showWelcomeScreen(Context context, boolean show) {
+        final SharedPreferences sharedPreferences =
+            PreferenceManager.getDefaultSharedPreferences(context);
+        final String welcome_shown = context.getString(R.string.preferences__web_client_welcome_shown);
+        sharedPreferences.edit().putBoolean(welcome_shown, !show).commit();
+    }
+
+    /**
+     * Enable or disable webclient.
+     */
+    private static void enableWebclient(Context context, boolean enable) {
+        final SharedPreferences sharedPreferences =
+            PreferenceManager.getDefaultSharedPreferences(context);
+        final String enabled = context.getString(R.string.preferences__web_client_enabled);
+        sharedPreferences.edit().putBoolean(enabled, enable).commit();
+    }
+
+    /**
+     * Clear all sessions.
+     */
+    private static void clearSessions() {
+        final DatabaseServiceNew databaseService = ThreemaApplication
+            .getServiceManager()
+            .getDatabaseServiceNew();
+        databaseService.getWebClientSessionModelFactory().deleteAll();
+    }
+
+    /**
+     * Create a new database session.
+     */
+    private static void createSession(
+        String label,
+        WebClientSessionModel.State state,
+        boolean persistent,
+        Date created,
+        Date lastConnection,
+        Browser browser
+    ) {
+        final DatabaseServiceNew databaseService = ThreemaApplication
+            .getServiceManager()
+            .getDatabaseServiceNew();
+
+        final WebClientSessionModel model = new WebClientSessionModel();
+
+        model.setLabel(label);
+        model.setState(state);
+        model.setPersistent(persistent);
+        model.setCreated(created);
+        model.setLastConnection(lastConnection);
+        switch (browser) {
+            case FIREFOX:
+                model.setClientDescription("Mozilla/5.0 (X11; Linux i686; rv:10.0) Gecko/20100101 Firefox/10.0");
+                break;
+            case CHROME:
+                model.setClientDescription("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36");
+                break;
+            case SAFARI:
+                model.setClientDescription("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Safari/604.1.38");
+                break;
+            case EDGE:
+                model.setClientDescription("edge");
+                break;
+            case OPERA:
+                model.setClientDescription("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36 OPR/54.0.2952.46\n");
+                break;
+        }
+        model.setSaltyRtcHost("saltyrtc.threema.example");
+        model.setSaltyRtcPort(8080);
+
+        databaseService.getWebClientSessionModelFactory().createOrUpdate(model);
+    }
+
+    @Before
+    public void setUp() {
+        final Context context = InstrumentationRegistry.getTargetContext();
+
+        // By default, don't show welcome screen
+        showWelcomeScreen(context, false);
+
+        // Clear all sessions
+        clearSessions();
+
+        // Disable webclient
+        enableWebclient(context, false);
+    }
+
+    /**
+     * Ensure that the welcome screen is shown
+     * the first time the activity is started.
+     */
+    @Test
+    public void testWelcomeScreen() {
+        showWelcomeScreen(InstrumentationRegistry.getTargetContext(), true);
+
+        final Instrumentation.ActivityMonitor monitor = getInstrumentation()
+            .addMonitor(SessionsIntroActivity.class.getName(), null, false);
+
+        activityTestRule.launchActivity(null);
+
+        final Activity activity = getInstrumentation()
+            .waitForMonitorWithTimeout(monitor, 500);
+        Assert.assertNotNull(activity);
+    }
+
+    /**
+     * Test the session list. Show two sessions, and delete one of them.
+     */
+    @Test
+    public void testSessionList() {
+        // Create two sessions
+        createSession("Feuerfuchs", WebClientSessionModel.State.AUTHORIZED,
+            true, new Date(), new Date(), Browser.FIREFOX);
+        createSession("Googlebrowser", WebClientSessionModel.State.ERROR,
+            true, new Date(System.currentTimeMillis() - 3600),
+            new Date(System.currentTimeMillis() - 3500), Browser.CHROME);
+
+        // Start activty
+        activityTestRule.launchActivity(null);
+
+        // Assert that the two sessions are listed
+        onView(withText("Feuerfuchs"))
+            .check(matches(isDisplayed()));
+        onView(withText("Googlebrowser"))
+            .check(matches(isDisplayed()));
+
+        // Delete Chrome session
+        onView(withText("Googlebrowser"))
+            .perform(click());
+        onView(withText(R.string.webclient_session_remove))
+            .perform(click());
+        onView(withText(R.string.ok))
+            .inRoot(isDialog())
+            .perform(click());
+
+        // Assert that the session is gone
+        onView(withText("Googlebrowser"))
+            .check(doesNotExist());
+    }
+
+    /**
+     * Test the cleaning of non persistent sessions on start.
+     */
+    @Test
+    public void testCleanOnStart() throws Exception {
+        final long hours = 3600000;
+
+        final Date now = new Date();
+        final Date hours23ago = new Date(System.currentTimeMillis() - hours * 23);
+        final Date hours25ago = new Date(System.currentTimeMillis() - hours * 25);
+
+        createSession("Persistent now", WebClientSessionModel.State.AUTHORIZED,
+            true, now, now, Browser.FIREFOX);
+        createSession("Persistent old", WebClientSessionModel.State.AUTHORIZED,
+            true, hours25ago, hours25ago, Browser.CHROME);
+        createSession("Disposable now", WebClientSessionModel.State.AUTHORIZED,
+            false, now, now, Browser.SAFARI);
+        createSession("Disposable fresh", WebClientSessionModel.State.AUTHORIZED,
+            false, now, null, Browser.SAFARI);
+        createSession("Disposable still valid", WebClientSessionModel.State.AUTHORIZED,
+            false, hours23ago, hours23ago, Browser.OPERA);
+        createSession("Disposable expired", WebClientSessionModel.State.AUTHORIZED,
+            false, hours25ago, hours25ago, Browser.EDGE);
+
+        activityTestRule.launchActivity(null);
+
+        onView(withText("Persistent now"))
+            .check(matches(isDisplayed()));
+        onView(withText("Persistent old"))
+            .check(matches(isDisplayed()));
+        onView(withText("Disposable now"))
+            .check(matches(isDisplayed()));
+        onView(withText("Disposable fresh"))
+            .check(matches(isDisplayed()));
+        onView(withText("Disposable still valid"))
+            .check(matches(isDisplayed()));
+        onView(withText("Disposable expired"))
+            .check(doesNotExist());
+    }
 
 }

+ 54 - 54
app/src/androidTest/java/ch/threema/app/webclient/converter/MessageTest.java

@@ -47,64 +47,64 @@ import static org.junit.Assert.assertEquals;
 @SmallTest
 @RunWith(AndroidJUnit4.class)
 public class MessageTest {
-	@Test
-	public void testFixFileName() {
-		// Fix file extension if missing
-		assertEquals("file.jpg", Message.fixFileName("file", "image/jpeg"));
-		assertEquals("file.png", Message.fixFileName("file", "image/png"));
-		assertEquals("file.txt", Message.fixFileName("file", "text/plain"));
+    @Test
+    public void testFixFileName() {
+        // Fix file extension if missing
+        assertEquals("file.jpg", Message.fixFileName("file", "image/jpeg"));
+        assertEquals("file.png", Message.fixFileName("file", "image/png"));
+        assertEquals("file.txt", Message.fixFileName("file", "text/plain"));
 
-		// Ignore files containing a dot
-		assertEquals("file.something", Message.fixFileName("file.something", "text/plain"));
+        // Ignore files containing a dot
+        assertEquals("file.something", Message.fixFileName("file.something", "text/plain"));
 
-		// Don't change existing extensions
-		assertEquals("file.txt", Message.fixFileName("file.txt", "image/jpeg"));
-	}
+        // Don't change existing extensions
+        assertEquals("file.txt", Message.fixFileName("file.txt", "image/jpeg"));
+    }
 
-	private static String testMaybePutFileImpl(
-		@NonNull String inputMimeType,
-		@Nullable Date createdAt,
-		@Nullable String messageId
-	) throws IOException {
-		// The Threema protocol does not require a file name in a file message,
-		// but ARP does!
-		final MsgpackObjectBuilder builder = new MsgpackObjectBuilder();
-		final FileDataModel fileDataModel = new FileDataModel(
-			inputMimeType,
-			"image/jpeg",
-			100,
-			null,
-			FileData.RENDERING_MEDIA,
-			"A photo without name",
-			true,
-			new HashMap<>()
-		);
-		final AbstractMessageModel messageModel = new MessageModel();
-		messageModel.setFileDataModel(fileDataModel);
-		messageModel.setCreatedAt(createdAt);
-		messageModel.setApiMessageId(messageId);
-		Message.maybePutFile(builder, "file", messageModel, fileDataModel);
+    private static String testMaybePutFileImpl(
+        @NonNull String inputMimeType,
+        @Nullable Date createdAt,
+        @Nullable String messageId
+    ) throws IOException {
+        // The Threema protocol does not require a file name in a file message,
+        // but ARP does!
+        final MsgpackObjectBuilder builder = new MsgpackObjectBuilder();
+        final FileDataModel fileDataModel = new FileDataModel(
+            inputMimeType,
+            "image/jpeg",
+            100,
+            null,
+            FileData.RENDERING_MEDIA,
+            "A photo without name",
+            true,
+            new HashMap<>()
+        );
+        final AbstractMessageModel messageModel = new MessageModel();
+        messageModel.setFileDataModel(fileDataModel);
+        messageModel.setCreatedAt(createdAt);
+        messageModel.setApiMessageId(messageId);
+        Message.maybePutFile(builder, "file", messageModel, fileDataModel);
 
-		// Create Msgpack message
-		final ByteBuffer buf = builder.consume();
+        // Create Msgpack message
+        final ByteBuffer buf = builder.consume();
 
-		// Decode message again
-		try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buf)) {
-			final Map<String, Value> map = new HashMap<>();
-			for (Map.Entry<Value, Value> entry : unpacker.unpackValue().asMapValue().map().entrySet()) {
-				map.put(entry.getKey().asStringValue().toString(), entry.getValue());
-			}
-			final Map<String, Value> fileData = new HashMap<>();
-			for (Map.Entry<Value, Value> entry : map.get("file").asMapValue().map().entrySet()) {
-				fileData.put(entry.getKey().asStringValue().toString(), entry.getValue());
-			}
-			return fileData.get("name").asStringValue().asString();
-		}
-	}
+        // Decode message again
+        try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buf)) {
+            final Map<String, Value> map = new HashMap<>();
+            for (Map.Entry<Value, Value> entry : unpacker.unpackValue().asMapValue().map().entrySet()) {
+                map.put(entry.getKey().asStringValue().toString(), entry.getValue());
+            }
+            final Map<String, Value> fileData = new HashMap<>();
+            for (Map.Entry<Value, Value> entry : map.get("file").asMapValue().map().entrySet()) {
+                fileData.put(entry.getKey().asStringValue().toString(), entry.getValue());
+            }
+            return fileData.get("name").asStringValue().asString();
+        }
+    }
 
-	@Test
-	public void testMaybePutFile() throws IOException {
-		assertEquals("threema-20201212-000000-null.png", testMaybePutFileImpl("image/png", new Date(2020 - 1900, 12 - 1, 12), null));
-		assertEquals("threema-20100130-131400-msgidasdf.txt", testMaybePutFileImpl("text/plain", new Date(2010 - 1900, 1 - 1, 30, 13, 14), "msgidasdf"));
-	}
+    @Test
+    public void testMaybePutFile() throws IOException {
+        assertEquals("threema-20201212-000000-null.png", testMaybePutFileImpl("image/png", new Date(2020 - 1900, 12 - 1, 12), null));
+        assertEquals("threema-20100130-131400-msgidasdf.txt", testMaybePutFileImpl("text/plain", new Date(2010 - 1900, 1 - 1, 30, 13, 14), "msgidasdf"));
+    }
 }

+ 15 - 15
app/src/androidTest/java/ch/threema/app/webclient/converter/MsgpackTest.java

@@ -41,19 +41,19 @@ import androidx.test.runner.AndroidJUnit4;
 @RunWith(AndroidJUnit4.class)
 @SmallTest
 public class MsgpackTest {
-	@Test
-	public void testPutString() throws Exception {
-		String sampleText = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.";
-		Map<String, String> sampleMap =  new HashMap<>();
-		for(int i=0; i<100; i++) {
-			sampleMap.put(String.valueOf(i), sampleText);
-		}
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		MessagePacker packer = MessagePack.newDefaultPacker(out);
-		packer.packMapHeader(sampleMap.size());
-		for (Map.Entry<String, String> entry : sampleMap.entrySet()) {
-			packer.packString(entry.getKey());
-			packer.packString(entry.getValue());
-		}
-	}
+    @Test
+    public void testPutString() throws Exception {
+        String sampleText = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.";
+        Map<String, String> sampleMap = new HashMap<>();
+        for (int i = 0; i < 100; i++) {
+            sampleMap.put(String.valueOf(i), sampleText);
+        }
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        MessagePacker packer = MessagePack.newDefaultPacker(out);
+        packer.packMapHeader(sampleMap.size());
+        for (Map.Entry<String, String> entry : sampleMap.entrySet()) {
+            packer.packString(entry.getKey());
+            packer.packString(entry.getValue());
+        }
+    }
 }

+ 1 - 1
app/src/androidTest/java/ch/threema/data/TestDatabaseService.kt

@@ -28,7 +28,7 @@ import ch.threema.storage.DatabaseServiceNew
 /**
  * An in-memory database used in android tests.
  */
-class TestDatabaseService: DatabaseServiceNew(
+class TestDatabaseService : DatabaseServiceNew(
     ApplicationProvider.getApplicationContext(),
     null,
     "test-database-key",

+ 8 - 2
app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt

@@ -412,10 +412,16 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
     fun updateNickname() {
         // Create contact using "old model"
         val identity = randomIdentity()
-        databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, nonSecureRandomArray(32)))
+        databaseService.contactModelFactory.createOrUpdate(
+            ContactModel(
+                identity,
+                nonSecureRandomArray(32)
+            )
+        )
 
         // Fetch model
-        val model: ch.threema.data.models.ContactModel? = contactModelRepository.getByIdentity(identity)
+        val model: ch.threema.data.models.ContactModel? =
+            contactModelRepository.getByIdentity(identity)
         assertNotNull(model)
         assertEquals(null, model!!.data.value?.nickname)
         model.setNicknameFromSync("testnick")

+ 10 - 4
app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt

@@ -167,8 +167,10 @@ class EmojiReactionsRepositoryTest {
         contactMessage.assertEmojiReactionSize(1)
         groupMessage.assertEmojiReactionSize(1)
 
-        val contactReaction = emojiReactionsRepository.getReactionsByMessage(contactMessage)!!.data.value!![0]
-        val groupReaction = emojiReactionsRepository.getReactionsByMessage(groupMessage)!!.data.value!![0]
+        val contactReaction =
+            emojiReactionsRepository.getReactionsByMessage(contactMessage)!!.data.value!![0]
+        val groupReaction =
+            emojiReactionsRepository.getReactionsByMessage(groupMessage)!!.data.value!![0]
 
         Assert.assertEquals("⚾", contactReaction.emojiSequence)
         Assert.assertEquals("⛵", groupReaction.emojiSequence)
@@ -286,9 +288,13 @@ class EmojiReactionsRepositoryTest {
             coreServiceManager = testCoreServiceManager
         )
 
-        val cachedEntryContact = testEmojiCache.getOrCreate(reactionMessageIdentifierContact) { emojiReactionsModelContact }
+        val cachedEntryContact =
+            testEmojiCache.getOrCreate(reactionMessageIdentifierContact) { emojiReactionsModelContact }
 
-        assertContentEquals(listOf(emojiReactionDataForContactMessage), cachedEntryContact!!.data.value)
+        assertContentEquals(
+            listOf(emojiReactionDataForContactMessage),
+            cachedEntryContact!!.data.value
+        )
         assertNull(testEmojiCache.get(reactionMessageIdentifierGroup))
 
         testEmojiCache.remove(reactionMessageIdentifierGroup)

+ 46 - 46
app/src/androidTest/java/ch/threema/logging/backend/DebugLogFileBackendTest.java

@@ -47,51 +47,51 @@ import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePe
 @DangerousTest // Deletes logfile
 public class DebugLogFileBackendTest {
 
-	@Rule
-	public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
-
-	@Before
-	public void disableLogfile() {
-		DebugLogFileBackend.setEnabled(false);
-	}
-
-	/**
-	 * Make sure that logging into the debug log file actually creates the debug log file.
-	 * Also test that the file is only created when enabled.
-	 */
-	@Test
-	public void testEnable() throws Exception {
-		final File logFilePath = DebugLogFileBackend.getLogFilePath();
-
-		// Log with the debug log file disabled
-		final DebugLogFileBackend backend = new DebugLogFileBackend(Log.INFO);
-		backend.print(Log.WARN, BuildConfig.LOG_TAG, null, "hi");
-
-		// Enabling the debug log file won't create the log file just yet
-		Assert.assertFalse(logFilePath.exists());
-		DebugLogFileBackend.setEnabled(true);
-		Assert.assertFalse(logFilePath.exists());
-
-		// Logs below the min log level are filtered
-		backend.printAsync(Log.DEBUG, BuildConfig.LOG_TAG, null, "hey").get(500, TimeUnit.MILLISECONDS);
-		Assert.assertFalse(logFilePath.exists());
-
-		// Log with the debug log file enabled
-		backend.printAsync(Log.WARN, BuildConfig.LOG_TAG, null, "hi").get(500, TimeUnit.MILLISECONDS);
-		Assert.assertTrue(logFilePath.exists());
-	}
-
-	/**
-	 * Make sure that enabling the debug log file actually creates the debug log file.
-	 */
-	@Test
-	public void testDisableRemovesFile() throws IOException {
-		final File logFilePath = DebugLogFileBackend.getLogFilePath();
-		Assert.assertFalse(logFilePath.exists());
-		Assert.assertTrue("Could not create logfile", logFilePath.createNewFile());
-		Assert.assertTrue(logFilePath.exists());
-		DebugLogFileBackend.setEnabled(false);
-		Assert.assertFalse(logFilePath.exists());
-	}
+    @Rule
+    public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
+
+    @Before
+    public void disableLogfile() {
+        DebugLogFileBackend.setEnabled(false);
+    }
+
+    /**
+     * Make sure that logging into the debug log file actually creates the debug log file.
+     * Also test that the file is only created when enabled.
+     */
+    @Test
+    public void testEnable() throws Exception {
+        final File logFilePath = DebugLogFileBackend.getLogFilePath();
+
+        // Log with the debug log file disabled
+        final DebugLogFileBackend backend = new DebugLogFileBackend(Log.INFO);
+        backend.print(Log.WARN, BuildConfig.LOG_TAG, null, "hi");
+
+        // Enabling the debug log file won't create the log file just yet
+        Assert.assertFalse(logFilePath.exists());
+        DebugLogFileBackend.setEnabled(true);
+        Assert.assertFalse(logFilePath.exists());
+
+        // Logs below the min log level are filtered
+        backend.printAsync(Log.DEBUG, BuildConfig.LOG_TAG, null, "hey").get(500, TimeUnit.MILLISECONDS);
+        Assert.assertFalse(logFilePath.exists());
+
+        // Log with the debug log file enabled
+        backend.printAsync(Log.WARN, BuildConfig.LOG_TAG, null, "hi").get(500, TimeUnit.MILLISECONDS);
+        Assert.assertTrue(logFilePath.exists());
+    }
+
+    /**
+     * Make sure that enabling the debug log file actually creates the debug log file.
+     */
+    @Test
+    public void testDisableRemovesFile() throws IOException {
+        final File logFilePath = DebugLogFileBackend.getLogFilePath();
+        Assert.assertFalse(logFilePath.exists());
+        Assert.assertTrue("Could not create logfile", logFilePath.createNewFile());
+        Assert.assertTrue(logFilePath.exists());
+        DebugLogFileBackend.setEnabled(false);
+        Assert.assertFalse(logFilePath.exists());
+    }
 
 }

+ 26 - 17
app/src/androidTest/java/ch/threema/storage/DatabaseNonceStoreTest.kt

@@ -192,7 +192,10 @@ class DatabaseNonceStoreTest {
         }
     }
 
-    private fun assertSameHashedNonces(expected: Collection<HashedNonce>, actual: Collection<HashedNonce>) {
+    private fun assertSameHashedNonces(
+        expected: Collection<HashedNonce>,
+        actual: Collection<HashedNonce>
+    ) {
         assertEquals(expected.size, actual.size)
         expected.forEach { expectedNonce ->
             // If `actual.contains(expectedNonce)` is used only referential equality is checked
@@ -227,28 +230,34 @@ const val USER_IDENTITY = "01234567"
 private class TestIdentityStore : IdentityStoreInterface {
     override fun getIdentity(): String = USER_IDENTITY
 
-    override fun encryptData(plaintext: ByteArray, nonce: ByteArray, receiverPublicKey: ByteArray): ByteArray
-        = throw UnsupportedOperationException()
+    override fun encryptData(
+        plaintext: ByteArray,
+        nonce: ByteArray,
+        receiverPublicKey: ByteArray
+    ): ByteArray = throw UnsupportedOperationException()
 
-    override fun decryptData(ciphertext: ByteArray, nonce: ByteArray, senderPublicKey: ByteArray): ByteArray
-        = throw UnsupportedOperationException()
+    override fun decryptData(
+        ciphertext: ByteArray,
+        nonce: ByteArray,
+        senderPublicKey: ByteArray
+    ): ByteArray = throw UnsupportedOperationException()
 
-    override fun calcSharedSecret(publicKey: ByteArray): ByteArray
-        = throw UnsupportedOperationException()
+    override fun calcSharedSecret(publicKey: ByteArray): ByteArray =
+        throw UnsupportedOperationException()
 
-    override fun getServerGroup(): String
-        = throw UnsupportedOperationException()
+    override fun getServerGroup(): String = throw UnsupportedOperationException()
 
-    override fun getPublicKey(): ByteArray
-        = throw UnsupportedOperationException()
+    override fun getPublicKey(): ByteArray = throw UnsupportedOperationException()
 
-    override fun getPrivateKey(): ByteArray
-        = throw UnsupportedOperationException()
+    override fun getPrivateKey(): ByteArray = throw UnsupportedOperationException()
 
-    override fun getPublicNickname(): String
-        = throw UnsupportedOperationException()
+    override fun getPublicNickname(): String = throw UnsupportedOperationException()
 
-    override fun storeIdentity(identity: String, serverGroup: String, publicKey: ByteArray, privateKey: ByteArray)
-        = throw UnsupportedOperationException()
+    override fun storeIdentity(
+        identity: String,
+        serverGroup: String,
+        publicKey: ByteArray,
+        privateKey: ByteArray
+    ) = throw UnsupportedOperationException()
 
 }

+ 233 - 233
app/src/androidTest/java/ch/threema/storage/SQLDHSessionStoreTest.java

@@ -44,237 +44,237 @@ import ch.threema.domain.taskmanager.TaskCodec;
 
 public class SQLDHSessionStoreTest {
 
-	private static final byte[] DATABASE_KEY = "dummyKey".getBytes(StandardCharsets.UTF_8);
-	private static final int NUM_RANDOM_RUNS = 20;
-
-	private String tempDbFileName;
-	private SQLDHSessionStore store;
-	private DHSession initiatorDHSession;
-	private DHSession responderDHSession;
-	private final TaskCodec taskCodec = new UnusedTaskCodec();
-
-	@Before
-	public void setup() {
-		tempDbFileName = "threema-fs-test-" + System.currentTimeMillis() + ".db";
-		store = new SQLDHSessionStore(
-			ApplicationProvider.getApplicationContext(),
-			DATABASE_KEY,
-			tempDbFileName,
-			ThreemaApplication.requireServiceManager().getUpdateSystemService()
-		);
-	}
-
-	@After
-	public void tearDown() {
-		store.close();
-		store = null;
-		ApplicationProvider.getApplicationContext().deleteDatabase(tempDbFileName);
-	}
-
-	public void createSessions() throws BadMessageException {
-		// Alice is the initiator (= us)
-		this.initiatorDHSession = new DHSession(
-			DummyUsers.getContactForUser(DummyUsers.BOB),
-			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
-		);
-
-		// Bob gets an init message from Alice with her ephemeral public key
-		this.responderDHSession = new DHSession(
-			this.initiatorDHSession.getId(),
-			DHSession.SUPPORTED_VERSION_RANGE,
-			this.initiatorDHSession.getMyEphemeralPublicKey(),
-			DummyUsers.getContactForUser(DummyUsers.ALICE),
-			DummyUsers.getIdentityStoreForUser(DummyUsers.BOB)
-		);
-	}
-
-	@Test
-	public void testStoreInitiatorSession() throws DHSessionStoreException, DHSession.MissingEphemeralPrivateKeyException, BadMessageException {
-		// Assume that we are Alice = the initiator, and Bob is the responder
-		createSessions();
-
-		// Delete any stored initiator session to start with a clean slate
-		store.deleteAllDHSessions(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity());
-		Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
-
-		// Insert an initiator DH session in 2DH mode
-		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet2DH());
-		Assert.assertNull(this.initiatorDHSession.getMyRatchet4DH());
-		store.storeDHSession(this.initiatorDHSession);
-
-		// Retrieve the session again and ensure that the details match
-		Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
-
-		// Turn 2DH ratchets once (need to do this here, as responder sessions are always 4DH)
-		this.initiatorDHSession.getMyRatchet2DH().turn();
-		store.storeDHSession(this.initiatorDHSession);
-		Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
-
-		// Now Bob sends his ephemeral public key back to Alice
-		this.initiatorDHSession.processAccept(
-			DHSession.SUPPORTED_VERSION_RANGE,
-			this.responderDHSession.getMyEphemeralPublicKey(),
-			DummyUsers.getContactForUser(DummyUsers.BOB),
-			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
-		);
-
-		// initiatorDHSession has now been upgraded to 4DH - store and retrieve it again
-		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet4DH());
-		store.storeDHSession(this.initiatorDHSession);
-		DHSession bestSession = this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec);
-		Assert.assertNotNull(bestSession);
-		Assert.assertEquals(this.initiatorDHSession, bestSession);
-
-		// Check that the private key has been discarded
-		Assert.assertNull(bestSession.getMyEphemeralPrivateKey());
-
-		// Delete initiator DH session
-		store.deleteDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), this.initiatorDHSession.getId());
-		Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
-	}
-
-	@Test
-	public void testStoreResponderSession() throws DHSessionStoreException, BadMessageException {
-		// Assume that we are Bob = the responder
-		createSessions();
-
-		// Store and retrieve the responder session
-		store.storeDHSession(this.responderDHSession);
-		Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), taskCodec));
-
-		// Turn the 4DH ratchets once, store, retrieve and compare again
-		Assert.assertNotNull(this.responderDHSession.getMyRatchet4DH());
-		Assert.assertNotNull(this.responderDHSession.getPeerRatchet4DH());
-		this.responderDHSession.getMyRatchet4DH().turn();
-		this.responderDHSession.getPeerRatchet4DH().turn();
-		store.storeDHSession(this.responderDHSession);
-		Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), taskCodec));
-
-		// Try to retrieve a responder session with a random session ID
-		Assert.assertNull(this.store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), new DHSessionId(), taskCodec));
-
-		// Delete DH session
-		store.deleteDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId());
-		Assert.assertNull(this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), taskCodec));
-	}
-
-	@Test
-	public void testDiscardRatchet() throws DHSessionStoreException, BadMessageException {
-		// Assume that we are Bob = the responder
-		createSessions();
-
-		Assert.assertNotNull(this.responderDHSession.getPeerRatchet2DH());
-		Assert.assertNotNull(this.responderDHSession.getPeerRatchet4DH());
-
-		// Store the responder session, including the 2DH ratchet
-		store.storeDHSession(this.responderDHSession);
-
-		// There should still be a 2DH ratchet at this point
-		DHSession retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId(), taskCodec);
-		Assert.assertNotNull(retrievedSession);
-		Assert.assertNotNull(retrievedSession.getPeerRatchet2DH());
-
-		// Discard the 2DH ratchet (assume Bob has received a 4DH message from Alice)
-		this.responderDHSession.discardPeerRatchet2DH();
-		Assert.assertNull(this.responderDHSession.getPeerRatchet2DH());
-
-		// Store the responder session again without the 2DH ratchet
-		store.storeDHSession(this.responderDHSession);
-
-		// Ensure that the 2DH ratchet is really gone
-		retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId(), taskCodec);
-		Assert.assertNotNull(retrievedSession);
-		Assert.assertNull(retrievedSession.getPeerRatchet2DH());
-	}
-
-	@Test
-	public void testRaceCondition() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException, BadMessageException {
-		// Repeat the test several times, as random session IDs are involved
-		for (int i = 0; i < NUM_RANDOM_RUNS; i++) {
-			if (i > 0) {
-				tearDown();
-				setup();
-			}
-			testRaceConditionOnce();
-		}
-	}
-
-	@Test
-	public void testGetAllSessions() throws DHSessionStoreException {
-		// Create sessions and its id's hashes
-		List<DHSession> dhSessions = new ArrayList<>();
-		for (int i = 0; i < 5; i++) {
-			dhSessions.add(new DHSession(
-				DummyUsers.getContactForUser(DummyUsers.BOB),
-				DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
-			));
-		}
-		List<Integer> dhSessionIdHashes = new ArrayList<>(dhSessions.size());
-		for (DHSession session : dhSessions) {
-			dhSessionIdHashes.add(session.getId().hashCode());
-		}
-
-		// Store the sessions
-		for (DHSession session : dhSessions) {
-			store.storeDHSession(session);
-		}
-
-		// Load the sessions again and calculate the hashes
-		List<DHSession> storedDHSessions = store.getAllDHSessions(
-			DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec
-		);
-		List<Integer> storedDHSessionIdHashes = new ArrayList<>(storedDHSessions.size());
-		for (DHSession session : storedDHSessions) {
-			storedDHSessionIdHashes.add(session.getId().hashCode());
-		}
-
-		// Assert that the hashes match (note that the ordering does not matter)
-		MatcherAssert.assertThat(storedDHSessionIdHashes, Matchers.containsInAnyOrder(dhSessionIdHashes.toArray()));
-	}
-
-	private void testRaceConditionOnce() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException, BadMessageException {
-		createSessions();
-
-		// Alice stores the session that she initiated (still in 2DH mode)
-		store.storeDHSession(this.initiatorDHSession);
-
-		// Pretend Bob has created a (separate) DH session before he has received the Init from Alice
-		DHSession raceInitiatorDHSession = new DHSession(
-			DummyUsers.getContactForUser(DummyUsers.BOB),
-			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
-		);
-
-		// Alice gets the Init for Bob's new session first and processes it
-		DHSession raceResponderDHSession = new DHSession(
-			raceInitiatorDHSession.getId(),
-			DHSession.SUPPORTED_VERSION_RANGE,
-			raceInitiatorDHSession.getMyEphemeralPublicKey(),
-			DummyUsers.getContactForUser(DummyUsers.BOB),
-			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
-		);
-
-		store.storeDHSession(raceResponderDHSession);
-
-		// Alice then processes the Accept from Bob and stores the session
-		this.initiatorDHSession.processAccept(
-			DHSession.SUPPORTED_VERSION_RANGE,
-			this.responderDHSession.getMyEphemeralPublicKey(),
-			DummyUsers.getContactForUser(DummyUsers.BOB),
-			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
-		);
-
-		store.storeDHSession(this.initiatorDHSession);
-
-		// At this point, there should be only one DH session with Bob from Alice's point of view,
-		// and it should be the one with the lower session ID
-		DHSessionId lowestSessionId;
-		if (raceResponderDHSession.getId().compareTo(this.initiatorDHSession.getId()) < 0) {
-			lowestSessionId = raceResponderDHSession.getId();
-		} else {
-			lowestSessionId = this.initiatorDHSession.getId();
-		}
-		DHSession bestSession = store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec);
-		Assert.assertNotNull(bestSession);
-		Assert.assertEquals(lowestSessionId, bestSession.getId());
-	}
+    private static final byte[] DATABASE_KEY = "dummyKey".getBytes(StandardCharsets.UTF_8);
+    private static final int NUM_RANDOM_RUNS = 20;
+
+    private String tempDbFileName;
+    private SQLDHSessionStore store;
+    private DHSession initiatorDHSession;
+    private DHSession responderDHSession;
+    private final TaskCodec taskCodec = new UnusedTaskCodec();
+
+    @Before
+    public void setup() {
+        tempDbFileName = "threema-fs-test-" + System.currentTimeMillis() + ".db";
+        store = new SQLDHSessionStore(
+            ApplicationProvider.getApplicationContext(),
+            DATABASE_KEY,
+            tempDbFileName,
+            ThreemaApplication.requireServiceManager().getUpdateSystemService()
+        );
+    }
+
+    @After
+    public void tearDown() {
+        store.close();
+        store = null;
+        ApplicationProvider.getApplicationContext().deleteDatabase(tempDbFileName);
+    }
+
+    public void createSessions() throws BadMessageException {
+        // Alice is the initiator (= us)
+        this.initiatorDHSession = new DHSession(
+            DummyUsers.getContactForUser(DummyUsers.BOB),
+            DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+        );
+
+        // Bob gets an init message from Alice with her ephemeral public key
+        this.responderDHSession = new DHSession(
+            this.initiatorDHSession.getId(),
+            DHSession.SUPPORTED_VERSION_RANGE,
+            this.initiatorDHSession.getMyEphemeralPublicKey(),
+            DummyUsers.getContactForUser(DummyUsers.ALICE),
+            DummyUsers.getIdentityStoreForUser(DummyUsers.BOB)
+        );
+    }
+
+    @Test
+    public void testStoreInitiatorSession() throws DHSessionStoreException, DHSession.MissingEphemeralPrivateKeyException, BadMessageException {
+        // Assume that we are Alice = the initiator, and Bob is the responder
+        createSessions();
+
+        // Delete any stored initiator session to start with a clean slate
+        store.deleteAllDHSessions(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity());
+        Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
+
+        // Insert an initiator DH session in 2DH mode
+        Assert.assertNotNull(this.initiatorDHSession.getMyRatchet2DH());
+        Assert.assertNull(this.initiatorDHSession.getMyRatchet4DH());
+        store.storeDHSession(this.initiatorDHSession);
+
+        // Retrieve the session again and ensure that the details match
+        Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
+
+        // Turn 2DH ratchets once (need to do this here, as responder sessions are always 4DH)
+        this.initiatorDHSession.getMyRatchet2DH().turn();
+        store.storeDHSession(this.initiatorDHSession);
+        Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
+
+        // Now Bob sends his ephemeral public key back to Alice
+        this.initiatorDHSession.processAccept(
+            DHSession.SUPPORTED_VERSION_RANGE,
+            this.responderDHSession.getMyEphemeralPublicKey(),
+            DummyUsers.getContactForUser(DummyUsers.BOB),
+            DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+        );
+
+        // initiatorDHSession has now been upgraded to 4DH - store and retrieve it again
+        Assert.assertNotNull(this.initiatorDHSession.getMyRatchet4DH());
+        store.storeDHSession(this.initiatorDHSession);
+        DHSession bestSession = this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec);
+        Assert.assertNotNull(bestSession);
+        Assert.assertEquals(this.initiatorDHSession, bestSession);
+
+        // Check that the private key has been discarded
+        Assert.assertNull(bestSession.getMyEphemeralPrivateKey());
+
+        // Delete initiator DH session
+        store.deleteDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), this.initiatorDHSession.getId());
+        Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
+    }
+
+    @Test
+    public void testStoreResponderSession() throws DHSessionStoreException, BadMessageException {
+        // Assume that we are Bob = the responder
+        createSessions();
+
+        // Store and retrieve the responder session
+        store.storeDHSession(this.responderDHSession);
+        Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), taskCodec));
+
+        // Turn the 4DH ratchets once, store, retrieve and compare again
+        Assert.assertNotNull(this.responderDHSession.getMyRatchet4DH());
+        Assert.assertNotNull(this.responderDHSession.getPeerRatchet4DH());
+        this.responderDHSession.getMyRatchet4DH().turn();
+        this.responderDHSession.getPeerRatchet4DH().turn();
+        store.storeDHSession(this.responderDHSession);
+        Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), taskCodec));
+
+        // Try to retrieve a responder session with a random session ID
+        Assert.assertNull(this.store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), new DHSessionId(), taskCodec));
+
+        // Delete DH session
+        store.deleteDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId());
+        Assert.assertNull(this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), taskCodec));
+    }
+
+    @Test
+    public void testDiscardRatchet() throws DHSessionStoreException, BadMessageException {
+        // Assume that we are Bob = the responder
+        createSessions();
+
+        Assert.assertNotNull(this.responderDHSession.getPeerRatchet2DH());
+        Assert.assertNotNull(this.responderDHSession.getPeerRatchet4DH());
+
+        // Store the responder session, including the 2DH ratchet
+        store.storeDHSession(this.responderDHSession);
+
+        // There should still be a 2DH ratchet at this point
+        DHSession retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId(), taskCodec);
+        Assert.assertNotNull(retrievedSession);
+        Assert.assertNotNull(retrievedSession.getPeerRatchet2DH());
+
+        // Discard the 2DH ratchet (assume Bob has received a 4DH message from Alice)
+        this.responderDHSession.discardPeerRatchet2DH();
+        Assert.assertNull(this.responderDHSession.getPeerRatchet2DH());
+
+        // Store the responder session again without the 2DH ratchet
+        store.storeDHSession(this.responderDHSession);
+
+        // Ensure that the 2DH ratchet is really gone
+        retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId(), taskCodec);
+        Assert.assertNotNull(retrievedSession);
+        Assert.assertNull(retrievedSession.getPeerRatchet2DH());
+    }
+
+    @Test
+    public void testRaceCondition() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException, BadMessageException {
+        // Repeat the test several times, as random session IDs are involved
+        for (int i = 0; i < NUM_RANDOM_RUNS; i++) {
+            if (i > 0) {
+                tearDown();
+                setup();
+            }
+            testRaceConditionOnce();
+        }
+    }
+
+    @Test
+    public void testGetAllSessions() throws DHSessionStoreException {
+        // Create sessions and its id's hashes
+        List<DHSession> dhSessions = new ArrayList<>();
+        for (int i = 0; i < 5; i++) {
+            dhSessions.add(new DHSession(
+                DummyUsers.getContactForUser(DummyUsers.BOB),
+                DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+            ));
+        }
+        List<Integer> dhSessionIdHashes = new ArrayList<>(dhSessions.size());
+        for (DHSession session : dhSessions) {
+            dhSessionIdHashes.add(session.getId().hashCode());
+        }
+
+        // Store the sessions
+        for (DHSession session : dhSessions) {
+            store.storeDHSession(session);
+        }
+
+        // Load the sessions again and calculate the hashes
+        List<DHSession> storedDHSessions = store.getAllDHSessions(
+            DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec
+        );
+        List<Integer> storedDHSessionIdHashes = new ArrayList<>(storedDHSessions.size());
+        for (DHSession session : storedDHSessions) {
+            storedDHSessionIdHashes.add(session.getId().hashCode());
+        }
+
+        // Assert that the hashes match (note that the ordering does not matter)
+        MatcherAssert.assertThat(storedDHSessionIdHashes, Matchers.containsInAnyOrder(dhSessionIdHashes.toArray()));
+    }
+
+    private void testRaceConditionOnce() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException, BadMessageException {
+        createSessions();
+
+        // Alice stores the session that she initiated (still in 2DH mode)
+        store.storeDHSession(this.initiatorDHSession);
+
+        // Pretend Bob has created a (separate) DH session before he has received the Init from Alice
+        DHSession raceInitiatorDHSession = new DHSession(
+            DummyUsers.getContactForUser(DummyUsers.BOB),
+            DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+        );
+
+        // Alice gets the Init for Bob's new session first and processes it
+        DHSession raceResponderDHSession = new DHSession(
+            raceInitiatorDHSession.getId(),
+            DHSession.SUPPORTED_VERSION_RANGE,
+            raceInitiatorDHSession.getMyEphemeralPublicKey(),
+            DummyUsers.getContactForUser(DummyUsers.BOB),
+            DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+        );
+
+        store.storeDHSession(raceResponderDHSession);
+
+        // Alice then processes the Accept from Bob and stores the session
+        this.initiatorDHSession.processAccept(
+            DHSession.SUPPORTED_VERSION_RANGE,
+            this.responderDHSession.getMyEphemeralPublicKey(),
+            DummyUsers.getContactForUser(DummyUsers.BOB),
+            DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+        );
+
+        store.storeDHSession(this.initiatorDHSession);
+
+        // At this point, there should be only one DH session with Bob from Alice's point of view,
+        // and it should be the one with the lower session ID
+        DHSessionId lowestSessionId;
+        if (raceResponderDHSession.getId().compareTo(this.initiatorDHSession.getId()) < 0) {
+            lowestSessionId = raceResponderDHSession.getId();
+        } else {
+            lowestSessionId = this.initiatorDHSession.getId();
+        }
+        DHSession bestSession = store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec);
+        Assert.assertNotNull(bestSession);
+        Assert.assertEquals(lowestSessionId, bestSession.getId());
+    }
 }

+ 2 - 1
app/src/androidTest/java/ch/threema/storage/TaskArchiveFactoryTest.kt

@@ -33,7 +33,8 @@ class TaskArchiveFactoryTest {
 
     @Before
     fun setup() {
-        taskArchiveFactory = ThreemaApplication.requireServiceManager().databaseServiceNew.taskArchiveFactory
+        taskArchiveFactory =
+            ThreemaApplication.requireServiceManager().databaseServiceNew.taskArchiveFactory
         taskArchiveFactory.deleteAll()
     }
 

+ 1 - 1
app/src/androidTest/java/com/azimolabs/conditionwatcher/ConditionWatcher.java

@@ -59,7 +59,7 @@ public class ConditionWatcher {
         } while (status != CONDITION_MET);
 
         if (status == TIMEOUT)
-            throw new Exception(instruction.getDescription() + " - took more than " + getInstance().timeoutLimit/1000 + " seconds. Test stopped.");
+            throw new Exception(instruction.getDescription() + " - took more than " + getInstance().timeoutLimit / 1000 + " seconds. Test stopped.");
     }
 
     public static void setWatchInterval(int watchInterval) {

+ 35 - 34
app/src/blue/AndroidManifest.xml

@@ -1,42 +1,43 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-          xmlns:tools="http://schemas.android.com/tools"
-          android:installLocation="internalOnly"
-          android:testOnly="false">
-	<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
+    xmlns:tools="http://schemas.android.com/tools"
+    android:installLocation="internalOnly"
+    android:testOnly="false">
 
-	<application tools:ignore="GoogleAppIndexingWarning">
+    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
 
-		<meta-data
-			android:name="android.content.APP_RESTRICTIONS"
-			android:resource="@xml/app_restrictions" />
-		<meta-data
-			android:name="com.google.android.gms.version"
-			android:value="@integer/google_play_services_version"/>
-		<meta-data
-			android:name="com.google.android.gms.car.application"
-			android:resource="@xml/automotive_app_desc"/>
-		<meta-data
-			android:name="firebase_analytics_collection_deactivated"
-			android:value="true"/>
-		<meta-data
-			android:name="google_analytics_adid_collection_enabled"
-			android:value="false"/>
-		<meta-data
-			android:name="google_analytics_ssaid_collection_enabled"
-			android:value="false" />
-		<meta-data
-			android:name="firebase_messaging_auto_init_enabled"
-			android:value="false"/>
+    <application tools:ignore="GoogleAppIndexingWarning">
 
-		<service
-			android:name="ch.threema.app.push.PushService"
-			android:exported="false">
-			<intent-filter>
-				<action android:name="com.google.firebase.MESSAGING_EVENT" />
-			</intent-filter>
-		</service>
+        <meta-data
+            android:name="android.content.APP_RESTRICTIONS"
+            android:resource="@xml/app_restrictions" />
+        <meta-data
+            android:name="com.google.android.gms.version"
+            android:value="@integer/google_play_services_version" />
+        <meta-data
+            android:name="com.google.android.gms.car.application"
+            android:resource="@xml/automotive_app_desc" />
+        <meta-data
+            android:name="firebase_analytics_collection_deactivated"
+            android:value="true" />
+        <meta-data
+            android:name="google_analytics_adid_collection_enabled"
+            android:value="false" />
+        <meta-data
+            android:name="google_analytics_ssaid_collection_enabled"
+            android:value="false" />
+        <meta-data
+            android:name="firebase_messaging_auto_init_enabled"
+            android:value="false" />
 
-	</application>
+        <service
+            android:name="ch.threema.app.push.PushService"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="com.google.firebase.MESSAGING_EVENT" />
+            </intent-filter>
+        </service>
+
+    </application>
 
 </manifest>

+ 9 - 9
app/src/blue/java/ch/threema/app/activities/DownloadApkActivity.java

@@ -30,17 +30,17 @@ import androidx.appcompat.app.AppCompatActivity;
 import ch.threema.base.utils.LoggingUtil;
 
 public class DownloadApkActivity extends AppCompatActivity {
-	public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
-	// stub
+    public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
+    // stub
 
-	private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
+    private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
 
-	@Override
-	protected void onCreate(@Nullable Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
 
-		logger.error("This activity may not be used in this build variant");
+        logger.error("This activity may not be used in this build variant");
 
-		finish();
-	}
+        finish();
+    }
 }

+ 3 - 2
app/src/blue/java/ch/threema/app/utils/DownloadUtil.java

@@ -24,6 +24,7 @@ package ch.threema.app.utils;
 import android.content.Context;
 
 public class DownloadUtil {
-	// stub, download happens through f-droid store
-	public static void deleteOldAPKs(Context context) {	}
+    // stub, download happens through f-droid store
+    public static void deleteOldAPKs(Context context) {
+    }
 }

+ 31 - 19
app/src/blue/res/drawable-v24/ic_launcher_foreground.xml

@@ -1,41 +1,53 @@
-<vector
-    xmlns:android="http://schemas.android.com/apk/res/android"
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
     android:width="108dp"
     android:height="108dp"
     android:viewportWidth="1500"
     android:viewportHeight="1500">
-      <group android:translateX="238"
-          android:translateY="238">
-<!--    <group>-->
+    <group
+        android:translateX="238"
+        android:translateY="238">
+        <!--    <group>-->
         <!-- background color of icon -->
-        <path android:fillColor="#FFF"
+        <path
+            android:fillColor="#FFF"
             android:fillType="evenOdd"
-            android:pathData="M0,0h1024v1024h-1024z" android:strokeColor="#00000000"/>
+            android:pathData="M0,0h1024v1024h-1024z"
+            android:strokeColor="#00000000" />
         <!-- sky color -->
-        <path android:fillColor="@color/ic_launcher_sky"
+        <path
+            android:fillColor="@color/ic_launcher_sky"
             android:fillType="evenOdd"
-            android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8ZZ" android:strokeColor="#00000000"/>
+            android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8ZZ"
+            android:strokeColor="#00000000" />
         <!-- shadow of the dune -->
         <group>
-            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z"/>
-            <path android:fillColor="@color/ic_launcher_shadow"
+            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z" />
+            <path
+                android:fillColor="@color/ic_launcher_shadow"
                 android:fillType="evenOdd"
-                android:pathData="M134.7,434.5C286.6,434.5 403.7,243 512,243C618.4,245.6 774.8,438.4 882,434.5L882,730L134.7,730L134.7,434.5ZZ" android:strokeColor="#00000000"/>
+                android:pathData="M134.7,434.5C286.6,434.5 403.7,243 512,243C618.4,245.6 774.8,438.4 882,434.5L882,730L134.7,730L134.7,434.5ZZ"
+                android:strokeColor="#00000000" />
         </group>
         <!-- right side of the dune -->
         <group>
-            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z"/>
-            <path android:fillColor="@color/ic_launcher_dune"
+            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z" />
+            <path
+                android:fillColor="@color/ic_launcher_dune"
                 android:fillType="evenOdd"
-                android:pathData="M479.9,248.7C544.4,229 572.5,307.3 459.2,389.2C346,471 182.8,668 479.9,730L882,730L882,434.5C769.9,434.5 594.9,204.7 479.9,248.7ZZ" android:strokeColor="#00000000"/>
+                android:pathData="M479.9,248.7C544.4,229 572.5,307.3 459.2,389.2C346,471 182.8,668 479.9,730L882,730L882,434.5C769.9,434.5 594.9,204.7 479.9,248.7ZZ"
+                android:strokeColor="#00000000" />
         </group>
         <!-- pad lock symbol -->
-        <path android:fillColor="#FFFFFF"
+        <path
+            android:fillColor="#FFFFFF"
             android:fillType="evenOdd"
-            android:pathData="M512,274C563.6,274 605.3,315.8 605.3,367.2L605.3,404.5L609,404.5C617.3,404.5 624,411.2 624,419.5L624,551C624,559.3 617.3,566 609,566L415,566C406.7,566 400,559.3 400,551L400,419.5C400,411.2 406.7,404.5 415,404.5L418.7,404.5L418.7,367.2C418.7,315.8 460.4,274 512,274ZZM512,311.3C481.1,311.3 456,336.3 456,367.2L456,404.5L568,404.5L568,367.2C568,336.3 542.9,311.3 512,311.3ZZ" android:strokeColor="#00000000"/>
+            android:pathData="M512,274C563.6,274 605.3,315.8 605.3,367.2L605.3,404.5L609,404.5C617.3,404.5 624,411.2 624,419.5L624,551C624,559.3 617.3,566 609,566L415,566C406.7,566 400,559.3 400,551L400,419.5C400,411.2 406.7,404.5 415,404.5L418.7,404.5L418.7,367.2C418.7,315.8 460.4,274 512,274ZZM512,311.3C481.1,311.3 456,336.3 456,367.2L456,404.5L568,404.5L568,367.2C568,336.3 542.9,311.3 512,311.3ZZ"
+            android:strokeColor="#00000000" />
         <!-- threema dots -->
-        <path android:fillColor="@color/ic_launcher_dots"
+        <path
+            android:fillColor="@color/ic_launcher_dots"
             android:fillType="evenOdd"
-            android:pathData="M568,848C568,878.9 542.9,904 511.9,904C481,904 456,878.9 456,848C456,817.1 481,792 511.9,792C542.9,792 568,817.1 568,848ZZM366,848C366,878.9 341,904 310,904C279.1,904 254,878.9 254,848C254,817.1 279.1,792 310,792C341,792 366,817.1 366,848ZZM769.9,848C769.9,878.9 744.9,904 713.9,904C683,904 658,878.9 658,848C658,817.1 683,792 713.9,792C744.9,792 769.9,817.1 769.9,848ZZ" android:strokeColor="#00000000"/>
+            android:pathData="M568,848C568,878.9 542.9,904 511.9,904C481,904 456,878.9 456,848C456,817.1 481,792 511.9,792C542.9,792 568,817.1 568,848ZZM366,848C366,878.9 341,904 310,904C279.1,904 254,878.9 254,848C254,817.1 279.1,792 310,792C341,792 366,817.1 366,848ZZM769.9,848C769.9,878.9 744.9,904 713.9,904C683,904 658,878.9 658,848C658,817.1 683,792 713.9,792C744.9,792 769.9,817.1 769.9,848ZZ"
+            android:strokeColor="#00000000" />
     </group>
 </vector>

+ 13 - 12
app/src/blue/res/drawable-v24/ic_launcher_monochrome.xml

@@ -3,16 +3,17 @@
     android:height="108dp"
     android:viewportWidth="108"
     android:viewportHeight="108">
-  <group android:scaleX="0.74"
-      android:scaleY="0.74"
-      android:translateX="14.04"
-      android:translateY="14.04">
-    <path
-        android:pathData="m58.72,80.98c1.56,-0.53 2.64,-1.78 2.64,-3.7 0,-2.88 -2.23,-4.39 -5.14,-4.39h-8.11v17.04h8.57c3.19,0 5.28,-1.8 5.28,-4.8 0,-2.18 -1.22,-3.53 -3.24,-4.15zM51.47,75.48h3.96c1.75,0 2.66,0.7 2.66,2.06 0,1.37 -0.94,2.21 -2.69,2.21h-3.94zM55.48,87.34h-4.01v-4.97h4.01c2.06,0 3.12,0.98 3.12,2.5 0,1.54 -1.03,2.47 -3.12,2.47z"
-        android:fillColor="#333333"/>
-    <path
-        android:pathData="M40.84,67.66l-14.89,3.72 3.18,-12.73c-3.15,-4.15 -4.99,-9.14 -4.99,-14.51 0,-14.45 13.31,-26.16 29.73,-26.16s29.73,11.71 29.73,26.16 -13.31,26.16 -29.73,26.16c-4.67,0 -9.09,-0.95 -13.03,-2.64h0ZM45.43,41.97h-0.33c-0.75,0 -1.36,0.61 -1.36,1.36v11.88c0,0.75 0.61,1.36 1.36,1.36h17.53c0.75,0 1.36,-0.61 1.36,-1.36v-11.88c0,-0.75 -0.61,-1.36 -1.36,-1.36h-0.33v-3.37c0,-4.65 -3.77,-8.42 -8.44,-8.42s-8.43,3.77 -8.43,8.42v3.37ZM58.92,41.97h-10.12v-3.37c0,-2.79 2.26,-5.05 5.06,-5.05s5.06,2.27 5.06,5.05v3.37Z"
-        android:fillColor="#333"
-        android:fillType="evenOdd"/>
-  </group>
+    <group
+        android:scaleX="0.74"
+        android:scaleY="0.74"
+        android:translateX="14.04"
+        android:translateY="14.04">
+        <path
+            android:pathData="m58.72,80.98c1.56,-0.53 2.64,-1.78 2.64,-3.7 0,-2.88 -2.23,-4.39 -5.14,-4.39h-8.11v17.04h8.57c3.19,0 5.28,-1.8 5.28,-4.8 0,-2.18 -1.22,-3.53 -3.24,-4.15zM51.47,75.48h3.96c1.75,0 2.66,0.7 2.66,2.06 0,1.37 -0.94,2.21 -2.69,2.21h-3.94zM55.48,87.34h-4.01v-4.97h4.01c2.06,0 3.12,0.98 3.12,2.5 0,1.54 -1.03,2.47 -3.12,2.47z"
+            android:fillColor="#333333" />
+        <path
+            android:pathData="M40.84,67.66l-14.89,3.72 3.18,-12.73c-3.15,-4.15 -4.99,-9.14 -4.99,-14.51 0,-14.45 13.31,-26.16 29.73,-26.16s29.73,11.71 29.73,26.16 -13.31,26.16 -29.73,26.16c-4.67,0 -9.09,-0.95 -13.03,-2.64h0ZM45.43,41.97h-0.33c-0.75,0 -1.36,0.61 -1.36,1.36v11.88c0,0.75 0.61,1.36 1.36,1.36h17.53c0.75,0 1.36,-0.61 1.36,-1.36v-11.88c0,-0.75 -0.61,-1.36 -1.36,-1.36h-0.33v-3.37c0,-4.65 -3.77,-8.42 -8.44,-8.42s-8.43,3.77 -8.43,8.42v3.37ZM58.92,41.97h-10.12v-3.37c0,-2.79 2.26,-5.05 5.06,-5.05s5.06,2.27 5.06,5.05v3.37Z"
+            android:fillColor="#333"
+            android:fillType="evenOdd" />
+    </group>
 </vector>

+ 12 - 4
app/src/blue/res/drawable/ic_badge_work.xml

@@ -1,5 +1,13 @@
-<vector android:height="48dp" android:viewportHeight="22"
-    android:viewportWidth="22" android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#fff" android:pathData="M11,0A11,11 0,1 1,0 11,11 11,0 0,1 11,0Z"/>
-    <path android:fillColor="#ff11a65d" android:fillType="evenOdd" android:pathData="M11,1A10,10 0,1 1,1 11,10 10,0 0,1 11,1ZM11,8L7,12v3.5a0.5,0.5 0,0 0,0.5 0.5h7a0.5,0.5 0,0 0,0.5 -0.5L15,12ZM11.354,5.525a0.5,0.5 0,0 0,-0.708 0l-5.3,5.3 0.707,0.708L11,6.586l4.95,4.95 0.707,-0.708Z"/>
+<vector android:height="48dp"
+    android:viewportHeight="22"
+    android:viewportWidth="22"
+    android:width="48dp"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+    <path
+        android:fillColor="#fff"
+        android:pathData="M11,0A11,11 0,1 1,0 11,11 11,0 0,1 11,0Z" />
+    <path
+        android:fillColor="#ff11a65d"
+        android:fillType="evenOdd"
+        android:pathData="M11,1A10,10 0,1 1,1 11,10 10,0 0,1 11,1ZM11,8L7,12v3.5a0.5,0.5 0,0 0,0.5 0.5h7a0.5,0.5 0,0 0,0.5 -0.5L15,12ZM11.354,5.525a0.5,0.5 0,0 0,-0.708 0l-5.3,5.3 0.707,0.708L11,6.586l4.95,4.95 0.707,-0.708Z" />
 </vector>

+ 24 - 9
app/src/blue/res/drawable/ic_finger_with_circles.xml

@@ -1,10 +1,25 @@
-<vector android:height="187dp" android:viewportHeight="374.962"
-    android:viewportWidth="330.142" android:width="164.64749dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="#0096ff" android:pathData="M165.124,80.018a85.07,85.07 0,1 1,-22.053 2.917,85.032 85.032,0 0,1 22.053,-2.917m0,-10v0a95.012,95.012 0,1 0,91.707 70.433,95.233 95.233,0 0,0 -91.707,-70.433Z"/>
-    <path android:fillAlpha="0.4" android:fillColor="#0096ff"
-        android:pathData="M165.146,45.009A120.1,120.1 0,1 1,134.015 49.128a120.046,120.046 0,0 1,31.133 -4.119m0.006,-10v10l0,-10A130.026,130.026 0,1 0,290.641 131.388,130.372 130.372,0 0,0 165.15,35.009Z" android:strokeAlpha="0.4"/>
-    <path android:fillAlpha="0.1" android:fillColor="#0096ff"
-        android:pathData="M165.168,10a155.132,155.132 0,1 1,-40.214 5.32,155.06 155.06,0 0,1 40.214,-5.32m0.007,-10v10l0,-10a165.329,165.329 0,1 0,99.548 33.519,165.526 165.526,0 0,0 -99.548,-33.519Z" android:strokeAlpha="0.1"/>
-    <path android:fillColor="#fff" android:pathData="M151.989,374.963L290.651,374.963L229.758,147.706a66.969,66.969 0,0 0,-129.374 34.666Z"/>
-    <path android:fillColor="#d8d8d8" android:pathData="M151.282,113.576a53.278,53.278 0,0 0,-38.626 41.88l13.882,51.81a48.149,48.149 0,0 0,93.017 -24.924l-13.882,-51.809A53.278,53.278 0,0 0,151.282 113.576Z"/>
+<vector android:height="187dp"
+    android:viewportHeight="374.962"
+    android:viewportWidth="330.142"
+    android:width="164.64749dp"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+    <path
+        android:fillColor="#0096ff"
+        android:pathData="M165.124,80.018a85.07,85.07 0,1 1,-22.053 2.917,85.032 85.032,0 0,1 22.053,-2.917m0,-10v0a95.012,95.012 0,1 0,91.707 70.433,95.233 95.233,0 0,0 -91.707,-70.433Z" />
+    <path
+        android:fillAlpha="0.4"
+        android:fillColor="#0096ff"
+        android:pathData="M165.146,45.009A120.1,120.1 0,1 1,134.015 49.128a120.046,120.046 0,0 1,31.133 -4.119m0.006,-10v10l0,-10A130.026,130.026 0,1 0,290.641 131.388,130.372 130.372,0 0,0 165.15,35.009Z"
+        android:strokeAlpha="0.4" />
+    <path
+        android:fillAlpha="0.1"
+        android:fillColor="#0096ff"
+        android:pathData="M165.168,10a155.132,155.132 0,1 1,-40.214 5.32,155.06 155.06,0 0,1 40.214,-5.32m0.007,-10v10l0,-10a165.329,165.329 0,1 0,99.548 33.519,165.526 165.526,0 0,0 -99.548,-33.519Z"
+        android:strokeAlpha="0.1" />
+    <path
+        android:fillColor="#fff"
+        android:pathData="M151.989,374.963L290.651,374.963L229.758,147.706a66.969,66.969 0,0 0,-129.374 34.666Z" />
+    <path
+        android:fillColor="#d8d8d8"
+        android:pathData="M151.282,113.576a53.278,53.278 0,0 0,-38.626 41.88l13.882,51.81a48.149,48.149 0,0 0,93.017 -24.924l-13.882,-51.809A53.278,53.278 0,0 0,151.282 113.576Z" />
 </vector>

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 2
app/src/blue/res/drawable/logo_main.xml


+ 272 - 272
app/src/blue/res/layout-land/activity_verification_level.xml

@@ -1,277 +1,277 @@
 <?xml version="1.0" encoding="utf-8"?>
 
 <LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
-	xmlns:tools="http://schemas.android.com/tools"
-	xmlns:android="http://schemas.android.com/apk/res/android"
-	android:id="@+id/linear_block"
-	android:layout_width="match_parent"
-	android:layout_height="match_parent"
-	android:orientation="vertical">
-
-	<include layout="@layout/toolbar_view"/>
-
-	<ScrollView
-		android:layout_width="match_parent"
-		android:layout_height="match_parent"
-		android:background="?android:attr/colorBackground">
-
-		<androidx.constraintlayout.widget.ConstraintLayout
-			android:id="@id/constraint_layout"
-			android:layout_width="match_parent"
-			android:layout_height="match_parent"
-			android:background="?android:attr/colorBackground">
-
-			<TextView
-				android:id="@id/verification_description"
-				android:layout_width="match_parent"
-				android:layout_height="wrap_content"
-				android:layout_marginEnd="16dp"
-				android:layout_marginStart="16dp"
-				android:layout_marginTop="16dp"
-				android:text="@string/verification_settings_desc"
-				android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-				android:textColor="?attr/colorOnBackground"
-				app:layout_constraintEnd_toEndOf="parent"
-				app:layout_constraintStart_toStartOf="parent"
-				app:layout_constraintTop_toTopOf="parent" />
-
-			<LinearLayout
-				android:layout_width="match_parent"
-				android:layout_height="match_parent"
-				android:clipToPadding="false"
-				android:orientation="horizontal"
-				android:paddingBottom="16dp"
-				android:paddingTop="16dp"
-				app:layout_constraintTop_toBottomOf="@id/verification_description"
-				tools:layout_editor_absoluteX="0dp">
-
-				<com.google.android.material.card.MaterialCardView
-					android:id="@id/work_verifications_container"
-					android:layout_width="match_parent"
-					android:layout_height="match_parent"
-					android:layout_marginLeft="16dp"
-					android:layout_marginRight="16dp"
-					android:layout_weight="1"
-					android:clickable="false"
-					android:focusable="false"
-					app:cardBackgroundColor="?attr/colorOnSurfaceInverse"
-					app:cardCornerRadius="@dimen/cardview_border_radius"
-					app:cardElevation="4dp"
-					app:strokeWidth="0dp"
-					app:layout_constraintEnd_toEndOf="parent"
-					app:layout_constraintTop_toBottomOf="@id/verification_description"
-					android:outlineProvider="none">
-
-					<androidx.constraintlayout.widget.ConstraintLayout
-						android:layout_width="match_parent"
-						android:layout_height="match_parent"
-						android:orientation="vertical">
-
-						<!-- Media -->
-						<ImageView
-							android:id="@id/verification_level_3_work_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level3_work_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_3_work_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_full_work" />
-
-						<ImageView
-							android:id="@id/verification_level_2_work_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level2_work_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_2_work_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_server_work" />
-
-						<TextView
-							android:id="@id/work_verifications_title"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:layout_marginEnd="16dp"
-							android:layout_marginTop="16dp"
-							android:text="@string/work_verification_levels_title"
-							android:textAppearance="@style/Threema.TextAppearance.Headline"
-							android:textSize="20sp"
-							app:layout_constraintStart_toStartOf="parent"
-							app:layout_constraintTop_toTopOf="parent" />
-
-						<TextView
-							android:id="@id/verification_level_3_work_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="16dp"
-							android:text="@string/verification_level3_work_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_3_work_img"
-							app:layout_constraintTop_toBottomOf="@id/work_verifications_title" />
-
-						<com.google.android.material.divider.MaterialDivider
-							android:layout_width="match_parent"
-							android:layout_height="1dp"
-							android:layout_marginTop="16dp"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_3_work_txt" />
-
-						<TextView
-							android:id="@id/verification_level_2_work_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="32dp"
-							android:text="@string/verification_level2_work_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_2_work_img"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_3_work_txt" />
-
-					</androidx.constraintlayout.widget.ConstraintLayout>
-
-				</com.google.android.material.card.MaterialCardView>
-
-				<com.google.android.material.card.MaterialCardView
-					android:id="@id/external_verifications_container"
-					android:layout_width="match_parent"
-					android:layout_height="wrap_content"
-					android:layout_marginRight="16dp"
-					android:clipToPadding="false"
-					android:layout_weight="1"
-					android:clickable="false"
-					android:focusable="false"
-					app:cardBackgroundColor="?attr/colorOnSurfaceInverse"
-					app:cardCornerRadius="@dimen/cardview_border_radius"
-					app:strokeWidth="0dp"
-					app:cardElevation="4dp"
-					android:outlineProvider="none">
-
-					<androidx.constraintlayout.widget.ConstraintLayout
-						android:layout_width="match_parent"
-						android:layout_height="wrap_content"
-						android:orientation="vertical"
-						android:clipToPadding="false">
-
-						<!-- Media -->
-						<ImageView
-							android:id="@id/verification_level_3_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level3_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_3_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_full" />
-
-						<ImageView
-							android:id="@id/verification_level_2_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level2_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_2_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_server" />
-
-						<ImageView
-							android:id="@id/verification_level_1_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level1_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_1_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_none" />
-
-						<TextView
-							android:id="@id/external_verifications_title"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="16dp"
-							android:layout_marginEnd="16dp"
-							android:text="@string/external_verification_levels_title"
-							android:textAppearance="@style/Threema.TextAppearance.Headline"
-							android:textSize="20sp"
-							app:layout_constraintStart_toStartOf="parent"
-							app:layout_constraintTop_toTopOf="parent" />
-
-						<TextView
-							android:id="@id/verification_level_3_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="16dp"
-							android:text="@string/verification_level3_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_3_img"
-							app:layout_constraintTop_toBottomOf="@id/external_verifications_title" />
-
-						<com.google.android.material.divider.MaterialDivider
-							android:layout_width="match_parent"
-							android:layout_height="1dp"
-							android:layout_marginTop="16dp"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_3_txt" />
-
-						<TextView
-							android:id="@id/verification_level_2_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="32dp"
-							android:text="@string/verification_level2_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_2_img"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_3_txt" />
-
-						<com.google.android.material.divider.MaterialDivider
-							android:layout_width="match_parent"
-							android:layout_height="1dp"
-							android:layout_marginTop="16dp"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_2_txt" />
-
-						<TextView
-							android:id="@id/verification_level_1_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginBottom="16dp"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="32dp"
-							android:text="@string/verification_level1_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintBottom_toBottomOf="parent"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_1_img"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_2_txt" />
-
-					</androidx.constraintlayout.widget.ConstraintLayout>
-
-				</com.google.android.material.card.MaterialCardView>
-			</LinearLayout>
-
-		</androidx.constraintlayout.widget.ConstraintLayout>
-
-	</ScrollView>
+    xmlns:tools="http://schemas.android.com/tools"
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/linear_block"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+
+    <include layout="@layout/toolbar_view" />
+
+    <ScrollView
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:background="?android:attr/colorBackground">
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:id="@id/constraint_layout"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:background="?android:attr/colorBackground">
+
+            <TextView
+                android:id="@id/verification_description"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginEnd="16dp"
+                android:layout_marginStart="16dp"
+                android:layout_marginTop="16dp"
+                android:text="@string/verification_settings_desc"
+                android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                android:textColor="?attr/colorOnBackground"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent" />
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                android:clipToPadding="false"
+                android:orientation="horizontal"
+                android:paddingBottom="16dp"
+                android:paddingTop="16dp"
+                app:layout_constraintTop_toBottomOf="@id/verification_description"
+                tools:layout_editor_absoluteX="0dp">
+
+                <com.google.android.material.card.MaterialCardView
+                    android:id="@id/work_verifications_container"
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:layout_marginLeft="16dp"
+                    android:layout_marginRight="16dp"
+                    android:layout_weight="1"
+                    android:clickable="false"
+                    android:focusable="false"
+                    app:cardBackgroundColor="?attr/colorOnSurfaceInverse"
+                    app:cardCornerRadius="@dimen/cardview_border_radius"
+                    app:cardElevation="4dp"
+                    app:strokeWidth="0dp"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintTop_toBottomOf="@id/verification_description"
+                    android:outlineProvider="none">
+
+                    <androidx.constraintlayout.widget.ConstraintLayout
+                        android:layout_width="match_parent"
+                        android:layout_height="match_parent"
+                        android:orientation="vertical">
+
+                        <!-- Media -->
+                        <ImageView
+                            android:id="@id/verification_level_3_work_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level3_work_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_3_work_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_full_work" />
+
+                        <ImageView
+                            android:id="@id/verification_level_2_work_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level2_work_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_2_work_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_server_work" />
+
+                        <TextView
+                            android:id="@id/work_verifications_title"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginTop="16dp"
+                            android:text="@string/work_verification_levels_title"
+                            android:textAppearance="@style/Threema.TextAppearance.Headline"
+                            android:textSize="20sp"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:layout_constraintTop_toTopOf="parent" />
+
+                        <TextView
+                            android:id="@id/verification_level_3_work_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="16dp"
+                            android:text="@string/verification_level3_work_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_3_work_img"
+                            app:layout_constraintTop_toBottomOf="@id/work_verifications_title" />
+
+                        <com.google.android.material.divider.MaterialDivider
+                            android:layout_width="match_parent"
+                            android:layout_height="1dp"
+                            android:layout_marginTop="16dp"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_3_work_txt" />
+
+                        <TextView
+                            android:id="@id/verification_level_2_work_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="32dp"
+                            android:text="@string/verification_level2_work_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_2_work_img"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_3_work_txt" />
+
+                    </androidx.constraintlayout.widget.ConstraintLayout>
+
+                </com.google.android.material.card.MaterialCardView>
+
+                <com.google.android.material.card.MaterialCardView
+                    android:id="@id/external_verifications_container"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginRight="16dp"
+                    android:clipToPadding="false"
+                    android:layout_weight="1"
+                    android:clickable="false"
+                    android:focusable="false"
+                    app:cardBackgroundColor="?attr/colorOnSurfaceInverse"
+                    app:cardCornerRadius="@dimen/cardview_border_radius"
+                    app:strokeWidth="0dp"
+                    app:cardElevation="4dp"
+                    android:outlineProvider="none">
+
+                    <androidx.constraintlayout.widget.ConstraintLayout
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:orientation="vertical"
+                        android:clipToPadding="false">
+
+                        <!-- Media -->
+                        <ImageView
+                            android:id="@id/verification_level_3_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level3_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_3_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_full" />
+
+                        <ImageView
+                            android:id="@id/verification_level_2_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level2_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_2_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_server" />
+
+                        <ImageView
+                            android:id="@id/verification_level_1_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level1_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_1_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_none" />
+
+                        <TextView
+                            android:id="@id/external_verifications_title"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="16dp"
+                            android:layout_marginEnd="16dp"
+                            android:text="@string/external_verification_levels_title"
+                            android:textAppearance="@style/Threema.TextAppearance.Headline"
+                            android:textSize="20sp"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:layout_constraintTop_toTopOf="parent" />
+
+                        <TextView
+                            android:id="@id/verification_level_3_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="16dp"
+                            android:text="@string/verification_level3_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_3_img"
+                            app:layout_constraintTop_toBottomOf="@id/external_verifications_title" />
+
+                        <com.google.android.material.divider.MaterialDivider
+                            android:layout_width="match_parent"
+                            android:layout_height="1dp"
+                            android:layout_marginTop="16dp"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_3_txt" />
+
+                        <TextView
+                            android:id="@id/verification_level_2_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="32dp"
+                            android:text="@string/verification_level2_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_2_img"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_3_txt" />
+
+                        <com.google.android.material.divider.MaterialDivider
+                            android:layout_width="match_parent"
+                            android:layout_height="1dp"
+                            android:layout_marginTop="16dp"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_2_txt" />
+
+                        <TextView
+                            android:id="@id/verification_level_1_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginBottom="16dp"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="32dp"
+                            android:text="@string/verification_level1_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintBottom_toBottomOf="parent"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_1_img"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_2_txt" />
+
+                    </androidx.constraintlayout.widget.ConstraintLayout>
+
+                </com.google.android.material.card.MaterialCardView>
+            </LinearLayout>
+
+        </androidx.constraintlayout.widget.ConstraintLayout>
+
+    </ScrollView>
 
 </LinearLayout>

+ 151 - 148
app/src/blue/res/layout/activity_enter_serial.xml

@@ -1,33 +1,34 @@
 <?xml version="1.0" encoding="utf-8"?>
 <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
-            xmlns:app="http://schemas.android.com/apk/res-auto"
-            android:id="@+id/top_view"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:fillViewport="true"
-            android:background="@android:color/black">
-
-	<FrameLayout android:layout_width="match_parent"
-				 android:layout_height="wrap_content">
-
-		<ImageView
-				android:id="@+id/wizard_background"
-				android:layout_width="wrap_content"
-				android:layout_height="fill_parent"
-				android:adjustViewBounds="true"
-				android:clickable="false"
-				android:src="@drawable/background_pic"
-				android:scaleType="centerCrop"/>
-
-		<RelativeLayout
-				android:id="@+id/layout_parent_top"
-				android:orientation="vertical"
-				android:layout_width="fill_parent"
-				android:layout_height="wrap_content"
-				android:paddingLeft="@dimen/wizard_contents_padding_horizontal"
-				android:paddingRight="@dimen/wizard_contents_padding_horizontal"
-				android:paddingTop="@dimen/wizard_contents_padding"
-				android:paddingBottom="@dimen/wizard_contents_padding">
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/top_view"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:fillViewport="true"
+    android:background="@android:color/black">
+
+    <FrameLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <ImageView
+            android:id="@+id/wizard_background"
+            android:layout_width="wrap_content"
+            android:layout_height="fill_parent"
+            android:adjustViewBounds="true"
+            android:clickable="false"
+            android:src="@drawable/background_pic"
+            android:scaleType="centerCrop" />
+
+        <RelativeLayout
+            android:id="@+id/layout_parent_top"
+            android:orientation="vertical"
+            android:layout_width="fill_parent"
+            android:layout_height="wrap_content"
+            android:paddingLeft="@dimen/wizard_contents_padding_horizontal"
+            android:paddingRight="@dimen/wizard_contents_padding_horizontal"
+            android:paddingTop="@dimen/wizard_contents_padding"
+            android:paddingBottom="@dimen/wizard_contents_padding">
 
             <ImageView
                 android:id="@+id/enter_serial_welcome_title"
@@ -39,125 +40,127 @@
                 android:scaleY="1.5"
                 android:layout_centerHorizontal="true" />
 
-			<TextView
-					style="@style/WizardMediumText"
-					android:id="@+id/layout_top"
-					android:layout_below="@id/enter_serial_welcome_title"
-					android:layout_width="match_parent"
-					android:layout_height="wrap_content"
-					android:gravity="center_horizontal"
-					android:layout_centerHorizontal="true"
-					android:linksClickable="true"
-					android:autoLink="web"
-					android:text="@string/enter_serial_body"
-					android:layout_marginBottom="5dp"
-			/>
-
-			<LinearLayout android:id="@+id/unlock_layout"
-						  android:layout_width="match_parent"
-						  android:layout_height="wrap_content"
-						  android:layout_marginTop="32dp"
-						  android:gravity="center_vertical"
-						  android:layout_below="@id/layout_top"
-						  android:orientation="horizontal">
-
-				<ImageView
-						style="@style/WizardEditTextIcon"
-						android:id="@+id/unlock_logo"
-						android:layout_width="@dimen/wizard_default_view_height"
-						android:layout_height="@dimen/wizard_default_view_height"
-						app:srcCompat="@drawable/ic_person_outline"/>
-
-				<ch.threema.app.emojis.EmojiEditText
-						style="@style/WizardEditText"
-						android:layout_width="fill_parent"
-						android:layout_height="@dimen/wizard_default_view_height"
-						android:layout_weight="2"
-						android:hint="@string/username_hint"
-						android:id="@+id/license_key"
-						android:inputType="textNoSuggestions"
-						android:imeOptions="actionNext"
-						android:nextFocusRight="@+id/password"
-						android:singleLine="true">
-					<requestFocus />
-
-				</ch.threema.app.emojis.EmojiEditText>
-
-			</LinearLayout>
-
-			<LinearLayout android:id="@+id/password_layout"
-						  android:layout_width="match_parent"
-						  android:layout_height="wrap_content"
-						  android:layout_marginTop="16dp"
-						  android:gravity="center_vertical"
-						  android:layout_below="@id/unlock_layout"
-						  android:orientation="horizontal">
-
-				<ImageView
-						style="@style/WizardEditTextIcon"
-						android:id="@+id/password_logo"
-						android:layout_gravity="bottom"
-						android:layout_width="@dimen/wizard_default_view_height"
-						android:layout_height="@dimen/wizard_default_view_height"
-						app:srcCompat="@drawable/ic_key_outline"/>
-
-				<com.google.android.material.textfield.TextInputLayout
-					android:id="@+id/password_container"
-					android:layout_width="match_parent"
-					android:layout_height="wrap_content"
-					app:hintAnimationEnabled="false"
-					app:counterEnabled="false"
-					app:hintEnabled="false"
-					app:passwordToggleEnabled="true"
-					app:errorEnabled="false"
-					app:hintTextAppearance="@style/NoHintTextAppearance">
-
-					<ch.threema.app.ui.ThreemaTextInputEditText
-						style="@style/WizardEditText"
-						android:layout_width="match_parent"
-						android:layout_height="@dimen/wizard_default_view_height"
-						android:hint="@string/password_hint"
-						android:id="@+id/password"
-						android:nextFocusRight="@+id/unlock_button"
-						android:inputType="textNoSuggestions|textPassword"
-						android:imeOptions="actionGo"
-						android:singleLine="true"/>
-
-				</com.google.android.material.textfield.TextInputLayout>
-
-			</LinearLayout>
-
-			<TextView
-					android:id="@+id/unlock_state"
-					android:layout_width="fill_parent"
-					android:layout_height="wrap_content"
-					android:layout_below="@id/password_layout"
-					android:layout_marginLeft="@dimen/wizard_default_view_height"
-					android:layout_marginTop="8dp"
-					android:layout_marginBottom="8dp"
-					android:textSize="@dimen/wizard_text_medium"
-					android:textColor="@color/material_red"/>
-
-			<androidx.appcompat.widget.AppCompatButton
-					style="@style/WizardButtonRegular"
-					android:id="@+id/unlock_button_work"
-					android:layout_below="@id/unlock_state"
-					android:layout_width="wrap_content"
-					android:layout_height="wrap_content"
-					android:layout_alignParentRight="true"
-					android:text="@string/next" />
-
-			<TextView
-				style="@style/WizardMediumText"
-				android:id="@+id/work_lost_credential_help"
-				android:layout_width="fill_parent"
-				android:layout_height="wrap_content"
-				android:layout_below="@id/unlock_button_work"
-				android:gravity="center_horizontal"
-				android:layout_marginTop="32dp" />
-
-		</RelativeLayout>
-
-	</FrameLayout>
+            <TextView
+                style="@style/WizardMediumText"
+                android:id="@+id/layout_top"
+                android:layout_below="@id/enter_serial_welcome_title"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:gravity="center_horizontal"
+                android:layout_centerHorizontal="true"
+                android:linksClickable="true"
+                android:autoLink="web"
+                android:text="@string/enter_serial_body"
+                android:layout_marginBottom="5dp" />
+
+            <LinearLayout
+                android:id="@+id/unlock_layout"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="32dp"
+                android:gravity="center_vertical"
+                android:layout_below="@id/layout_top"
+                android:orientation="horizontal">
+
+                <ImageView
+                    style="@style/WizardEditTextIcon"
+                    android:id="@+id/unlock_logo"
+                    android:layout_width="@dimen/wizard_default_view_height"
+                    android:layout_height="@dimen/wizard_default_view_height"
+                    app:srcCompat="@drawable/ic_person_outline" />
+
+                <ch.threema.app.emojis.EmojiEditText
+                    style="@style/WizardEditText"
+                    android:layout_width="fill_parent"
+                    android:layout_height="@dimen/wizard_default_view_height"
+                    android:layout_weight="2"
+                    android:hint="@string/username_hint"
+                    android:id="@+id/license_key"
+                    android:inputType="textNoSuggestions"
+                    android:imeOptions="actionNext"
+                    android:nextFocusRight="@+id/password"
+                    android:singleLine="true">
+
+                    <requestFocus />
+
+                </ch.threema.app.emojis.EmojiEditText>
+
+            </LinearLayout>
+
+            <LinearLayout
+                android:id="@+id/password_layout"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="16dp"
+                android:gravity="center_vertical"
+                android:layout_below="@id/unlock_layout"
+                android:orientation="horizontal">
+
+                <ImageView
+                    style="@style/WizardEditTextIcon"
+                    android:id="@+id/password_logo"
+                    android:layout_gravity="bottom"
+                    android:layout_width="@dimen/wizard_default_view_height"
+                    android:layout_height="@dimen/wizard_default_view_height"
+                    app:srcCompat="@drawable/ic_key_outline" />
+
+                <com.google.android.material.textfield.TextInputLayout
+                    android:id="@+id/password_container"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    app:hintAnimationEnabled="false"
+                    app:counterEnabled="false"
+                    app:hintEnabled="false"
+                    app:passwordToggleEnabled="true"
+                    app:errorEnabled="false"
+                    app:hintTextAppearance="@style/NoHintTextAppearance">
+
+                    <ch.threema.app.ui.ThreemaTextInputEditText
+                        style="@style/WizardEditText"
+                        android:layout_width="match_parent"
+                        android:layout_height="@dimen/wizard_default_view_height"
+                        android:hint="@string/password_hint"
+                        android:id="@+id/password"
+                        android:nextFocusRight="@+id/unlock_button"
+                        android:inputType="textNoSuggestions|textPassword"
+                        android:imeOptions="actionGo"
+                        android:singleLine="true" />
+
+                </com.google.android.material.textfield.TextInputLayout>
+
+            </LinearLayout>
+
+            <TextView
+                android:id="@+id/unlock_state"
+                android:layout_width="fill_parent"
+                android:layout_height="wrap_content"
+                android:layout_below="@id/password_layout"
+                android:layout_marginLeft="@dimen/wizard_default_view_height"
+                android:layout_marginTop="8dp"
+                android:layout_marginBottom="8dp"
+                android:textSize="@dimen/wizard_text_medium"
+                android:textColor="@color/material_red" />
+
+            <androidx.appcompat.widget.AppCompatButton
+                style="@style/WizardButtonRegular"
+                android:id="@+id/unlock_button_work"
+                android:layout_below="@id/unlock_state"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_alignParentRight="true"
+                android:text="@string/next" />
+
+            <TextView
+                style="@style/WizardMediumText"
+                android:id="@+id/work_lost_credential_help"
+                android:layout_width="fill_parent"
+                android:layout_height="wrap_content"
+                android:layout_below="@id/unlock_button_work"
+                android:gravity="center_horizontal"
+                android:layout_marginTop="32dp" />
+
+        </RelativeLayout>
+
+    </FrameLayout>
 
 </ScrollView>

+ 274 - 274
app/src/blue/res/layout/activity_verification_level.xml

@@ -1,279 +1,279 @@
 <?xml version="1.0" encoding="utf-8"?>
 
 <LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
-	xmlns:tools="http://schemas.android.com/tools"
-	android:id="@+id/linear_block"
-	android:layout_width="match_parent"
-	android:layout_height="match_parent"
-	android:orientation="vertical"
-	xmlns:android="http://schemas.android.com/apk/res/android">
-
-	<include layout="@layout/toolbar_view"/>
-
-	<ScrollView
-		android:layout_width="match_parent"
-		android:layout_height="match_parent"
-		android:layout_marginBottom="16dp"
-		android:background="?android:attr/colorBackground">
-
-		<androidx.constraintlayout.widget.ConstraintLayout
-			android:id="@+id/constraint_layout"
-			android:layout_width="match_parent"
-			android:layout_height="wrap_content"
-			android:background="?android:attr/colorBackground">
-
-			<TextView
-				android:id="@+id/verification_description"
-				android:layout_width="match_parent"
-				android:layout_height="wrap_content"
-				android:layout_marginEnd="16dp"
-				android:layout_marginStart="16dp"
-				android:layout_marginTop="16dp"
-				android:text="@string/verification_settings_desc"
-				android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-				android:textColor="?attr/colorOnBackground"
-				app:layout_constraintEnd_toEndOf="parent"
-				app:layout_constraintStart_toStartOf="parent"
-				app:layout_constraintTop_toTopOf="parent" />
-
-			<LinearLayout
-				android:layout_width="match_parent"
-				android:layout_height="wrap_content"
-				android:clipToPadding="false"
-				android:orientation="vertical"
-				android:paddingBottom="16dp"
-				android:paddingTop="16dp"
-				app:layout_constraintTop_toBottomOf="@id/verification_description"
-				tools:layout_editor_absoluteX="0dp">
-
-				<com.google.android.material.card.MaterialCardView
-					android:id="@+id/work_verifications_container"
-					android:layout_width="match_parent"
-					android:layout_height="wrap_content"
-					android:layout_marginLeft="16dp"
-					android:layout_marginRight="16dp"
-					android:clickable="false"
-					android:focusable="false"
-					app:cardBackgroundColor="?attr/colorOnSurfaceInverse"
-					app:cardCornerRadius="@dimen/cardview_border_radius"
-					app:cardElevation="4dp"
-					app:strokeWidth="0dp"
-					app:layout_constraintEnd_toEndOf="parent"
-					app:layout_constraintTop_toBottomOf="@id/verification_description"
-					android:outlineProvider="none">
-
-					<androidx.constraintlayout.widget.ConstraintLayout
-						android:layout_width="match_parent"
-						android:layout_height="wrap_content"
-						android:orientation="vertical">
-
-						<!-- Media -->
-						<ImageView
-							android:id="@+id/verification_level_3_work_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level3_work_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_3_work_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_full_work" />
-
-						<ImageView
-							android:id="@+id/verification_level_2_work_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level2_work_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_2_work_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_server_work" />
-
-						<TextView
-							android:id="@+id/work_verifications_title"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:layout_marginEnd="16dp"
-							android:layout_marginTop="16dp"
-							android:text="@string/work_verification_levels_title"
-							android:textAppearance="@style/Threema.TextAppearance.Headline"
-							android:textSize="20sp"
-							android:ellipsize="end"
-							app:layout_constraintStart_toStartOf="parent"
-							app:layout_constraintTop_toTopOf="parent" />
-
-						<TextView
-							android:id="@+id/verification_level_3_work_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="16dp"
-							android:text="@string/verification_level3_work_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_3_work_img"
-							app:layout_constraintTop_toBottomOf="@id/work_verifications_title" />
-
-						<com.google.android.material.divider.MaterialDivider
-							android:layout_width="match_parent"
-							android:layout_height="1dp"
-							android:layout_marginTop="16dp"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_3_work_txt" />
-
-						<TextView
-							android:id="@+id/verification_level_2_work_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginBottom="16dp"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="32dp"
-							android:text="@string/verification_level2_work_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintBottom_toBottomOf="parent"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_2_work_img"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_3_work_txt" />
-
-					</androidx.constraintlayout.widget.ConstraintLayout>
-
-				</com.google.android.material.card.MaterialCardView>
-
-				<com.google.android.material.card.MaterialCardView
-					android:id="@+id/external_verifications_container"
-					android:layout_width="match_parent"
-					android:layout_height="wrap_content"
-					android:layout_margin="16dp"
-					android:clipToPadding="false"
-					android:clickable="false"
-					android:focusable="false"
-					app:cardBackgroundColor="?attr/colorOnSurfaceInverse"
-					app:cardCornerRadius="@dimen/cardview_border_radius"
-					app:strokeWidth="0dp"
-					app:cardElevation="4dp"
-					android:outlineProvider="none">
-
-					<androidx.constraintlayout.widget.ConstraintLayout
-						android:layout_width="match_parent"
-						android:layout_height="wrap_content"
-						android:orientation="vertical"
-						android:clipToPadding="false">
-
-						<!-- Media -->
-						<ImageView
-							android:id="@+id/verification_level_3_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level3_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_3_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_full" />
-
-						<ImageView
-							android:id="@+id/verification_level_2_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level2_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_2_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_server" />
-
-						<ImageView
-							android:id="@+id/verification_level_1_img"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:baselineAlignBottom="true"
-							android:contentDescription="@string/verification_level1_explain"
-							app:layout_constraintBaseline_toBaselineOf="@id/verification_level_1_txt"
-							app:layout_constraintStart_toStartOf="parent"
-							app:srcCompat="@drawable/ic_verification_none" />
-
-						<TextView
-							android:id="@+id/external_verifications_title"
-							android:layout_width="wrap_content"
-							android:layout_height="wrap_content"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="16dp"
-							android:layout_marginEnd="16dp"
-							android:text="@string/external_verification_levels_title"
-							android:textAppearance="@style/Threema.TextAppearance.Headline"
-							android:textSize="20sp"
-							app:layout_constraintStart_toStartOf="parent"
-							app:layout_constraintTop_toTopOf="parent" />
-
-						<TextView
-							android:id="@+id/verification_level_3_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="16dp"
-							android:text="@string/verification_level3_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_3_img"
-							app:layout_constraintTop_toBottomOf="@id/external_verifications_title" />
-
-						<com.google.android.material.divider.MaterialDivider
-							android:layout_width="match_parent"
-							android:layout_height="1dp"
-							android:layout_marginTop="16dp"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_3_txt" />
-
-						<TextView
-							android:id="@+id/verification_level_2_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="32dp"
-							android:text="@string/verification_level2_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_2_img"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_3_txt" />
-
-						<com.google.android.material.divider.MaterialDivider
-							android:layout_width="match_parent"
-							android:layout_height="1dp"
-							android:layout_marginTop="16dp"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_2_txt" />
-
-						<TextView
-							android:id="@+id/verification_level_1_txt"
-							android:layout_width="0dp"
-							android:layout_height="wrap_content"
-							android:layout_marginBottom="16dp"
-							android:layout_marginEnd="16dp"
-							android:layout_marginStart="16dp"
-							android:layout_marginTop="32dp"
-							android:text="@string/verification_level1_explain"
-							android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
-							android:textColor="?attr/colorOnBackground"
-							app:layout_constraintBottom_toBottomOf="parent"
-							app:layout_constraintEnd_toEndOf="parent"
-							app:layout_constraintStart_toEndOf="@id/verification_level_1_img"
-							app:layout_constraintTop_toBottomOf="@id/verification_level_2_txt" />
-
-					</androidx.constraintlayout.widget.ConstraintLayout>
-
-				</com.google.android.material.card.MaterialCardView>
-			</LinearLayout>
-
-		</androidx.constraintlayout.widget.ConstraintLayout>
-
-	</ScrollView>
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/linear_block"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <include layout="@layout/toolbar_view" />
+
+    <ScrollView
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_marginBottom="16dp"
+        android:background="?android:attr/colorBackground">
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:id="@+id/constraint_layout"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:background="?android:attr/colorBackground">
+
+            <TextView
+                android:id="@+id/verification_description"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginEnd="16dp"
+                android:layout_marginStart="16dp"
+                android:layout_marginTop="16dp"
+                android:text="@string/verification_settings_desc"
+                android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                android:textColor="?attr/colorOnBackground"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent" />
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:clipToPadding="false"
+                android:orientation="vertical"
+                android:paddingBottom="16dp"
+                android:paddingTop="16dp"
+                app:layout_constraintTop_toBottomOf="@id/verification_description"
+                tools:layout_editor_absoluteX="0dp">
+
+                <com.google.android.material.card.MaterialCardView
+                    android:id="@+id/work_verifications_container"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginLeft="16dp"
+                    android:layout_marginRight="16dp"
+                    android:clickable="false"
+                    android:focusable="false"
+                    app:cardBackgroundColor="?attr/colorOnSurfaceInverse"
+                    app:cardCornerRadius="@dimen/cardview_border_radius"
+                    app:cardElevation="4dp"
+                    app:strokeWidth="0dp"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintTop_toBottomOf="@id/verification_description"
+                    android:outlineProvider="none">
+
+                    <androidx.constraintlayout.widget.ConstraintLayout
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:orientation="vertical">
+
+                        <!-- Media -->
+                        <ImageView
+                            android:id="@+id/verification_level_3_work_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level3_work_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_3_work_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_full_work" />
+
+                        <ImageView
+                            android:id="@+id/verification_level_2_work_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level2_work_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_2_work_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_server_work" />
+
+                        <TextView
+                            android:id="@+id/work_verifications_title"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginTop="16dp"
+                            android:text="@string/work_verification_levels_title"
+                            android:textAppearance="@style/Threema.TextAppearance.Headline"
+                            android:textSize="20sp"
+                            android:ellipsize="end"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:layout_constraintTop_toTopOf="parent" />
+
+                        <TextView
+                            android:id="@+id/verification_level_3_work_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="16dp"
+                            android:text="@string/verification_level3_work_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_3_work_img"
+                            app:layout_constraintTop_toBottomOf="@id/work_verifications_title" />
+
+                        <com.google.android.material.divider.MaterialDivider
+                            android:layout_width="match_parent"
+                            android:layout_height="1dp"
+                            android:layout_marginTop="16dp"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_3_work_txt" />
+
+                        <TextView
+                            android:id="@+id/verification_level_2_work_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginBottom="16dp"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="32dp"
+                            android:text="@string/verification_level2_work_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintBottom_toBottomOf="parent"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_2_work_img"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_3_work_txt" />
+
+                    </androidx.constraintlayout.widget.ConstraintLayout>
+
+                </com.google.android.material.card.MaterialCardView>
+
+                <com.google.android.material.card.MaterialCardView
+                    android:id="@+id/external_verifications_container"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_margin="16dp"
+                    android:clipToPadding="false"
+                    android:clickable="false"
+                    android:focusable="false"
+                    app:cardBackgroundColor="?attr/colorOnSurfaceInverse"
+                    app:cardCornerRadius="@dimen/cardview_border_radius"
+                    app:strokeWidth="0dp"
+                    app:cardElevation="4dp"
+                    android:outlineProvider="none">
+
+                    <androidx.constraintlayout.widget.ConstraintLayout
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:orientation="vertical"
+                        android:clipToPadding="false">
+
+                        <!-- Media -->
+                        <ImageView
+                            android:id="@+id/verification_level_3_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level3_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_3_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_full" />
+
+                        <ImageView
+                            android:id="@+id/verification_level_2_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level2_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_2_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_server" />
+
+                        <ImageView
+                            android:id="@+id/verification_level_1_img"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:baselineAlignBottom="true"
+                            android:contentDescription="@string/verification_level1_explain"
+                            app:layout_constraintBaseline_toBaselineOf="@id/verification_level_1_txt"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:srcCompat="@drawable/ic_verification_none" />
+
+                        <TextView
+                            android:id="@+id/external_verifications_title"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="16dp"
+                            android:layout_marginEnd="16dp"
+                            android:text="@string/external_verification_levels_title"
+                            android:textAppearance="@style/Threema.TextAppearance.Headline"
+                            android:textSize="20sp"
+                            app:layout_constraintStart_toStartOf="parent"
+                            app:layout_constraintTop_toTopOf="parent" />
+
+                        <TextView
+                            android:id="@+id/verification_level_3_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="16dp"
+                            android:text="@string/verification_level3_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_3_img"
+                            app:layout_constraintTop_toBottomOf="@id/external_verifications_title" />
+
+                        <com.google.android.material.divider.MaterialDivider
+                            android:layout_width="match_parent"
+                            android:layout_height="1dp"
+                            android:layout_marginTop="16dp"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_3_txt" />
+
+                        <TextView
+                            android:id="@+id/verification_level_2_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="32dp"
+                            android:text="@string/verification_level2_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_2_img"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_3_txt" />
+
+                        <com.google.android.material.divider.MaterialDivider
+                            android:layout_width="match_parent"
+                            android:layout_height="1dp"
+                            android:layout_marginTop="16dp"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_2_txt" />
+
+                        <TextView
+                            android:id="@+id/verification_level_1_txt"
+                            android:layout_width="0dp"
+                            android:layout_height="wrap_content"
+                            android:layout_marginBottom="16dp"
+                            android:layout_marginEnd="16dp"
+                            android:layout_marginStart="16dp"
+                            android:layout_marginTop="32dp"
+                            android:text="@string/verification_level1_explain"
+                            android:textAppearance="@style/Threema.TextAppearance.BodyLarge"
+                            android:textColor="?attr/colorOnBackground"
+                            app:layout_constraintBottom_toBottomOf="parent"
+                            app:layout_constraintEnd_toEndOf="parent"
+                            app:layout_constraintStart_toEndOf="@id/verification_level_1_img"
+                            app:layout_constraintTop_toBottomOf="@id/verification_level_2_txt" />
+
+                    </androidx.constraintlayout.widget.ConstraintLayout>
+
+                </com.google.android.material.card.MaterialCardView>
+            </LinearLayout>
+
+        </androidx.constraintlayout.widget.ConstraintLayout>
+
+    </ScrollView>
 
 </LinearLayout>

+ 28 - 29
app/src/blue/res/layout/header_contact_section_work.xml

@@ -2,38 +2,37 @@
   ~ Copyright (c) 2021 Threema GmbH
   ~ All rights reserved.
   -->
-<androidx.constraintlayout.widget.ConstraintLayout
-	xmlns:android="http://schemas.android.com/apk/res/android"
-	xmlns:app="http://schemas.android.com/apk/res-auto"
-	android:layout_width="match_parent"
-	android:layout_height="wrap_content"
-	android:background="?attr/colorOnSurfaceInverse">
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:background="?attr/colorOnSurfaceInverse">
 
-	<com.google.android.material.tabs.TabLayout
-		android:id="@+id/work_contacts_tab_layout"
-		android:layout_width="match_parent"
-		android:layout_height="@dimen/header_contact_section_work_height"
-		android:elevation="0dp"
-		app:layout_constraintLeft_toLeftOf="parent"
-		app:layout_constraintRight_toRightOf="parent"
-		app:layout_constraintTop_toTopOf="parent"
-		app:tabGravity="fill"
-		app:tabMode="fixed">
+    <com.google.android.material.tabs.TabLayout
+        android:id="@+id/work_contacts_tab_layout"
+        android:layout_width="match_parent"
+        android:layout_height="@dimen/header_contact_section_work_height"
+        android:elevation="0dp"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:tabGravity="fill"
+        app:tabMode="fixed">
 
-		<com.google.android.material.tabs.TabItem
-			android:id="@+id/tab_all_contacts"
-			android:layout_width="wrap_content"
-			android:layout_height="wrap_content"
-			android:icon="@drawable/ic_person_outline"
-			android:contentDescription="Alle Kontakte" />
+        <com.google.android.material.tabs.TabItem
+            android:id="@+id/tab_all_contacts"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:icon="@drawable/ic_person_outline"
+            android:contentDescription="Alle Kontakte" />
 
-		<com.google.android.material.tabs.TabItem
-			android:id="@+id/tab_work_contacts"
-			android:layout_width="wrap_content"
-			android:layout_height="wrap_content"
-			android:icon="@drawable/ic_work_outline"
-			android:contentDescription="Work-Kontakte" />
+        <com.google.android.material.tabs.TabItem
+            android:id="@+id/tab_work_contacts"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:icon="@drawable/ic_work_outline"
+            android:contentDescription="Work-Kontakte" />
 
-	</com.google.android.material.tabs.TabLayout>
+    </com.google.android.material.tabs.TabLayout>
 
 </androidx.constraintlayout.widget.ConstraintLayout>

+ 37 - 38
app/src/blue/res/layout/toolbar_home.xml

@@ -1,46 +1,45 @@
 <?xml version="1.0" encoding="utf-8"?>
 
-<com.google.android.material.appbar.MaterialToolbar
-		xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
-		android:id="@+id/main_toolbar"
-		android:layout_height="?attr/actionBarSize"
-		android:layout_width="match_parent"
-		android:paddingLeft="@dimen/navigation_icon_padding"
-		app:navigationIcon="@drawable/ic_account_circle_black_24dp"
-		app:layout_scrollFlags="enterAlways">
+<com.google.android.material.appbar.MaterialToolbar xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/main_toolbar"
+    android:layout_height="?attr/actionBarSize"
+    android:layout_width="match_parent"
+    android:paddingLeft="@dimen/navigation_icon_padding"
+    app:navigationIcon="@drawable/ic_account_circle_black_24dp"
+    app:layout_scrollFlags="enterAlways">
 
-	<androidx.appcompat.widget.AppCompatImageButton
-		style="?android:attr/borderlessButtonStyle"
-		android:id="@+id/toolbar_warning"
-		android:layout_width="36dp"
-		android:layout_height="36dp"
-		android:layout_marginRight="8dp"
-		android:padding="0dp"
-		android:drawablePadding="0dp"
-		android:layout_gravity="start"
-		android:clickable="true"
-		android:focusable="true"
-		android:background="@drawable/selector_compose_button"
-		android:contentDescription="@string/warning"
-		android:tooltipText="@string/warning"
-		app:srcCompat="@drawable/ic_warning"
-		app:tint="@color/material_red"
-		android:visibility="gone" />
+    <androidx.appcompat.widget.AppCompatImageButton
+        style="?android:attr/borderlessButtonStyle"
+        android:id="@+id/toolbar_warning"
+        android:layout_width="36dp"
+        android:layout_height="36dp"
+        android:layout_marginRight="8dp"
+        android:padding="0dp"
+        android:drawablePadding="0dp"
+        android:layout_gravity="start"
+        android:clickable="true"
+        android:focusable="true"
+        android:background="@drawable/selector_compose_button"
+        android:contentDescription="@string/warning"
+        android:tooltipText="@string/warning"
+        app:srcCompat="@drawable/ic_warning"
+        app:tint="@color/material_red"
+        android:visibility="gone" />
 
-	<FrameLayout
-			android:layout_width="wrap_content"
-			android:layout_height="wrap_content"
-			android:layout_gravity="center">
+    <FrameLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center">
 
-		<androidx.appcompat.widget.AppCompatImageView
-				android:id="@+id/toolbar_logo_main"
-				android:layout_width="wrap_content"
-				android:layout_height="wrap_content"
-				android:scaleType="centerCrop"
-				android:adjustViewBounds="true"
-				android:foreground="?android:selectableItemBackground"
-			/>
+        <androidx.appcompat.widget.AppCompatImageView
+            android:id="@+id/toolbar_logo_main"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:scaleType="centerCrop"
+            android:adjustViewBounds="true"
+            android:foreground="?android:selectableItemBackground" />
 
-	</FrameLayout>
+    </FrameLayout>
 
 </com.google.android.material.appbar.MaterialToolbar>

+ 20 - 20
app/src/blue/res/values-de/strings.xml

@@ -1,32 +1,32 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-	<string name="about_title">Threema Blue für Android</string>
-	<string name="enter_serial_body">Geben Sie Ihre Threema Work-Zugangsdaten ein, die Sie von Ihrem Unternehmen erhalten haben.</string>
-	<string name="serial_required_want_exit">Die Lizenz ist ungültig. Möchten Sie es nochmals versuchen oder Threema verlassen?</string>
-	<string name="checking_serial">Zugangsberechtigung wird überprüft</string>
-	<string name="username_hint">Benutzername</string>
-	<string name="new_wizard_works_like_phone_number">Ihre Threema-ID funktioniert wie eine Telefonnummer. Ihre Kontakte können Sie über diese ID erreichen.</string>
-	<string name="new_wizard_nickname_explain">Der Nickname wird bei Ihren Kontakten angezeigt, wenn sie eine Nachricht empfangen.</string>
-	<string name="new_wizard_help_your_friends_find_you">Damit Sie einfacher gefunden werden</string>
-	<string name="new_wizard_find_friends">Finden Sie Ihre Kontakte auf Threema</string>
-	<string name="new_wizard_sync_contacts_explain">Einschalten, um zu sehen, welche Ihrer Kontakte Threema oder Threema Work nutzen.</string>
-	<string name="new_wizard_anonymous_confirm">Sie haben weder eine Handynummer noch eine E-Mail-Adresse zur Verknüpfung angegeben. Sie werden deshalb nicht automatisch in Kontaktlisten aufgeführt. Möchten Sie Threema Blue wirklich komplett anonym nutzen?</string>
-	<string name="new_wizard_setup_threema">Threema Blue einrichten</string>
-	<string name="new_wizard_welcome">Willkommen bei Threema Blue</string>
-	<string name="new_wizard_info_fingerprint">Durch die Bewegung des Fingers erzeugen Sie Zufallsdaten (sogenannte Entropie),
+    <string name="about_title">Threema Blue für Android</string>
+    <string name="enter_serial_body">Geben Sie Ihre Threema Work-Zugangsdaten ein, die Sie von Ihrem Unternehmen erhalten haben.</string>
+    <string name="serial_required_want_exit">Die Lizenz ist ungültig. Möchten Sie es nochmals versuchen oder Threema verlassen?</string>
+    <string name="checking_serial">Zugangsberechtigung wird überprüft</string>
+    <string name="username_hint">Benutzername</string>
+    <string name="new_wizard_works_like_phone_number">Ihre Threema-ID funktioniert wie eine Telefonnummer. Ihre Kontakte können Sie über diese ID erreichen.</string>
+    <string name="new_wizard_nickname_explain">Der Nickname wird bei Ihren Kontakten angezeigt, wenn sie eine Nachricht empfangen.</string>
+    <string name="new_wizard_help_your_friends_find_you">Damit Sie einfacher gefunden werden</string>
+    <string name="new_wizard_find_friends">Finden Sie Ihre Kontakte auf Threema</string>
+    <string name="new_wizard_sync_contacts_explain">Einschalten, um zu sehen, welche Ihrer Kontakte Threema oder Threema Work nutzen.</string>
+    <string name="new_wizard_anonymous_confirm">Sie haben weder eine Handynummer noch eine E-Mail-Adresse zur Verknüpfung angegeben. Sie werden deshalb nicht automatisch in Kontaktlisten aufgeführt. Möchten Sie Threema Blue wirklich komplett anonym nutzen?</string>
+    <string name="new_wizard_setup_threema">Threema Blue einrichten</string>
+    <string name="new_wizard_welcome">Willkommen bei Threema Blue</string>
+    <string name="new_wizard_info_fingerprint">Durch die Bewegung des Fingers erzeugen Sie Zufallsdaten (sogenannte Entropie),
 		die für die Erzeugung des Schlüsselpaars genutzt werden. Dieses Schlüsselpaar wird mit Ihrer neuen Threema-ID verbunden. Es besteht aus einem
 		<b>öffentlichen Schlüssel</b>, der an Ihre Kontakte verteilt wird und einem <b>privaten Schlüssel</b>, der auf Ihrem Gerät verbleibt. Bei Ihren
 		Kontakten wird der öffentliche Schlüssel genutzt, um Nachrichten an Sie zu verschlüsseln. Nur der Inhaber des privaten Schlüssels und niemand
 		anderes kann die Nachrichten wieder entschlüsseln.
 	</string>
-	<string name="new_wizard_info_link">Wenn Sie Ihre eigene Handynummer und/oder E-Mail-Adresse angeben, kann Threema Blue Ihren Kontakten helfen, Sie
+    <string name="new_wizard_info_link">Wenn Sie Ihre eigene Handynummer und/oder E-Mail-Adresse angeben, kann Threema Blue Ihren Kontakten helfen, Sie
 		automatisch zu finden, wenn Sie in deren Adressbuch eingetragen sind. Die Angaben werden dazu in einwegverschlüsselter (gehashter) Form
 		auf unserem Server gespeichert. Sie können diesen Schritt auch einfach übespringen, wenn Sie Threema Blue anonym benutzen möchten.
 	</string>
-	<string name="threema_contact">Threema Work-Kontakt</string>
-	<string name="menu_about">Über Threema Blue</string>
-	<string name="directory_search">Im Verzeichnis suchen</string>
-	<string name="directory_title">Unternehmensverzeichnis</string>
-	<string name="directory_empty_view_text">Bitte geben Sie mindestens 3 Zeichen eines Namens ein, um mit der Suche im Unternehmensverzeichnis zu beginnen oder wählen Sie eine Kategorie, indem Sie auf das Filter-Symbol tippen.</string>
+    <string name="threema_contact">Threema Work-Kontakt</string>
+    <string name="menu_about">Über Threema Blue</string>
+    <string name="directory_search">Im Verzeichnis suchen</string>
+    <string name="directory_title">Unternehmensverzeichnis</string>
+    <string name="directory_empty_view_text">Bitte geben Sie mindestens 3 Zeichen eines Namens ein, um mit der Suche im Unternehmensverzeichnis zu beginnen oder wählen Sie eine Kategorie, indem Sie auf das Filter-Symbol tippen.</string>
 </resources>
 

+ 8 - 9
app/src/blue/res/values/firebase_messaging.xml

@@ -1,15 +1,14 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
+<?xml version="1.0" encoding="utf-8"?><!--
   ~ Copyright (c) 2020 Threema GmbH
   ~ All rights reserved.
   -->
 <!-- TODO: move to build.gradle -->
 <resources>
-	<string name="google_app_id" translatable="false">1:480681303521:android:6ec12987090e0e4f9fc6a0</string>
-	<string name="gcm_defaultSenderId" translatable="false">480681303521</string>
-	<string name="default_web_client_id" translatable="false">480681303521-dhlrmu6jot4ho92k37hoi8c8db9690no.apps.googleusercontent.com</string>
-	<string name="firebase_database_url" translatable="false">https://threema-red.firebaseio.com</string>
-	<string name="google_api_key" translatable="false">AIzaSyCEZ9OiIrh6zgz6RTQmk7zbEm_-8_vRj2U</string>
-	<string name="google_crash_reporting_api_key" translatable="false">AIzaSyCEZ9OiIrh6zgz6RTQmk7zbEm_-8_vRj2U</string>
-	<string name="project_id" translatable="false">threema-red</string>
+    <string name="google_app_id" translatable="false">1:480681303521:android:6ec12987090e0e4f9fc6a0</string>
+    <string name="gcm_defaultSenderId" translatable="false">480681303521</string>
+    <string name="default_web_client_id" translatable="false">480681303521-dhlrmu6jot4ho92k37hoi8c8db9690no.apps.googleusercontent.com</string>
+    <string name="firebase_database_url" translatable="false">https://threema-red.firebaseio.com</string>
+    <string name="google_api_key" translatable="false">AIzaSyCEZ9OiIrh6zgz6RTQmk7zbEm_-8_vRj2U</string>
+    <string name="google_crash_reporting_api_key" translatable="false">AIzaSyCEZ9OiIrh6zgz6RTQmk7zbEm_-8_vRj2U</string>
+    <string name="project_id" translatable="false">threema-red</string>
 </resources>

+ 20 - 20
app/src/blue/res/values/strings.xml

@@ -1,31 +1,31 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-	<string name="about_title">Threema Blue for Android</string>
-	<string name="enter_serial_body">Please enter the Threema Work credentials provided by your company.</string>
-	<string name="serial_required_want_exit">License is invalid. Would you like to try again or quit Threema?</string>
-	<string name="checking_serial">Checking credentials</string>
-	<string name="username_hint">Username</string>
-	<string name="new_wizard_works_like_phone_number">Your Threema ID works just like a phone number.\nYour contacts can reach you through this ID.</string>
-	<string name="new_wizard_nickname_explain">Your contacts will see your nickname in their notifications.</string>
-	<string name="new_wizard_help_your_friends_find_you">Help your contacts find you!</string>
-	<string name="new_wizard_find_friends">Find your contacts on Threema Blue</string>
-	<string name="new_wizard_sync_contacts_explain">Switch on to see which of your contacts is already using Threema or Threema Work.</string>
-	<string name="new_wizard_anonymous_confirm">You have entered neither a mobile number nor an email address to link to your Threema ID. You will not appear on contact lists. Do you really want to use Threema Blue anonymously?</string>
-	<string name="new_wizard_setup_threema">Set up Threema Blue</string>
-	<string name="new_wizard_welcome">Welcome to Threema Blue!</string>
-	<string name="new_wizard_info_fingerprint">By moving your finger, you create random data (called entropy) that is used to generate
+    <string name="about_title">Threema Blue for Android</string>
+    <string name="enter_serial_body">Please enter the Threema Work credentials provided by your company.</string>
+    <string name="serial_required_want_exit">License is invalid. Would you like to try again or quit Threema?</string>
+    <string name="checking_serial">Checking credentials</string>
+    <string name="username_hint">Username</string>
+    <string name="new_wizard_works_like_phone_number">Your Threema ID works just like a phone number.\nYour contacts can reach you through this ID.</string>
+    <string name="new_wizard_nickname_explain">Your contacts will see your nickname in their notifications.</string>
+    <string name="new_wizard_help_your_friends_find_you">Help your contacts find you!</string>
+    <string name="new_wizard_find_friends">Find your contacts on Threema Blue</string>
+    <string name="new_wizard_sync_contacts_explain">Switch on to see which of your contacts is already using Threema or Threema Work.</string>
+    <string name="new_wizard_anonymous_confirm">You have entered neither a mobile number nor an email address to link to your Threema ID. You will not appear on contact lists. Do you really want to use Threema Blue anonymously?</string>
+    <string name="new_wizard_setup_threema">Set up Threema Blue</string>
+    <string name="new_wizard_welcome">Welcome to Threema Blue!</string>
+    <string name="new_wizard_info_fingerprint">By moving your finger, you create random data (called entropy) that is used to generate
 		the keypair associated with your new unique Threema ID. The keypair consists of a <b>public key</b> that is distributed to
 		your contacts and a <b>private key</b> that is safely stored on your phone. Your contacts will encrypt messages to you with
 		your public key. Only the owner of the private key and nobody else is able to decrypt these messages.
 	</string>
-	<string name="new_wizard_info_link">By providing your phone number and email address, Threema Blue can help your contacts
+    <string name="new_wizard_info_link">By providing your phone number and email address, Threema Blue can help your contacts
 		find you automatically if they have you in their phone’s address book. The data will be stored in a
 		one-way encrypted (hashed) form on our server. You can simply skip this step, if you would like to use Threema Blue
 		anonymously.
 	</string>
-	<string name="threema_contact">Threema Work Contact</string>
-	<string name="menu_about">About Threema Blue</string>
-	<string name="directory_search">Search in the directory</string>
-	<string name="directory_title">Company directory</string>
-	<string name="directory_empty_view_text">Please enter at least 3 characters of a name to begin searching in your company\'s directory or select a category by tapping on the filter icon.</string>
+    <string name="threema_contact">Threema Work Contact</string>
+    <string name="menu_about">About Threema Blue</string>
+    <string name="directory_search">Search in the directory</string>
+    <string name="directory_title">Company directory</string>
+    <string name="directory_empty_view_text">Please enter at least 3 characters of a name to begin searching in your company\'s directory or select a category by tapping on the filter icon.</string>
 </resources>

+ 10 - 10
app/src/blue/res/xml/contacts.xml

@@ -2,14 +2,14 @@
 
 <ContactsSource xmlns:android="http://schemas.android.com/apk/res/android">
     <ContactsDataKind
-			android:icon="@mipmap/ic_launcher"
-			android:mimeType="vnd.android.cursor.item/vnd.ch.threema.app.red.profile"
-			android:summaryColumn="data2"
-			android:detailColumn="data3"
-			android:detailSocialSummary="true"/>
-	<ContactsDataKind
-			android:icon="@mipmap/ic_launcher"
-			android:mimeType="vnd.android.cursor.item/vnd.ch.threema.app.red.call"
-			android:summaryColumn="data2"
-			android:detailColumn="data3"/>
+        android:icon="@mipmap/ic_launcher"
+        android:mimeType="vnd.android.cursor.item/vnd.ch.threema.app.red.profile"
+        android:summaryColumn="data2"
+        android:detailColumn="data3"
+        android:detailSocialSummary="true" />
+    <ContactsDataKind
+        android:icon="@mipmap/ic_launcher"
+        android:mimeType="vnd.android.cursor.item/vnd.ch.threema.app.red.call"
+        android:summaryColumn="data2"
+        android:detailColumn="data3" />
 </ContactsSource>

+ 9 - 3
app/src/blue/res/xml/file_paths.xml

@@ -1,6 +1,12 @@
 <?xml version="1.0" encoding="utf-8"?>
 <paths xmlns:android="http://schemas.android.com/apk/res/android">
-	<files-path name="tmp_files" path="tmp/"/>
-	<cache-path name="cache_files" path="/"/>
-	<external-path name="ext_files" path="ThreemaRed/"/>
+    <files-path
+        name="tmp_files"
+        path="tmp/" />
+    <cache-path
+        name="cache_files"
+        path="/" />
+    <external-path
+        name="ext_files"
+        path="ThreemaRed/" />
 </paths>

+ 3 - 3
app/src/foss_based/java/ch/threema/app/activities/VoiceActionActivity.java

@@ -25,7 +25,7 @@ import android.app.Activity;
 
 public class VoiceActionActivity extends Activity {
 
-	public VoiceActionActivity() {
-		// stub, no voice assistant api in hms build
-	}
+    public VoiceActionActivity() {
+        // stub, no voice assistant api in hms build
+    }
 }

+ 5 - 4
app/src/foss_based/java/ch/threema/app/licensing/StoreLicenseCheck.java

@@ -28,9 +28,10 @@ import ch.threema.app.services.UserService;
 
 public class StoreLicenseCheck implements CheckLicenseRoutine.StoreLicenseChecker {
 
-	private StoreLicenseCheck() {}
+    private StoreLicenseCheck() {
+    }
 
-	public static void checkLicense(Context context, UserService userService) {
-		// stub, no platform store license check in foss based builds
-	}
+    public static void checkLicense(Context context, UserService userService) {
+        // stub, no platform store license check in foss based builds
+    }
 }

+ 9 - 9
app/src/foss_based/java/ch/threema/app/push/PushRegistrationWorker.java

@@ -29,14 +29,14 @@ import androidx.work.WorkerParameters;
 
 public class PushRegistrationWorker extends Worker {
 
-	public PushRegistrationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
-		super(context, workerParams);
-	}
+    public PushRegistrationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
+        super(context, workerParams);
+    }
 
-	@NonNull
-	@Override
-	public Result doWork() {
-		// stub, no push services in foss build
-		return Result.success();
-	}
+    @NonNull
+    @Override
+    public Result doWork() {
+        // stub, no push services in foss build
+        return Result.success();
+    }
 }

+ 15 - 15
app/src/foss_based/java/ch/threema/app/push/PushService.java

@@ -27,23 +27,23 @@ import org.slf4j.Logger;
 
 public class PushService {
 
-	public static void deleteToken(Context context) {
-		// stub
-	}
+    public static void deleteToken(Context context) {
+        // stub
+    }
 
-	public void onMessageReceived() {
-		// stub
-	}
+    public void onMessageReceived() {
+        // stub
+    }
 
-	public static boolean hmsServicesInstalled(Context context) {
-		return false;
-	}
+    public static boolean hmsServicesInstalled(Context context) {
+        return false;
+    }
 
-	public static boolean playServicesInstalled(Context context) {
-		return false;
-	}
+    public static boolean playServicesInstalled(Context context) {
+        return false;
+    }
 
-	public static boolean servicesInstalled(Context context) {
-		return false;
-	}
+    public static boolean servicesInstalled(Context context) {
+        return false;
+    }
 }

+ 8 - 8
app/src/foss_based/java/ch/threema/app/services/VoiceActionService.java

@@ -28,13 +28,13 @@ import android.os.IBinder;
 import androidx.annotation.Nullable;
 
 public class VoiceActionService extends Service {
-	public VoiceActionService() {
-		// stub, no voice assistant api in hms build
-	}
+    public VoiceActionService() {
+        // stub, no voice assistant api in hms build
+    }
 
-	@Nullable
-	@Override
-	public IBinder onBind(Intent intent) {
-		return null;
-	}
+    @Nullable
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
 }

+ 4 - 4
app/src/google_services_based/java/ch/threema/app/activities/VoiceActionActivity.java

@@ -27,8 +27,8 @@ import com.google.android.search.verification.client.SearchActionVerificationCli
 import ch.threema.app.services.VoiceActionService;
 
 public class VoiceActionActivity extends SearchActionVerificationClientActivity {
-	@Override
-	public Class<? extends SearchActionVerificationClientService> getServiceClass() {
-		return VoiceActionService.class;
-	}
+    @Override
+    public Class<? extends SearchActionVerificationClientService> getServiceClass() {
+        return VoiceActionService.class;
+    }
 }

+ 45 - 45
app/src/google_services_based/java/ch/threema/app/licensing/StoreLicenseCheck.java

@@ -35,54 +35,54 @@ import ch.threema.app.services.UserService;
 import ch.threema.base.utils.LoggingUtil;
 
 public class StoreLicenseCheck implements CheckLicenseRoutine.StoreLicenseChecker {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("StoreLicenseCheck");
+    private static final Logger logger = LoggingUtil.getThreemaLogger("StoreLicenseCheck");
 
-	private static final String LICENSE_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJArbOQT3Vi2KUEbyk+xq+DSsowwIYoudh3miXC7DmR6SVL6ji7XG8C+hmtR6t+Ytar64z87xgTPiEPiuyyg6/fp8ALRLAjM2FmZadSS4hSpvmJKb2ViFyUmcCJ8MoZ2QPxA+SVGZFdwIwwXdHPx2xUQw6ftyx0EF0hvF4nwHLvq89p03QtiPnIb0A3MOEXsq88xu2xAUge/BTvRWo0gWTtIJhTdZXY2CSib5d/G45xca0DKgOECAaMxVbFhE5jSyS+qZvUN4tABgDKBiEPuuzBBaHVt/m7MQoqoM6kcNrozACmIx6UdwWbkK3Isa9Xo9g3Yy6oc9Mp/9iKXwco4vwIDAQAB";
+    private static final String LICENSE_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJArbOQT3Vi2KUEbyk+xq+DSsowwIYoudh3miXC7DmR6SVL6ji7XG8C+hmtR6t+Ytar64z87xgTPiEPiuyyg6/fp8ALRLAjM2FmZadSS4hSpvmJKb2ViFyUmcCJ8MoZ2QPxA+SVGZFdwIwwXdHPx2xUQw6ftyx0EF0hvF4nwHLvq89p03QtiPnIb0A3MOEXsq88xu2xAUge/BTvRWo0gWTtIJhTdZXY2CSib5d/G45xca0DKgOECAaMxVbFhE5jSyS+qZvUN4tABgDKBiEPuuzBBaHVt/m7MQoqoM6kcNrozACmIx6UdwWbkK3Isa9Xo9g3Yy6oc9Mp/9iKXwco4vwIDAQAB";
 
-	private StoreLicenseCheck() {}
+    private StoreLicenseCheck() {
+    }
 
-	public static void checkLicense(Context context, UserService userService) {
-		logger.debug("Checking LVL licence");
-		final ThreemaLicensePolicy policy = new ThreemaLicensePolicy();
-		LicenseCheckerCallback callback = new LicenseCheckerCallback() {
-			@Override
-			public void allow(int reason) {
-				logger.debug("LVL License OK");
-				userService.setPolicyResponse(
-					policy.getLastResponseData().responseData,
-					policy.getLastResponseData().signature,
-					0
-				);
-			}
+    public static void checkLicense(Context context, UserService userService) {
+        logger.debug("Checking LVL licence");
+        final ThreemaLicensePolicy policy = new ThreemaLicensePolicy();
+        LicenseCheckerCallback callback = new LicenseCheckerCallback() {
+            @Override
+            public void allow(int reason) {
+                logger.debug("LVL License OK");
+                userService.setPolicyResponse(
+                    policy.getLastResponseData().responseData,
+                    policy.getLastResponseData().signature,
+                    0
+                );
+            }
 
-			@Override
-			public void dontAllow(int reason) {
-				// 561 == not licensed
-				// 291 == no connection
-				logger.debug("LVL License not allowed (code {})", reason);
-				userService.setPolicyResponse(
-					null,
-					null,
-					reason
-				);
-			}
+            @Override
+            public void dontAllow(int reason) {
+                // 561 == not licensed
+                // 291 == no connection
+                logger.debug("LVL License not allowed (code {})", reason);
+                userService.setPolicyResponse(
+                    null,
+                    null,
+                    reason
+                );
+            }
 
-			@Override
-			public void applicationError(int errorCode) {
-				logger.debug("LVL License check failed errorCode: {}", errorCode);
-				userService.setPolicyResponse(
-					null,
-					null,
-					errorCode
-				);
-			}
-		};
-		LicenseChecker licenseChecker = new LicenseChecker(context, policy, LICENSE_PUBLIC_KEY);
-		try {
-			licenseChecker.checkAccess(callback);
-		}
-		catch (ReceiverCallNotAllowedException x) {
-			logger.error("LVL: Receiver call not allowed", x);
-		}
-	}
+            @Override
+            public void applicationError(int errorCode) {
+                logger.debug("LVL License check failed errorCode: {}", errorCode);
+                userService.setPolicyResponse(
+                    null,
+                    null,
+                    errorCode
+                );
+            }
+        };
+        LicenseChecker licenseChecker = new LicenseChecker(context, policy, LICENSE_PUBLIC_KEY);
+        try {
+            licenseChecker.checkAccess(callback);
+        } catch (ReceiverCallNotAllowedException x) {
+            logger.error("LVL: Receiver call not allowed", x);
+        }
+    }
 }

+ 22 - 22
app/src/google_services_based/java/ch/threema/app/licensing/ThreemaLicensePolicy.java

@@ -26,26 +26,26 @@ import com.google.android.vending.licensing.ResponseData;
 
 public class ThreemaLicensePolicy implements Policy {
 
-	private int lastResponse = Policy.RETRY;
-	private ResponseData lastResponseData;
-
-	@Override
-	public void processServerResponse(int response, ResponseData rawData) {
-		lastResponse = response;
-		lastResponseData = rawData;
-	}
-
-	@Override
-	public boolean allowAccess() {
-		return (lastResponse == Policy.LICENSED);
-	}
-
-	@Override
-	public String getLicensingUrl() {
-		return null;
-	}
-
-	public ResponseData getLastResponseData() {
-		return lastResponseData;
-	}
+    private int lastResponse = Policy.RETRY;
+    private ResponseData lastResponseData;
+
+    @Override
+    public void processServerResponse(int response, ResponseData rawData) {
+        lastResponse = response;
+        lastResponseData = rawData;
+    }
+
+    @Override
+    public boolean allowAccess() {
+        return (lastResponse == Policy.LICENSED);
+    }
+
+    @Override
+    public String getLicensingUrl() {
+        return null;
+    }
+
+    public ResponseData getLastResponseData() {
+        return lastResponseData;
+    }
 }

+ 51 - 51
app/src/google_services_based/java/ch/threema/app/push/PushRegistrationWorker.java

@@ -39,61 +39,61 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushRegistrationWorker extends Worker {
-	private final Logger logger = LoggingUtil.getThreemaLogger("PushRegistrationWorker");
+    private final Logger logger = LoggingUtil.getThreemaLogger("PushRegistrationWorker");
 
-	private final Context appContext;
+    private final Context appContext;
 
-	/**
-	 * Constructor for the PushRegistrationWorker.
-	 *
-	 * Note: This constructor is called by the WorkManager, so don't add additional parameters!
-	 */
-	public PushRegistrationWorker(@NonNull Context appContext, @NonNull WorkerParameters workerParams) {
-		super(appContext, workerParams);
-		this.appContext = appContext;
-	}
+    /**
+     * Constructor for the PushRegistrationWorker.
+     * <p>
+     * Note: This constructor is called by the WorkManager, so don't add additional parameters!
+     */
+    public PushRegistrationWorker(@NonNull Context appContext, @NonNull WorkerParameters workerParams) {
+        super(appContext, workerParams);
+        this.appContext = appContext;
+    }
 
-	@NonNull
-	@Override
-	public Result doWork() {
-		Data workerFlags = getInputData();
-		final boolean clearToken = workerFlags.getBoolean(PushService.EXTRA_CLEAR_TOKEN, false);
-		final boolean withCallback = workerFlags.getBoolean(PushService.EXTRA_WITH_CALLBACK, false);
-		logger.debug("doWork FCM registration clear {} withCallback {}", clearToken, withCallback);
+    @NonNull
+    @Override
+    public Result doWork() {
+        Data workerFlags = getInputData();
+        final boolean clearToken = workerFlags.getBoolean(PushService.EXTRA_CLEAR_TOKEN, false);
+        final boolean withCallback = workerFlags.getBoolean(PushService.EXTRA_WITH_CALLBACK, false);
+        logger.debug("doWork FCM registration clear {} withCallback {}", clearToken, withCallback);
 
-		FirebaseApp.initializeApp(appContext);
+        FirebaseApp.initializeApp(appContext);
 
-		if (clearToken) {
-			String error = PushService.deleteToken(appContext);
-			if (withCallback) {
-				PushUtil.signalRegistrationFinished(error, true);
-			}
-		} else {
-			FirebaseMessaging.getInstance().getToken()
-				.addOnCompleteListener(task -> {
-					if (!task.isSuccessful()) {
-						logger.error("Unable to get token", task.getException());
-						if (withCallback) {
-							PushUtil.signalRegistrationFinished(task.getException() != null ? task.getException().getMessage() : "unknown", clearToken);
-						}
-						return;
-					}
+        if (clearToken) {
+            String error = PushService.deleteToken(appContext);
+            if (withCallback) {
+                PushUtil.signalRegistrationFinished(error, true);
+            }
+        } else {
+            FirebaseMessaging.getInstance().getToken()
+                .addOnCompleteListener(task -> {
+                    if (!task.isSuccessful()) {
+                        logger.error("Unable to get token", task.getException());
+                        if (withCallback) {
+                            PushUtil.signalRegistrationFinished(task.getException() != null ? task.getException().getMessage() : "unknown", clearToken);
+                        }
+                        return;
+                    }
 
-					String token = task.getResult();
-					logger.info("Received FCM registration token");
-					String error = null;
-					try {
-						PushUtil.sendTokenToServer(token, ProtocolDefines.PUSHTOKEN_TYPE_FCM);
-					} catch (ThreemaException e) {
-						logger.error("Exception", e);
-						error = e.getMessage();
-					}
-					if (withCallback) {
-						PushUtil.signalRegistrationFinished(error, clearToken);
-					}
-				});
-		}
-		// required by the Worker interface but is not used for any error handling in the push registration process
-		return Result.success();
-	}
+                    String token = task.getResult();
+                    logger.info("Received FCM registration token");
+                    String error = null;
+                    try {
+                        PushUtil.sendTokenToServer(token, ProtocolDefines.PUSHTOKEN_TYPE_FCM);
+                    } catch (ThreemaException e) {
+                        logger.error("Exception", e);
+                        error = e.getMessage();
+                    }
+                    if (withCallback) {
+                        PushUtil.signalRegistrationFinished(error, clearToken);
+                    }
+                });
+        }
+        // required by the Worker interface but is not used for any error handling in the push registration process
+        return Result.success();
+    }
 }

+ 91 - 90
app/src/google_services_based/java/ch/threema/app/push/PushService.java

@@ -47,94 +47,95 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushService extends FirebaseMessagingService {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("PushService");
-
-	public static final String EXTRA_CLEAR_TOKEN = "clear";
-	public static final String EXTRA_WITH_CALLBACK = "cb";
-
-	@Override
-	public void onNewToken(@NonNull String token) {
-		logger.info("New FCM token received");
-		try {
-			PushUtil.sendTokenToServer(token, ProtocolDefines.PUSHTOKEN_TYPE_FCM);
-		} catch (ThreemaException e) {
-			logger.error("onNewToken, could not send token to server ", e);
-		}
-	}
-
-	public static String deleteToken(Context context) {
-		try {
-			FirebaseMessaging.getInstance().deleteToken();
-			Tasks.await(FirebaseInstallations.getInstance().delete());
-			PushUtil.sendTokenToServer("", ProtocolDefines.PUSHTOKEN_TYPE_NONE);
-		} catch (ThreemaException | ExecutionException | InterruptedException e) {
-			logger.warn("Could not delete FCM token", e);
-			return e.getMessage();
-		}
-		return null;
-	}
-
-	@Override
-	public void onDeletedMessages() {
-		logger.info("Too many messages stored on the Firebase server. Messages have been dropped.");
-	}
-
-	@Override
-	public void onMessageSent(@NonNull String msgId) {
-		logger.info("onMessageSent called for message id: {}", msgId);
-	}
-
-	@Override
-	public void onSendError(@NonNull String msgId, @NonNull Exception exception) {
-		logger.info("onSendError called for message id: {} exception: {}", msgId, exception);
-	}
-
-	@Override
-	public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
-		logger.info("Handling incoming FCM intent.");
-
-		RuntimeUtil.runInWakelock(getApplicationContext(), DateUtils.MINUTE_IN_MILLIS * 10, "PushService", () -> processFcmMessage(remoteMessage));
-	}
-
-	private void processFcmMessage(RemoteMessage remoteMessage) {
-		logger.info("Received FCM message: {}", remoteMessage.getMessageId());
-
-		// Log message sent time
-		try {
-			Date sentDate = new Date(remoteMessage.getSentTime());
-
-			logger.info("*** Message sent     : " + sentDate.toString(), true);
-			logger.info("*** Message received : " + new Date().toString(), true);
-			logger.info("*** Original priority: " + remoteMessage.getOriginalPriority());
-			logger.info("*** Current priority: " + remoteMessage.getPriority());
-		} catch (Exception ignore) {
-		}
-
-		Map<String, String> data = remoteMessage.getData();
-		PushUtil.processRemoteMessage(data);
-	}
-
-	// following services check are handled here and not in ConfigUtils to minimize number of duplicating classes
-	/**
-	 * check for specific huawei services
-	 */
-	public static boolean hmsServicesInstalled(Context context) {
-		return false;
-	}
-
-	/**
-	 * check for specific google services
-	 */
-	public static boolean playServicesInstalled(Context context) {
-		GoogleApiAvailability apiAvailability = com.google.android.gms.common.GoogleApiAvailability.getInstance();
-		int resultCode = apiAvailability.isGooglePlayServicesAvailable(context);
-		return RuntimeUtil.isInTest() || (resultCode == ConnectionResult.SUCCESS);
-	}
-
-	/**
-	 * check for available push service
-	 */
-	public static boolean servicesInstalled(Context context) {
-		return playServicesInstalled(context) || hmsServicesInstalled(context);
-	}
+    private static final Logger logger = LoggingUtil.getThreemaLogger("PushService");
+
+    public static final String EXTRA_CLEAR_TOKEN = "clear";
+    public static final String EXTRA_WITH_CALLBACK = "cb";
+
+    @Override
+    public void onNewToken(@NonNull String token) {
+        logger.info("New FCM token received");
+        try {
+            PushUtil.sendTokenToServer(token, ProtocolDefines.PUSHTOKEN_TYPE_FCM);
+        } catch (ThreemaException e) {
+            logger.error("onNewToken, could not send token to server ", e);
+        }
+    }
+
+    public static String deleteToken(Context context) {
+        try {
+            FirebaseMessaging.getInstance().deleteToken();
+            Tasks.await(FirebaseInstallations.getInstance().delete());
+            PushUtil.sendTokenToServer("", ProtocolDefines.PUSHTOKEN_TYPE_NONE);
+        } catch (ThreemaException | ExecutionException | InterruptedException e) {
+            logger.warn("Could not delete FCM token", e);
+            return e.getMessage();
+        }
+        return null;
+    }
+
+    @Override
+    public void onDeletedMessages() {
+        logger.info("Too many messages stored on the Firebase server. Messages have been dropped.");
+    }
+
+    @Override
+    public void onMessageSent(@NonNull String msgId) {
+        logger.info("onMessageSent called for message id: {}", msgId);
+    }
+
+    @Override
+    public void onSendError(@NonNull String msgId, @NonNull Exception exception) {
+        logger.info("onSendError called for message id: {} exception: {}", msgId, exception);
+    }
+
+    @Override
+    public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
+        logger.info("Handling incoming FCM intent.");
+
+        RuntimeUtil.runInWakelock(getApplicationContext(), DateUtils.MINUTE_IN_MILLIS * 10, "PushService", () -> processFcmMessage(remoteMessage));
+    }
+
+    private void processFcmMessage(RemoteMessage remoteMessage) {
+        logger.info("Received FCM message: {}", remoteMessage.getMessageId());
+
+        // Log message sent time
+        try {
+            Date sentDate = new Date(remoteMessage.getSentTime());
+
+            logger.info("*** Message sent     : " + sentDate.toString(), true);
+            logger.info("*** Message received : " + new Date().toString(), true);
+            logger.info("*** Original priority: " + remoteMessage.getOriginalPriority());
+            logger.info("*** Current priority: " + remoteMessage.getPriority());
+        } catch (Exception ignore) {
+        }
+
+        Map<String, String> data = remoteMessage.getData();
+        PushUtil.processRemoteMessage(data);
+    }
+
+    // following services check are handled here and not in ConfigUtils to minimize number of duplicating classes
+
+    /**
+     * check for specific huawei services
+     */
+    public static boolean hmsServicesInstalled(Context context) {
+        return false;
+    }
+
+    /**
+     * check for specific google services
+     */
+    public static boolean playServicesInstalled(Context context) {
+        GoogleApiAvailability apiAvailability = com.google.android.gms.common.GoogleApiAvailability.getInstance();
+        int resultCode = apiAvailability.isGooglePlayServicesAvailable(context);
+        return RuntimeUtil.isInTest() || (resultCode == ConnectionResult.SUCCESS);
+    }
+
+    /**
+     * check for available push service
+     */
+    public static boolean servicesInstalled(Context context) {
+        return playServicesInstalled(context) || hmsServicesInstalled(context);
+    }
 }

+ 175 - 175
app/src/google_services_based/java/ch/threema/app/services/VoiceActionService.java

@@ -53,179 +53,179 @@ import ch.threema.storage.models.ContactModel;
 import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING;
 
 public class VoiceActionService extends SearchActionVerificationClientService {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("VoiceActionService");
-	private static final String TAG = "VoiceActionService";
-
-	private MessageService messageService;
-	private LifetimeService lifetimeService;
-	private NotificationService notificationService;
-	private ContactService contactService;
-	private LockAppService lockAppService;
-
-	private static final String CHANNEL_ID_GOOGLE_ASSISTANT = "Voice_Actions";
-	private static final int NOTIFICATION_ID = 10000;
-
-	private static final int FG_SERVICE_TYPE =
-		Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
-			? FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
-			: 0;
-
-	@Override
-	public void performAction(Intent intent, boolean isVerified, Bundle options) {
-		logger.debug("performAction: intent - {}, isVerified - {}", intent, isVerified);
-
-		this.instantiate();
-
-		if (!lockAppService.isLocked()) {
-			doPerformAction(intent, isVerified);
-		} else {
-			RuntimeUtil.runOnUiThread(() -> Toast.makeText(VoiceActionService.this, R.string.pin_locked_cannot_send, Toast.LENGTH_LONG).show());
-		}
-	}
-
-	@RequiresApi(Build.VERSION_CODES.O)
-	@Override
-	protected void postForegroundNotification() {
-		this.createChannel();
-		NotificationCompat.Builder notificationBuilder =
-				new NotificationCompat.Builder(this.getApplicationContext(), CHANNEL_ID_GOOGLE_ASSISTANT)
-						.setGroup(CHANNEL_ID_GOOGLE_ASSISTANT)
-						.setContentTitle(this.getApplicationContext().getResources().getString(R.string.voice_action_title))
-						.setContentText(this.getApplicationContext().getResources().getString(R.string.voice_action_body))
-						.setSmallIcon(R.drawable.ic_notification_small)
-						.setPriority(NotificationCompat.PRIORITY_MIN)
-						.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
-						.setLocalOnly(true);
-		ServiceCompat.startForeground(
-			this,
-			NOTIFICATION_ID,
-			notificationBuilder.build(),
-			FG_SERVICE_TYPE);
-	}
-
-	@RequiresApi(Build.VERSION_CODES.O)
-	private void createChannel() {
-		NotificationChannel channel = new NotificationChannel(CHANNEL_ID_GOOGLE_ASSISTANT, this.getApplicationContext().getResources().getString(R.string.voice_action_title), NotificationManager.IMPORTANCE_LOW);
-		channel.enableVibration(false);
-		channel.enableLights(false);
-		channel.setShowBadge(false);
-
-		NotificationManager notificationManager = this.getApplicationContext().getSystemService(NotificationManager.class);
-		if (notificationManager != null) {
-			notificationManager.createNotificationChannel(channel);
-		}
-	}
-
-	private boolean sendAudioMessage(final MessageReceiver messageReceiver, Intent intent, String caption) {
-		ClipData clipData;
-		clipData = intent.getClipData();
-		if (clipData == null) {
-			return false;
-		}
-
-		ClipData.Item item = clipData.getItemAt(0);
-		if (item == null) {
-			return false;
-		}
-
-		Uri uri = item.getUri();
-		if (uri == null) {
-			return false;
-		}
-
-		logger.debug("Audio uri: " + uri);
-
-		MediaItem mediaItem = new MediaItem(uri, MediaItem.TYPE_VOICEMESSAGE);
-		mediaItem.setCaption(caption);
-
-		messageService.sendMediaAsync(Collections.singletonList(mediaItem), Collections.singletonList(messageReceiver), new MessageServiceImpl.SendResultListener() {
-			@Override
-			public void onError(String errorMessage) {
-				logger.debug("Error sending audio message: " + errorMessage);
-				lifetimeService.releaseConnectionLinger(TAG, PollingHelper.CONNECTION_LINGER);
-			}
-
-			@Override
-			public void onCompleted() {
-				logger.debug("Audio message sent");
-				messageService.markConversationAsRead(messageReceiver, notificationService);
-				lifetimeService.releaseConnectionLinger(TAG, PollingHelper.CONNECTION_LINGER);
-			}
-		});
-		return true;
-	}
-
-	public void doPerformAction(Intent intent, boolean isVerified) {
-
-		if (isVerified) {
-			Bundle bundle = intent.getExtras();
-
-			if (bundle != null) {
-				String identity = bundle.getString("com.google.android.voicesearch.extra.RECIPIENT_CONTACT_CHAT_ID");
-				String message = bundle.getString("android.intent.extra.TEXT");
-
-				if (!TestUtil.isEmptyOrNull(identity, message)) {
-					ContactModel contactModel = contactService.getByIdentity(identity);
-
-					if (contactModel != null) {
-						final MessageReceiver messageReceiver = contactService.createReceiver(contactModel);
-
-						if (messageReceiver != null) {
-							lifetimeService.acquireConnection(TAG);
-
-							if (!sendAudioMessage(messageReceiver, intent, message)) {
-								try {
-									messageService.sendText(message, messageReceiver);
-									messageService.markConversationAsRead(messageReceiver, notificationService);
-
-									logger.debug("Message sent to: " + identity);
-								} catch (Exception e) {
-									logger.error("Exception", e);
-								}
-
-								lifetimeService.releaseConnectionLinger(TAG, PollingHelper.CONNECTION_LINGER);
-							}
-						}
-					}
-				}
-			}
-		}
-	}
-
-/*	@Override
-	public boolean isTestingMode() {
-		return true;
-	}
-*/
-	final protected boolean requiredInstances() {
-		if (!this.checkInstances()) {
-			this.instantiate();
-		}
-		return this.checkInstances();
-	}
-
-	protected boolean checkInstances() {
-		return TestUtil.required(
-				this.messageService,
-				this.lifetimeService,
-				this.notificationService,
-				this.contactService,
-				this.lockAppService
-		);
-	}
-
-	protected void instantiate() {
-		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-		if (serviceManager != null) {
-			try {
-				this.messageService = serviceManager.getMessageService();
-				this.lifetimeService = serviceManager.getLifetimeService();
-				this.notificationService = serviceManager.getNotificationService();
-				this.contactService = serviceManager.getContactService();
-				this.lockAppService = serviceManager.getLockAppService();
-			} catch (Exception e) {
-				logger.error("Exception", e);
-			}
-		}
-	}
+    private static final Logger logger = LoggingUtil.getThreemaLogger("VoiceActionService");
+    private static final String TAG = "VoiceActionService";
+
+    private MessageService messageService;
+    private LifetimeService lifetimeService;
+    private NotificationService notificationService;
+    private ContactService contactService;
+    private LockAppService lockAppService;
+
+    private static final String CHANNEL_ID_GOOGLE_ASSISTANT = "Voice_Actions";
+    private static final int NOTIFICATION_ID = 10000;
+
+    private static final int FG_SERVICE_TYPE =
+        Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+            ? FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
+            : 0;
+
+    @Override
+    public void performAction(Intent intent, boolean isVerified, Bundle options) {
+        logger.debug("performAction: intent - {}, isVerified - {}", intent, isVerified);
+
+        this.instantiate();
+
+        if (!lockAppService.isLocked()) {
+            doPerformAction(intent, isVerified);
+        } else {
+            RuntimeUtil.runOnUiThread(() -> Toast.makeText(VoiceActionService.this, R.string.pin_locked_cannot_send, Toast.LENGTH_LONG).show());
+        }
+    }
+
+    @RequiresApi(Build.VERSION_CODES.O)
+    @Override
+    protected void postForegroundNotification() {
+        this.createChannel();
+        NotificationCompat.Builder notificationBuilder =
+            new NotificationCompat.Builder(this.getApplicationContext(), CHANNEL_ID_GOOGLE_ASSISTANT)
+                .setGroup(CHANNEL_ID_GOOGLE_ASSISTANT)
+                .setContentTitle(this.getApplicationContext().getResources().getString(R.string.voice_action_title))
+                .setContentText(this.getApplicationContext().getResources().getString(R.string.voice_action_body))
+                .setSmallIcon(R.drawable.ic_notification_small)
+                .setPriority(NotificationCompat.PRIORITY_MIN)
+                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+                .setLocalOnly(true);
+        ServiceCompat.startForeground(
+            this,
+            NOTIFICATION_ID,
+            notificationBuilder.build(),
+            FG_SERVICE_TYPE);
+    }
+
+    @RequiresApi(Build.VERSION_CODES.O)
+    private void createChannel() {
+        NotificationChannel channel = new NotificationChannel(CHANNEL_ID_GOOGLE_ASSISTANT, this.getApplicationContext().getResources().getString(R.string.voice_action_title), NotificationManager.IMPORTANCE_LOW);
+        channel.enableVibration(false);
+        channel.enableLights(false);
+        channel.setShowBadge(false);
+
+        NotificationManager notificationManager = this.getApplicationContext().getSystemService(NotificationManager.class);
+        if (notificationManager != null) {
+            notificationManager.createNotificationChannel(channel);
+        }
+    }
+
+    private boolean sendAudioMessage(final MessageReceiver messageReceiver, Intent intent, String caption) {
+        ClipData clipData;
+        clipData = intent.getClipData();
+        if (clipData == null) {
+            return false;
+        }
+
+        ClipData.Item item = clipData.getItemAt(0);
+        if (item == null) {
+            return false;
+        }
+
+        Uri uri = item.getUri();
+        if (uri == null) {
+            return false;
+        }
+
+        logger.debug("Audio uri: " + uri);
+
+        MediaItem mediaItem = new MediaItem(uri, MediaItem.TYPE_VOICEMESSAGE);
+        mediaItem.setCaption(caption);
+
+        messageService.sendMediaAsync(Collections.singletonList(mediaItem), Collections.singletonList(messageReceiver), new MessageServiceImpl.SendResultListener() {
+            @Override
+            public void onError(String errorMessage) {
+                logger.debug("Error sending audio message: " + errorMessage);
+                lifetimeService.releaseConnectionLinger(TAG, PollingHelper.CONNECTION_LINGER);
+            }
+
+            @Override
+            public void onCompleted() {
+                logger.debug("Audio message sent");
+                messageService.markConversationAsRead(messageReceiver, notificationService);
+                lifetimeService.releaseConnectionLinger(TAG, PollingHelper.CONNECTION_LINGER);
+            }
+        });
+        return true;
+    }
+
+    public void doPerformAction(Intent intent, boolean isVerified) {
+
+        if (isVerified) {
+            Bundle bundle = intent.getExtras();
+
+            if (bundle != null) {
+                String identity = bundle.getString("com.google.android.voicesearch.extra.RECIPIENT_CONTACT_CHAT_ID");
+                String message = bundle.getString("android.intent.extra.TEXT");
+
+                if (!TestUtil.isEmptyOrNull(identity, message)) {
+                    ContactModel contactModel = contactService.getByIdentity(identity);
+
+                    if (contactModel != null) {
+                        final MessageReceiver messageReceiver = contactService.createReceiver(contactModel);
+
+                        if (messageReceiver != null) {
+                            lifetimeService.acquireConnection(TAG);
+
+                            if (!sendAudioMessage(messageReceiver, intent, message)) {
+                                try {
+                                    messageService.sendText(message, messageReceiver);
+                                    messageService.markConversationAsRead(messageReceiver, notificationService);
+
+                                    logger.debug("Message sent to: " + identity);
+                                } catch (Exception e) {
+                                    logger.error("Exception", e);
+                                }
+
+                                lifetimeService.releaseConnectionLinger(TAG, PollingHelper.CONNECTION_LINGER);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /*	@Override
+        public boolean isTestingMode() {
+            return true;
+        }
+    */
+    final protected boolean requiredInstances() {
+        if (!this.checkInstances()) {
+            this.instantiate();
+        }
+        return this.checkInstances();
+    }
+
+    protected boolean checkInstances() {
+        return TestUtil.required(
+            this.messageService,
+            this.lifetimeService,
+            this.notificationService,
+            this.contactService,
+            this.lockAppService
+        );
+    }
+
+    protected void instantiate() {
+        ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+        if (serviceManager != null) {
+            try {
+                this.messageService = serviceManager.getMessageService();
+                this.lifetimeService = serviceManager.getLifetimeService();
+                this.notificationService = serviceManager.getNotificationService();
+                this.contactService = serviceManager.getContactService();
+                this.lockAppService = serviceManager.getLockAppService();
+            } catch (Exception e) {
+                logger.error("Exception", e);
+            }
+        }
+    }
 }

+ 7 - 7
app/src/google_services_based/java/com/google/android/vending/licensing/AESObfuscator.java

@@ -61,17 +61,17 @@ public class AESObfuscator implements Obfuscator {
     private static final String KEYGEN_ALGORITHM = "PBEWITHSHAAND256BITAES-CBC-BC";
     private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
     private static final byte[] IV =
-        { 16, 74, 71, -80, 32, 101, -47, 72, 117, -14, 0, -29, 70, 65, -12, 74 };
+        {16, 74, 71, -80, 32, 101, -47, 72, 117, -14, 0, -29, 70, 65, -12, 74};
     private static final String header = "com.google.android.vending.licensing.AESObfuscator-1|";
 
     private Cipher mEncryptor;
     private Cipher mDecryptor;
 
     /**
-     * @param salt an array of random bytes to use for each (un)obfuscation
+     * @param salt          an array of random bytes to use for each (un)obfuscation
      * @param applicationId application identifier, e.g. the package name
-     * @param deviceId device identifier. Use as many sources as possible to
-     *    create this unique identifier.
+     * @param deviceId      device identifier. Use as many sources as possible to
+     *                      create this unique identifier.
      */
     public AESObfuscator(byte[] salt, String applicationId, String deviceId) {
         try {
@@ -112,12 +112,12 @@ public class AESObfuscator implements Obfuscator {
             String result = new String(mDecryptor.doFinal(Base64.decode(obfuscated)), UTF8);
             // Check for presence of header. This serves as a final integrity check, for cases
             // where the block size is correct during decryption.
-            int headerIndex = result.indexOf(header+key);
+            int headerIndex = result.indexOf(header + key);
             if (headerIndex != 0) {
                 throw new ValidationException("Header not found (invalid data or key)" + ":" +
-                        obfuscated);
+                    obfuscated);
             }
-            return result.substring(header.length()+key.length(), result.length());
+            return result.substring(header.length() + key.length(), result.length());
         } catch (Base64DecoderException e) {
             throw new ValidationException(e.getMessage() + ":" + obfuscated);
         } catch (IllegalBlockSizeException e) {

+ 90 - 87
app/src/google_services_based/java/com/google/android/vending/licensing/ILicenseResultListener.java

@@ -39,97 +39,100 @@
  * Original file: aidl/ILicenseResultListener.aidl
  */
 package com.google.android.vending.licensing;
+
 import java.lang.String;
+
 import android.os.RemoteException;
 import android.os.IBinder;
 import android.os.IInterface;
 import android.os.Binder;
 import android.os.Parcel;
-public interface ILicenseResultListener extends android.os.IInterface
-{
-/** Local-side IPC implementation contentViewStub class. */
-public static abstract class Stub extends android.os.Binder implements com.google.android.vending.licensing.ILicenseResultListener
-{
-private static final java.lang.String DESCRIPTOR = "com.android.vending.licensing.ILicenseResultListener";
-/** Construct the contentViewStub at attach it to the interface. */
-public Stub()
-{
-this.attachInterface(this, DESCRIPTOR);
-}
-/**
- * Cast an IBinder object into an ILicenseResultListener interface,
- * generating a proxy if needed.
- */
-public static com.google.android.vending.licensing.ILicenseResultListener asInterface(android.os.IBinder obj)
-{
-if ((obj==null)) {
-return null;
-}
-android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
-if (((iin!=null)&&(iin instanceof com.google.android.vending.licensing.ILicenseResultListener))) {
-return ((com.google.android.vending.licensing.ILicenseResultListener)iin);
-}
-return new com.google.android.vending.licensing.ILicenseResultListener.Stub.Proxy(obj);
-}
-public android.os.IBinder asBinder()
-{
-return this;
-}
-public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException
-{
-switch (code)
-{
-case INTERFACE_TRANSACTION:
-{
-reply.writeString(DESCRIPTOR);
-return true;
-}
-case TRANSACTION_verifyLicense:
-{
-data.enforceInterface(DESCRIPTOR);
-int _arg0;
-_arg0 = data.readInt();
-java.lang.String _arg1;
-_arg1 = data.readString();
-java.lang.String _arg2;
-_arg2 = data.readString();
-this.verifyLicense(_arg0, _arg1, _arg2);
-return true;
-}
-}
-return super.onTransact(code, data, reply, flags);
-}
-private static class Proxy implements com.google.android.vending.licensing.ILicenseResultListener
-{
-private android.os.IBinder mRemote;
-Proxy(android.os.IBinder remote)
-{
-mRemote = remote;
-}
-public android.os.IBinder asBinder()
-{
-return mRemote;
-}
-public java.lang.String getInterfaceDescriptor()
-{
-return DESCRIPTOR;
-}
-public void verifyLicense(int responseCode, java.lang.String signedData, java.lang.String signature) throws android.os.RemoteException
-{
-android.os.Parcel _data = android.os.Parcel.obtain();
-try {
-_data.writeInterfaceToken(DESCRIPTOR);
-_data.writeInt(responseCode);
-_data.writeString(signedData);
-_data.writeString(signature);
-mRemote.transact(Stub.TRANSACTION_verifyLicense, _data, null, IBinder.FLAG_ONEWAY);
-}
-finally {
-_data.recycle();
-}
-}
-}
-static final int TRANSACTION_verifyLicense = (IBinder.FIRST_CALL_TRANSACTION + 0);
-}
-public void verifyLicense(int responseCode, java.lang.String signedData, java.lang.String signature) throws android.os.RemoteException;
+
+public interface ILicenseResultListener extends android.os.IInterface {
+    /**
+     * Local-side IPC implementation contentViewStub class.
+     */
+    public static abstract class Stub extends android.os.Binder implements com.google.android.vending.licensing.ILicenseResultListener {
+        private static final java.lang.String DESCRIPTOR = "com.android.vending.licensing.ILicenseResultListener";
+
+        /**
+         * Construct the contentViewStub at attach it to the interface.
+         */
+        public Stub() {
+            this.attachInterface(this, DESCRIPTOR);
+        }
+
+        /**
+         * Cast an IBinder object into an ILicenseResultListener interface,
+         * generating a proxy if needed.
+         */
+        public static com.google.android.vending.licensing.ILicenseResultListener asInterface(android.os.IBinder obj) {
+            if ((obj == null)) {
+                return null;
+            }
+            android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
+            if (((iin != null) && (iin instanceof com.google.android.vending.licensing.ILicenseResultListener))) {
+                return ((com.google.android.vending.licensing.ILicenseResultListener) iin);
+            }
+            return new com.google.android.vending.licensing.ILicenseResultListener.Stub.Proxy(obj);
+        }
+
+        public android.os.IBinder asBinder() {
+            return this;
+        }
+
+        public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException {
+            switch (code) {
+                case INTERFACE_TRANSACTION: {
+                    reply.writeString(DESCRIPTOR);
+                    return true;
+                }
+                case TRANSACTION_verifyLicense: {
+                    data.enforceInterface(DESCRIPTOR);
+                    int _arg0;
+                    _arg0 = data.readInt();
+                    java.lang.String _arg1;
+                    _arg1 = data.readString();
+                    java.lang.String _arg2;
+                    _arg2 = data.readString();
+                    this.verifyLicense(_arg0, _arg1, _arg2);
+                    return true;
+                }
+            }
+            return super.onTransact(code, data, reply, flags);
+        }
+
+        private static class Proxy implements com.google.android.vending.licensing.ILicenseResultListener {
+            private android.os.IBinder mRemote;
+
+            Proxy(android.os.IBinder remote) {
+                mRemote = remote;
+            }
+
+            public android.os.IBinder asBinder() {
+                return mRemote;
+            }
+
+            public java.lang.String getInterfaceDescriptor() {
+                return DESCRIPTOR;
+            }
+
+            public void verifyLicense(int responseCode, java.lang.String signedData, java.lang.String signature) throws android.os.RemoteException {
+                android.os.Parcel _data = android.os.Parcel.obtain();
+                try {
+                    _data.writeInterfaceToken(DESCRIPTOR);
+                    _data.writeInt(responseCode);
+                    _data.writeString(signedData);
+                    _data.writeString(signature);
+                    mRemote.transact(Stub.TRANSACTION_verifyLicense, _data, null, IBinder.FLAG_ONEWAY);
+                } finally {
+                    _data.recycle();
+                }
+            }
+        }
+
+        static final int TRANSACTION_verifyLicense = (IBinder.FIRST_CALL_TRANSACTION + 0);
+    }
+
+    public void verifyLicense(int responseCode, java.lang.String signedData, java.lang.String signature) throws android.os.RemoteException;
 }

+ 90 - 87
app/src/google_services_based/java/com/google/android/vending/licensing/ILicensingService.java

@@ -39,97 +39,100 @@
  * Original file: aidl/ILicensingService.aidl
  */
 package com.google.android.vending.licensing;
+
 import java.lang.String;
+
 import android.os.RemoteException;
 import android.os.IBinder;
 import android.os.IInterface;
 import android.os.Binder;
 import android.os.Parcel;
-public interface ILicensingService extends android.os.IInterface
-{
-/** Local-side IPC implementation contentViewStub class. */
-public static abstract class Stub extends android.os.Binder implements com.google.android.vending.licensing.ILicensingService
-{
-private static final java.lang.String DESCRIPTOR = "com.android.vending.licensing.ILicensingService";
-/** Construct the contentViewStub at attach it to the interface. */
-public Stub()
-{
-this.attachInterface(this, DESCRIPTOR);
-}
-/**
- * Cast an IBinder object into an ILicensingService interface,
- * generating a proxy if needed.
- */
-public static com.google.android.vending.licensing.ILicensingService asInterface(android.os.IBinder obj)
-{
-if ((obj==null)) {
-return null;
-}
-android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
-if (((iin!=null)&&(iin instanceof com.google.android.vending.licensing.ILicensingService))) {
-return ((com.google.android.vending.licensing.ILicensingService)iin);
-}
-return new com.google.android.vending.licensing.ILicensingService.Stub.Proxy(obj);
-}
-public android.os.IBinder asBinder()
-{
-return this;
-}
-public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException
-{
-switch (code)
-{
-case INTERFACE_TRANSACTION:
-{
-reply.writeString(DESCRIPTOR);
-return true;
-}
-case TRANSACTION_checkLicense:
-{
-data.enforceInterface(DESCRIPTOR);
-long _arg0;
-_arg0 = data.readLong();
-java.lang.String _arg1;
-_arg1 = data.readString();
-com.google.android.vending.licensing.ILicenseResultListener _arg2;
-_arg2 = com.google.android.vending.licensing.ILicenseResultListener.Stub.asInterface(data.readStrongBinder());
-this.checkLicense(_arg0, _arg1, _arg2);
-return true;
-}
-}
-return super.onTransact(code, data, reply, flags);
-}
-private static class Proxy implements com.google.android.vending.licensing.ILicensingService
-{
-private android.os.IBinder mRemote;
-Proxy(android.os.IBinder remote)
-{
-mRemote = remote;
-}
-public android.os.IBinder asBinder()
-{
-return mRemote;
-}
-public java.lang.String getInterfaceDescriptor()
-{
-return DESCRIPTOR;
-}
-public void checkLicense(long nonce, java.lang.String packageName, com.google.android.vending.licensing.ILicenseResultListener listener) throws android.os.RemoteException
-{
-android.os.Parcel _data = android.os.Parcel.obtain();
-try {
-_data.writeInterfaceToken(DESCRIPTOR);
-_data.writeLong(nonce);
-_data.writeString(packageName);
-_data.writeStrongBinder((((listener!=null))?(listener.asBinder()):(null)));
-mRemote.transact(Stub.TRANSACTION_checkLicense, _data, null, IBinder.FLAG_ONEWAY);
-}
-finally {
-_data.recycle();
-}
-}
-}
-static final int TRANSACTION_checkLicense = (IBinder.FIRST_CALL_TRANSACTION + 0);
-}
-public void checkLicense(long nonce, java.lang.String packageName, com.google.android.vending.licensing.ILicenseResultListener listener) throws android.os.RemoteException;
+
+public interface ILicensingService extends android.os.IInterface {
+    /**
+     * Local-side IPC implementation contentViewStub class.
+     */
+    public static abstract class Stub extends android.os.Binder implements com.google.android.vending.licensing.ILicensingService {
+        private static final java.lang.String DESCRIPTOR = "com.android.vending.licensing.ILicensingService";
+
+        /**
+         * Construct the contentViewStub at attach it to the interface.
+         */
+        public Stub() {
+            this.attachInterface(this, DESCRIPTOR);
+        }
+
+        /**
+         * Cast an IBinder object into an ILicensingService interface,
+         * generating a proxy if needed.
+         */
+        public static com.google.android.vending.licensing.ILicensingService asInterface(android.os.IBinder obj) {
+            if ((obj == null)) {
+                return null;
+            }
+            android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
+            if (((iin != null) && (iin instanceof com.google.android.vending.licensing.ILicensingService))) {
+                return ((com.google.android.vending.licensing.ILicensingService) iin);
+            }
+            return new com.google.android.vending.licensing.ILicensingService.Stub.Proxy(obj);
+        }
+
+        public android.os.IBinder asBinder() {
+            return this;
+        }
+
+        public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException {
+            switch (code) {
+                case INTERFACE_TRANSACTION: {
+                    reply.writeString(DESCRIPTOR);
+                    return true;
+                }
+                case TRANSACTION_checkLicense: {
+                    data.enforceInterface(DESCRIPTOR);
+                    long _arg0;
+                    _arg0 = data.readLong();
+                    java.lang.String _arg1;
+                    _arg1 = data.readString();
+                    com.google.android.vending.licensing.ILicenseResultListener _arg2;
+                    _arg2 = com.google.android.vending.licensing.ILicenseResultListener.Stub.asInterface(data.readStrongBinder());
+                    this.checkLicense(_arg0, _arg1, _arg2);
+                    return true;
+                }
+            }
+            return super.onTransact(code, data, reply, flags);
+        }
+
+        private static class Proxy implements com.google.android.vending.licensing.ILicensingService {
+            private android.os.IBinder mRemote;
+
+            Proxy(android.os.IBinder remote) {
+                mRemote = remote;
+            }
+
+            public android.os.IBinder asBinder() {
+                return mRemote;
+            }
+
+            public java.lang.String getInterfaceDescriptor() {
+                return DESCRIPTOR;
+            }
+
+            public void checkLicense(long nonce, java.lang.String packageName, com.google.android.vending.licensing.ILicenseResultListener listener) throws android.os.RemoteException {
+                android.os.Parcel _data = android.os.Parcel.obtain();
+                try {
+                    _data.writeInterfaceToken(DESCRIPTOR);
+                    _data.writeLong(nonce);
+                    _data.writeString(packageName);
+                    _data.writeStrongBinder((((listener != null)) ? (listener.asBinder()) : (null)));
+                    mRemote.transact(Stub.TRANSACTION_checkLicense, _data, null, IBinder.FLAG_ONEWAY);
+                } finally {
+                    _data.recycle();
+                }
+            }
+        }
+
+        static final int TRANSACTION_checkLicense = (IBinder.FIRST_CALL_TRANSACTION + 0);
+    }
+
+    public void checkLicense(long nonce, java.lang.String packageName, com.google.android.vending.licensing.ILicenseResultListener listener) throws android.os.RemoteException;
 }

+ 42 - 38
app/src/google_services_based/java/com/google/android/vending/licensing/LicenseChecker.java

@@ -102,8 +102,8 @@ public class LicenseChecker implements ServiceConnection {
     private final Queue<LicenseValidator> mPendingChecks = new LinkedList<LicenseValidator>();
 
     /**
-     * @param context a Context
-     * @param policy implementation of Policy
+     * @param context          a Context
+     * @param policy           implementation of Policy
      * @param encodedPublicKey Base64-encoded RSA public key
      * @throws IllegalArgumentException if encodedPublicKey is invalid
      */
@@ -162,40 +162,40 @@ public class LicenseChecker implements ServiceConnection {
             callback.allow(Policy.LICENSED);
         } else {
             LicenseValidator validator = new LicenseValidator(mPolicy, new NullDeviceLimiter(),
-                    callback, generateNonce(), mPackageName, mVersionCode);
+                callback, generateNonce(), mPackageName, mVersionCode);
 
             if (mService == null) {
                 Log.i(TAG, "Binding to licensing service.");
                 try {
                     boolean bindResult = mContext
-                            .bindService(
-                                    new Intent(
-                                            new String(
-                                                    // Base64 encoded -
-                                                    // com.android.vending.licensing.ILicensingService
-                                                    // Consider encoding this in another way in your
-                                                    // code to improve security
-                                                    Base64.decode(
-                                                            "Y29tLmFuZHJvaWQudmVuZGluZy5saWNlbnNpbmcuSUxpY2Vuc2luZ1NlcnZpY2U=")))
-                                                                    // As of Android 5.0, implicit
-                                                                    // Service Intents are no longer
-                                                                    // allowed because it's not
-                                                                    // possible for the user to
-                                                                    // participate in disambiguating
-                                                                    // them. This does mean we break
-                                                                    // compatibility with Android
-                                                                    // Cupcake devices with this
-                                                                    // release, since setPackage was
-                                                                    // added in Donut.
-                                                                    .setPackage(
-                                                                            new String(
-                                                                                    // Base64
-                                                                                    // encoded -
-                                                                                    // com.android.vending
-                                                                                    Base64.decode(
-                                                                                            "Y29tLmFuZHJvaWQudmVuZGluZw=="))),
-                                    this, // ServiceConnection.
-                                    Context.BIND_AUTO_CREATE);
+                        .bindService(
+                            new Intent(
+                                new String(
+                                    // Base64 encoded -
+                                    // com.android.vending.licensing.ILicensingService
+                                    // Consider encoding this in another way in your
+                                    // code to improve security
+                                    Base64.decode(
+                                        "Y29tLmFuZHJvaWQudmVuZGluZy5saWNlbnNpbmcuSUxpY2Vuc2luZ1NlcnZpY2U=")))
+                                // As of Android 5.0, implicit
+                                // Service Intents are no longer
+                                // allowed because it's not
+                                // possible for the user to
+                                // participate in disambiguating
+                                // them. This does mean we break
+                                // compatibility with Android
+                                // Cupcake devices with this
+                                // release, since setPackage was
+                                // added in Donut.
+                                .setPackage(
+                                    new String(
+                                        // Base64
+                                        // encoded -
+                                        // com.android.vending
+                                        Base64.decode(
+                                            "Y29tLmFuZHJvaWQudmVuZGluZw=="))),
+                            this, // ServiceConnection.
+                            Context.BIND_AUTO_CREATE);
                     if (bindResult) {
                         mPendingChecks.offer(validator);
                     } else {
@@ -220,8 +220,8 @@ public class LicenseChecker implements ServiceConnection {
             try {
                 Log.i(TAG, "Calling checkLicense on service for " + validator.getPackageName());
                 mService.checkLicense(
-                        validator.getNonce(), validator.getPackageName(),
-                        new ResultListener(validator));
+                    validator.getNonce(), validator.getPackageName(),
+                    new ResultListener(validator));
                 mChecksInProgress.add(validator);
             } catch (RemoteException e) {
                 Log.w(TAG, "RemoteException in checkLicense call.", e);
@@ -260,7 +260,7 @@ public class LicenseChecker implements ServiceConnection {
         // Runs in IPC thread pool. Post it to the Handler, so we can guarantee
         // either this or the timeout runs.
         public void verifyLicense(final int responseCode, final String signedData,
-                final String signature) {
+                                  final String signature) {
             mHandler.post(new Runnable() {
                 public void run() {
                     Log.i(TAG, "Received response.");
@@ -292,7 +292,7 @@ public class LicenseChecker implements ServiceConnection {
 
                         if (logResponse) {
                             String android_id = Secure.getString(mContext.getContentResolver(),
-                                    Secure.ANDROID_ID);
+                                Secure.ANDROID_ID);
                             Date date = new Date();
                             Log.d(TAG, "Server Failure: " + stringError);
                             Log.d(TAG, "Android ID: " + android_id);
@@ -342,7 +342,9 @@ public class LicenseChecker implements ServiceConnection {
         }
     }
 
-    /** Unbinds service if necessary and removes reference to it. */
+    /**
+     * Unbinds service if necessary and removes reference to it.
+     */
     private void cleanupService() {
         if (mService != null) {
             try {
@@ -369,7 +371,9 @@ public class LicenseChecker implements ServiceConnection {
         mHandler.getLooper().quit();
     }
 
-    /** Generates a nonce (number used once). */
+    /**
+     * Generates a nonce (number used once).
+     */
     private int generateNonce() {
         return RANDOM.nextInt();
     }
@@ -384,7 +388,7 @@ public class LicenseChecker implements ServiceConnection {
     private static String getVersionCode(Context context, String packageName) {
         try {
             return String.valueOf(
-                    context.getPackageManager().getPackageInfo(packageName, 0).versionCode);
+                context.getPackageManager().getPackageInfo(packageName, 0).versionCode);
         } catch (NameNotFoundException e) {
             Log.e(TAG, "Package not found. could not get version code.");
             return "";

+ 6 - 4
app/src/google_services_based/java/com/google/android/vending/licensing/LicenseCheckerCallback.java

@@ -59,7 +59,7 @@ public interface LicenseCheckerCallback {
      * Allow use. App should proceed as normal.
      *
      * @param reason Policy.LICENSED or Policy.RETRY typically. (although in
-     *            theory the policy can return Policy.NOT_LICENSED here as well)
+     *               theory the policy can return Policy.NOT_LICENSED here as well)
      */
     public void allow(int reason);
 
@@ -67,12 +67,14 @@ public interface LicenseCheckerCallback {
      * Don't allow use. App should inform user and take appropriate action.
      *
      * @param reason Policy.NOT_LICENSED or Policy.RETRY. (although in theory
-     *            the policy can return Policy.LICENSED here as well ---
-     *            perhaps the call to the LVL took too long, for example)
+     *               the policy can return Policy.LICENSED here as well ---
+     *               perhaps the call to the LVL took too long, for example)
      */
     public void dontAllow(int reason);
 
-    /** Application error codes. */
+    /**
+     * Application error codes.
+     */
     public static final int ERROR_INVALID_PACKAGE_NAME = 1;
     public static final int ERROR_NON_MATCHING_UID = 2;
     public static final int ERROR_NOT_MARKET_MANAGED = 3;

+ 6 - 6
app/src/google_services_based/java/com/google/android/vending/licensing/LicenseValidator.java

@@ -76,7 +76,7 @@ class LicenseValidator {
     private final DeviceLimiter mDeviceLimiter;
 
     LicenseValidator(Policy policy, DeviceLimiter deviceLimiter, LicenseCheckerCallback callback,
-             int nonce, String packageName, String versionCode) {
+                     int nonce, String packageName, String versionCode) {
         mPolicy = policy;
         mDeviceLimiter = deviceLimiter;
         mCallback = callback;
@@ -102,22 +102,22 @@ class LicenseValidator {
     /**
      * Verifies the response from server and calls appropriate callback method.
      *
-     * @param publicKey public key associated with the developer account
+     * @param publicKey    public key associated with the developer account
      * @param responseCode server response code
-     * @param signedData signed data from server
-     * @param signature server signature
+     * @param signedData   signed data from server
+     * @param signature    server signature
      */
     public void verify(PublicKey publicKey, int responseCode, String signedData, String signature) {
         String userId = null;
         // Skip signature check for unsuccessful requests
         ResponseData data = null;
         if (responseCode == LICENSED || responseCode == NOT_LICENSED ||
-                responseCode == LICENSED_OLD_KEY) {
+            responseCode == LICENSED_OLD_KEY) {
             // Verify signature.
             try {
                 if (TextUtils.isEmpty(signedData)) {
                     Log.e(TAG, "Signature verification failed: signedData is empty. " +
-                            "(Device not signed-in to any Google accounts?)");
+                        "(Device not signed-in to any Google accounts?)");
                     handleInvalidResponse();
                     return;
                 }

+ 2 - 2
app/src/google_services_based/java/com/google/android/vending/licensing/Obfuscator.java

@@ -52,7 +52,7 @@ public interface Obfuscator {
      * Obfuscate a string that is being stored into shared preferences.
      *
      * @param original The data that is to be obfuscated.
-     * @param key The key for the data that is to be obfuscated.
+     * @param key      The key for the data that is to be obfuscated.
      * @return A transformed version of the original data.
      */
     String obfuscate(String original, String key);
@@ -61,7 +61,7 @@ public interface Obfuscator {
      * Undo the transformation applied to data by the obfuscate() method.
      *
      * @param obfuscated The data that is to be un-obfuscated.
-     * @param key The key for the data that is to be un-obfuscated.
+     * @param key        The key for the data that is to be un-obfuscated.
      * @return The original data transformed by the obfuscate() method.
      * @throws ValidationException Optionally thrown if a data integrity check fails.
      */

+ 1 - 1
app/src/google_services_based/java/com/google/android/vending/licensing/Policy.java

@@ -69,7 +69,7 @@ public interface Policy {
      * used for any future policy decisions.
      *
      * @param response the result from validating the server response
-     * @param rawData the raw server response data, can be null for RETRY
+     * @param rawData  the raw server response data, can be null for RETRY
      */
     void processServerResponse(int response, ResponseData rawData);
 

+ 1 - 1
app/src/google_services_based/java/com/google/android/vending/licensing/PreferenceObfuscator.java

@@ -55,7 +55,7 @@ public class PreferenceObfuscator {
      * Constructor.
      *
      * @param sp A SharedPreferences instance provided by the system.
-     * @param o The Obfuscator to use when reading or writing data.
+     * @param o  The Obfuscator to use when reading or writing data.
      */
     public PreferenceObfuscator(SharedPreferences sp, Obfuscator o) {
         mPreferences = sp;

+ 7 - 5
app/src/google_services_based/java/com/google/android/vending/licensing/ResponseData.java

@@ -52,7 +52,9 @@ public class ResponseData {
     public String versionCode;
     public String userId;
     public long timestamp;
-    /** Response-specific data. */
+    /**
+     * Response-specific data.
+     */
     public String extra;
 
     public String responseData;
@@ -62,8 +64,8 @@ public class ResponseData {
      * Parses response string into ResponseData.
      *
      * @param responseData response data string
-     * @throws IllegalArgumentException upon parsing error
      * @return ResponseData object
+     * @throws IllegalArgumentException upon parsing error
      */
     public static ResponseData parse(String responseData, String signature) {
         // Must parse out main response data and response-specific data.
@@ -100,9 +102,9 @@ public class ResponseData {
 
     @Override
     public String toString() {
-        return TextUtils.join("|", new Object[] {
-                responseCode, nonce, packageName, versionCode,
-                userId, timestamp
+        return TextUtils.join("|", new Object[]{
+            responseCode, nonce, packageName, versionCode,
+            userId, timestamp
         });
     }
 }

+ 2 - 2
app/src/google_services_based/java/com/google/android/vending/licensing/StrictPolicy.java

@@ -79,7 +79,7 @@ public class StrictPolicy implements Policy {
      * extra is still extracted in cases where the app is unlicensed.
      *
      * @param response the result from validating the server response
-     * @param rawData the raw server response data
+     * @param rawData  the raw server response data
      */
     public void processServerResponse(int response, ResponseData rawData) {
         mLastResponse = response;
@@ -92,7 +92,7 @@ public class StrictPolicy implements Policy {
 
     /**
      * {@inheritDoc}
-     *
+     * <p>
      * This implementation allows access if and only if a LICENSED response
      * was received the last time the server was contacted.
      */

+ 2 - 2
app/src/google_services_based/java/com/google/android/vending/licensing/ValidationException.java

@@ -43,11 +43,11 @@ package com.google.android.vending.licensing;
  */
 public class ValidationException extends Exception {
     public ValidationException() {
-      super();
+        super();
     }
 
     public ValidationException(String s) {
-      super(s);
+        super(s);
     }
 
     private static final long serialVersionUID = 1L;

+ 517 - 510
app/src/google_services_based/java/com/google/android/vending/licensing/util/Base64.java

@@ -60,526 +60,533 @@ package com.google.android.vending.licensing.util;
  * class.
  */
 public class Base64 {
-  /** The equals sign (=) as a byte. */
-  private final static byte EQUALS_SIGN = (byte) '=';
-
-  /** The new line character (\n) as a byte. */
-  private final static byte NEW_LINE = (byte) '\n';
-
-  /**
-   * The 64 valid Base64 values.
-   */
-  private final static byte[] ALPHABET =
-      {(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F',
-          (byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K',
-          (byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P',
-          (byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U',
-          (byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z',
-          (byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e',
-          (byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j',
-          (byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o',
-          (byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't',
-          (byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y',
-          (byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3',
-          (byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8',
-          (byte) '9', (byte) '+', (byte) '/'};
-
-  /**
-   * The 64 valid web safe Base64 values.
-   */
-  private final static byte[] WEBSAFE_ALPHABET =
-      {(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F',
-          (byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K',
-          (byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P',
-          (byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U',
-          (byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z',
-          (byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e',
-          (byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j',
-          (byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o',
-          (byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't',
-          (byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y',
-          (byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3',
-          (byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8',
-          (byte) '9', (byte) '-', (byte) '_'};
-
-  /**
-   * Translates a Base64 value to either its 6-bit reconstruction value
-   * or a negative number indicating some other meaning.
-   **/
-  private final static byte[] DECODABET = {-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal  0 -  8
-      -5, -5, // Whitespace: Tab and Linefeed
-      -9, -9, // Decimal 11 - 12
-      -5, // Whitespace: Carriage Return
-      -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26
-      -9, -9, -9, -9, -9, // Decimal 27 - 31
-      -5, // Whitespace: Space
-      -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 42
-      62, // Plus sign at decimal 43
-      -9, -9, -9, // Decimal 44 - 46
-      63, // Slash at decimal 47
-      52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine
-      -9, -9, -9, // Decimal 58 - 60
-      -1, // Equals sign at decimal 61
-      -9, -9, -9, // Decimal 62 - 64
-      0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N'
-      14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z'
-      -9, -9, -9, -9, -9, -9, // Decimal 91 - 96
-      26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm'
-      39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z'
-      -9, -9, -9, -9, -9 // Decimal 123 - 127
-      /*  ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 128 - 139
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 140 - 152
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 153 - 165
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 166 - 178
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 179 - 191
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 192 - 204
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 205 - 217
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 218 - 230
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 231 - 243
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9         // Decimal 244 - 255 */
-      };
-
-  /** The web safe decodabet */
-  private final static byte[] WEBSAFE_DECODABET =
-      {-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal  0 -  8
-          -5, -5, // Whitespace: Tab and Linefeed
-          -9, -9, // Decimal 11 - 12
-          -5, // Whitespace: Carriage Return
-          -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26
-          -9, -9, -9, -9, -9, // Decimal 27 - 31
-          -5, // Whitespace: Space
-          -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 44
-          62, // Dash '-' sign at decimal 45
-          -9, -9, // Decimal 46-47
-          52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine
-          -9, -9, -9, // Decimal 58 - 60
-          -1, // Equals sign at decimal 61
-          -9, -9, -9, // Decimal 62 - 64
-          0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N'
-          14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z'
-          -9, -9, -9, -9, // Decimal 91-94
-          63, // Underscore '_' at decimal 95
-          -9, // Decimal 96
-          26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm'
-          39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z'
-          -9, -9, -9, -9, -9 // Decimal 123 - 127
-      /*  ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 128 - 139
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 140 - 152
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 153 - 165
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 166 - 178
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 179 - 191
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 192 - 204
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 205 - 217
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 218 - 230
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 231 - 243
-        -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9         // Decimal 244 - 255 */
-      };
-
-  // Indicates white space in encoding
-  private final static byte WHITE_SPACE_ENC = -5;
-  // Indicates equals sign in encoding
-  private final static byte EQUALS_SIGN_ENC = -1;
-
-  /** Defeats instantiation. */
-  private Base64() {
-  }
-
-  /* ********  E N C O D I N G   M E T H O D S  ******** */
-
-  /**
-   * Encodes up to three bytes of the array <var>source</var>
-   * and writes the resulting four Base64 bytes to <var>destination</var>.
-   * The source and destination arrays can be manipulated
-   * anywhere along their length by specifying
-   * <var>srcOffset</var> and <var>destOffset</var>.
-   * This method does not check to make sure your arrays
-   * are large enough to accommodate <var>srcOffset</var> + 3 for
-   * the <var>source</var> array or <var>destOffset</var> + 4 for
-   * the <var>destination</var> array.
-   * The actual number of significant bytes in your array is
-   * given by <var>numSigBytes</var>.
-   *
-   * @param source the array to convert
-   * @param srcOffset the index where conversion begins
-   * @param numSigBytes the number of significant bytes in your array
-   * @param destination the array to hold the conversion
-   * @param destOffset the index where output will be put
-   * @param alphabet is the encoding alphabet
-   * @return the <var>destination</var> array
-   * @since 1.3
-   */
-  private static byte[] encode3to4(byte[] source, int srcOffset,
-      int numSigBytes, byte[] destination, int destOffset, byte[] alphabet) {
-    //           1         2         3
-    // 01234567890123456789012345678901 Bit position
-    // --------000000001111111122222222 Array position from threeBytes
-    // --------|    ||    ||    ||    | Six bit groups to index alphabet
-    //          >>18  >>12  >> 6  >> 0  Right shift necessary
-    //                0x3f  0x3f  0x3f  Additional AND
-
-    // Create buffer with zero-padding if there are only one or two
-    // significant bytes passed in the array.
-    // We have to shift left 24 in order to flush out the 1's that appear
-    // when Java treats a value as negative that is cast from a byte to an int.
-    int inBuff =
-        (numSigBytes > 0 ? ((source[srcOffset] << 24) >>> 8) : 0)
-            | (numSigBytes > 1 ? ((source[srcOffset + 1] << 24) >>> 16) : 0)
-            | (numSigBytes > 2 ? ((source[srcOffset + 2] << 24) >>> 24) : 0);
-
-    switch (numSigBytes) {
-      case 3:
-        destination[destOffset] = alphabet[(inBuff >>> 18)];
-        destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
-        destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f];
-        destination[destOffset + 3] = alphabet[(inBuff) & 0x3f];
-        return destination;
-      case 2:
-        destination[destOffset] = alphabet[(inBuff >>> 18)];
-        destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
-        destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f];
-        destination[destOffset + 3] = EQUALS_SIGN;
-        return destination;
-      case 1:
-        destination[destOffset] = alphabet[(inBuff >>> 18)];
-        destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
-        destination[destOffset + 2] = EQUALS_SIGN;
-        destination[destOffset + 3] = EQUALS_SIGN;
-        return destination;
-      default:
-        return destination;
-    } // end switch
-  } // end encode3to4
-
-  /**
-   * Encodes a byte array into Base64 notation.
-   * Equivalent to calling
-   * {@code encodeBytes(source, 0, source.length)}
-   *
-   * @param source The data to convert
-   * @since 1.4
-   */
-  public static String encode(byte[] source) {
-    return encode(source, 0, source.length, ALPHABET, true);
-  }
-
-  /**
-   * Encodes a byte array into web safe Base64 notation.
-   *
-   * @param source The data to convert
-   * @param doPadding is {@code true} to pad result with '=' chars
-   *        if it does not fall on 3 byte boundaries
-   */
-  public static String encodeWebSafe(byte[] source, boolean doPadding) {
-    return encode(source, 0, source.length, WEBSAFE_ALPHABET, doPadding);
-  }
-
-  /**
-   * Encodes a byte array into Base64 notation.
-   *
-   * @param source The data to convert
-   * @param off Offset in array where conversion should begin
-   * @param len Length of data to convert
-   * @param alphabet is the encoding alphabet
-   * @param doPadding is {@code true} to pad result with '=' chars
-   *        if it does not fall on 3 byte boundaries
-   * @since 1.4
-   */
-  public static String encode(byte[] source, int off, int len, byte[] alphabet,
-      boolean doPadding) {
-    byte[] outBuff = encode(source, off, len, alphabet, Integer.MAX_VALUE);
-    int outLen = outBuff.length;
-
-    // If doPadding is false, set length to truncate '='
-    // padding characters
-    while (doPadding == false && outLen > 0) {
-      if (outBuff[outLen - 1] != '=') {
-        break;
-      }
-      outLen -= 1;
+    /**
+     * The equals sign (=) as a byte.
+     */
+    private final static byte EQUALS_SIGN = (byte) '=';
+
+    /**
+     * The new line character (\n) as a byte.
+     */
+    private final static byte NEW_LINE = (byte) '\n';
+
+    /**
+     * The 64 valid Base64 values.
+     */
+    private final static byte[] ALPHABET =
+        {(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F',
+            (byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K',
+            (byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P',
+            (byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U',
+            (byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z',
+            (byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e',
+            (byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j',
+            (byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o',
+            (byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't',
+            (byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y',
+            (byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3',
+            (byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8',
+            (byte) '9', (byte) '+', (byte) '/'};
+
+    /**
+     * The 64 valid web safe Base64 values.
+     */
+    private final static byte[] WEBSAFE_ALPHABET =
+        {(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F',
+            (byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K',
+            (byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P',
+            (byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U',
+            (byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z',
+            (byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e',
+            (byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j',
+            (byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o',
+            (byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't',
+            (byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y',
+            (byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3',
+            (byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8',
+            (byte) '9', (byte) '-', (byte) '_'};
+
+    /**
+     * Translates a Base64 value to either its 6-bit reconstruction value
+     * or a negative number indicating some other meaning.
+     **/
+    private final static byte[] DECODABET = {-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal  0 -  8
+        -5, -5, // Whitespace: Tab and Linefeed
+        -9, -9, // Decimal 11 - 12
+        -5, // Whitespace: Carriage Return
+        -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26
+        -9, -9, -9, -9, -9, // Decimal 27 - 31
+        -5, // Whitespace: Space
+        -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 42
+        62, // Plus sign at decimal 43
+        -9, -9, -9, // Decimal 44 - 46
+        63, // Slash at decimal 47
+        52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine
+        -9, -9, -9, // Decimal 58 - 60
+        -1, // Equals sign at decimal 61
+        -9, -9, -9, // Decimal 62 - 64
+        0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N'
+        14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z'
+        -9, -9, -9, -9, -9, -9, // Decimal 91 - 96
+        26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm'
+        39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z'
+        -9, -9, -9, -9, -9 // Decimal 123 - 127
+        /*  ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 128 - 139
+          -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 140 - 152
+          -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 153 - 165
+          -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 166 - 178
+          -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 179 - 191
+          -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 192 - 204
+          -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 205 - 217
+          -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 218 - 230
+          -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 231 - 243
+          -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9         // Decimal 244 - 255 */
+    };
+
+    /**
+     * The web safe decodabet
+     */
+    private final static byte[] WEBSAFE_DECODABET =
+        {-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal  0 -  8
+            -5, -5, // Whitespace: Tab and Linefeed
+            -9, -9, // Decimal 11 - 12
+            -5, // Whitespace: Carriage Return
+            -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26
+            -9, -9, -9, -9, -9, // Decimal 27 - 31
+            -5, // Whitespace: Space
+            -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 44
+            62, // Dash '-' sign at decimal 45
+            -9, -9, // Decimal 46-47
+            52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine
+            -9, -9, -9, // Decimal 58 - 60
+            -1, // Equals sign at decimal 61
+            -9, -9, -9, // Decimal 62 - 64
+            0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N'
+            14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z'
+            -9, -9, -9, -9, // Decimal 91-94
+            63, // Underscore '_' at decimal 95
+            -9, // Decimal 96
+            26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm'
+            39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z'
+            -9, -9, -9, -9, -9 // Decimal 123 - 127
+            /*  ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 128 - 139
+              -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 140 - 152
+              -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 153 - 165
+              -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 166 - 178
+              -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 179 - 191
+              -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 192 - 204
+              -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 205 - 217
+              -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 218 - 230
+              -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,     // Decimal 231 - 243
+              -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9         // Decimal 244 - 255 */
+        };
+
+    // Indicates white space in encoding
+    private final static byte WHITE_SPACE_ENC = -5;
+    // Indicates equals sign in encoding
+    private final static byte EQUALS_SIGN_ENC = -1;
+
+    /**
+     * Defeats instantiation.
+     */
+    private Base64() {
     }
 
-    return new String(outBuff, 0, outLen);
-  }
-
-  /**
-   * Encodes a byte array into Base64 notation.
-   *
-   * @param source The data to convert
-   * @param off Offset in array where conversion should begin
-   * @param len Length of data to convert
-   * @param alphabet is the encoding alphabet
-   * @param maxLineLength maximum length of one line.
-   * @return the BASE64-encoded byte array
-   */
-  public static byte[] encode(byte[] source, int off, int len, byte[] alphabet,
-      int maxLineLength) {
-    int lenDiv3 = (len + 2) / 3; // ceil(len / 3)
-    int len43 = lenDiv3 * 4;
-    byte[] outBuff = new byte[len43 // Main 4:3
-        + (len43 / maxLineLength)]; // New lines
-
-    int d = 0;
-    int e = 0;
-    int len2 = len - 2;
-    int lineLength = 0;
-    for (; d < len2; d += 3, e += 4) {
-
-      // The following block of code is the same as
-      // encode3to4( source, d + off, 3, outBuff, e, alphabet );
-      // but inlined for faster encoding (~20% improvement)
-      int inBuff =
-          ((source[d + off] << 24) >>> 8)
-              | ((source[d + 1 + off] << 24) >>> 16)
-              | ((source[d + 2 + off] << 24) >>> 24);
-      outBuff[e] = alphabet[(inBuff >>> 18)];
-      outBuff[e + 1] = alphabet[(inBuff >>> 12) & 0x3f];
-      outBuff[e + 2] = alphabet[(inBuff >>> 6) & 0x3f];
-      outBuff[e + 3] = alphabet[(inBuff) & 0x3f];
-
-      lineLength += 4;
-      if (lineLength == maxLineLength) {
-        outBuff[e + 4] = NEW_LINE;
-        e++;
-        lineLength = 0;
-      } // end if: end of line
-    } // end for: each piece of array
-
-    if (d < len) {
-      encode3to4(source, d + off, len - d, outBuff, e, alphabet);
-
-      lineLength += 4;
-      if (lineLength == maxLineLength) {
-        // Add a last newline
-        outBuff[e + 4] = NEW_LINE;
-        e++;
-      }
-      e += 4;
+    /* ********  E N C O D I N G   M E T H O D S  ******** */
+
+    /**
+     * Encodes up to three bytes of the array <var>source</var>
+     * and writes the resulting four Base64 bytes to <var>destination</var>.
+     * The source and destination arrays can be manipulated
+     * anywhere along their length by specifying
+     * <var>srcOffset</var> and <var>destOffset</var>.
+     * This method does not check to make sure your arrays
+     * are large enough to accommodate <var>srcOffset</var> + 3 for
+     * the <var>source</var> array or <var>destOffset</var> + 4 for
+     * the <var>destination</var> array.
+     * The actual number of significant bytes in your array is
+     * given by <var>numSigBytes</var>.
+     *
+     * @param source      the array to convert
+     * @param srcOffset   the index where conversion begins
+     * @param numSigBytes the number of significant bytes in your array
+     * @param destination the array to hold the conversion
+     * @param destOffset  the index where output will be put
+     * @param alphabet    is the encoding alphabet
+     * @return the <var>destination</var> array
+     * @since 1.3
+     */
+    private static byte[] encode3to4(byte[] source, int srcOffset,
+                                     int numSigBytes, byte[] destination, int destOffset, byte[] alphabet) {
+        //           1         2         3
+        // 01234567890123456789012345678901 Bit position
+        // --------000000001111111122222222 Array position from threeBytes
+        // --------|    ||    ||    ||    | Six bit groups to index alphabet
+        //          >>18  >>12  >> 6  >> 0  Right shift necessary
+        //                0x3f  0x3f  0x3f  Additional AND
+
+        // Create buffer with zero-padding if there are only one or two
+        // significant bytes passed in the array.
+        // We have to shift left 24 in order to flush out the 1's that appear
+        // when Java treats a value as negative that is cast from a byte to an int.
+        int inBuff =
+            (numSigBytes > 0 ? ((source[srcOffset] << 24) >>> 8) : 0)
+                | (numSigBytes > 1 ? ((source[srcOffset + 1] << 24) >>> 16) : 0)
+                | (numSigBytes > 2 ? ((source[srcOffset + 2] << 24) >>> 24) : 0);
+
+        switch (numSigBytes) {
+            case 3:
+                destination[destOffset] = alphabet[(inBuff >>> 18)];
+                destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
+                destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f];
+                destination[destOffset + 3] = alphabet[(inBuff) & 0x3f];
+                return destination;
+            case 2:
+                destination[destOffset] = alphabet[(inBuff >>> 18)];
+                destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
+                destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f];
+                destination[destOffset + 3] = EQUALS_SIGN;
+                return destination;
+            case 1:
+                destination[destOffset] = alphabet[(inBuff >>> 18)];
+                destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
+                destination[destOffset + 2] = EQUALS_SIGN;
+                destination[destOffset + 3] = EQUALS_SIGN;
+                return destination;
+            default:
+                return destination;
+        } // end switch
+    } // end encode3to4
+
+    /**
+     * Encodes a byte array into Base64 notation.
+     * Equivalent to calling
+     * {@code encodeBytes(source, 0, source.length)}
+     *
+     * @param source The data to convert
+     * @since 1.4
+     */
+    public static String encode(byte[] source) {
+        return encode(source, 0, source.length, ALPHABET, true);
     }
 
-    assert (e == outBuff.length);
-    return outBuff;
-  }
-
-
-  /* ********  D E C O D I N G   M E T H O D S  ******** */
-
-
-  /**
-   * Decodes four bytes from array <var>source</var>
-   * and writes the resulting bytes (up to three of them)
-   * to <var>destination</var>.
-   * The source and destination arrays can be manipulated
-   * anywhere along their length by specifying
-   * <var>srcOffset</var> and <var>destOffset</var>.
-   * This method does not check to make sure your arrays
-   * are large enough to accommodate <var>srcOffset</var> + 4 for
-   * the <var>source</var> array or <var>destOffset</var> + 3 for
-   * the <var>destination</var> array.
-   * This method returns the actual number of bytes that
-   * were converted from the Base64 encoding.
-   *
-   *
-   * @param source the array to convert
-   * @param srcOffset the index where conversion begins
-   * @param destination the array to hold the conversion
-   * @param destOffset the index where output will be put
-   * @param decodabet the decodabet for decoding Base64 content
-   * @return the number of decoded bytes converted
-   * @since 1.3
-   */
-  private static int decode4to3(byte[] source, int srcOffset,
-      byte[] destination, int destOffset, byte[] decodabet) {
-    // Example: Dk==
-    if (source[srcOffset + 2] == EQUALS_SIGN) {
-      int outBuff =
-          ((decodabet[source[srcOffset]] << 24) >>> 6)
-              | ((decodabet[source[srcOffset + 1]] << 24) >>> 12);
-
-      destination[destOffset] = (byte) (outBuff >>> 16);
-      return 1;
-    } else if (source[srcOffset + 3] == EQUALS_SIGN) {
-      // Example: DkL=
-      int outBuff =
-          ((decodabet[source[srcOffset]] << 24) >>> 6)
-              | ((decodabet[source[srcOffset + 1]] << 24) >>> 12)
-              | ((decodabet[source[srcOffset + 2]] << 24) >>> 18);
-
-      destination[destOffset] = (byte) (outBuff >>> 16);
-      destination[destOffset + 1] = (byte) (outBuff >>> 8);
-      return 2;
-    } else {
-      // Example: DkLE
-      int outBuff =
-          ((decodabet[source[srcOffset]] << 24) >>> 6)
-              | ((decodabet[source[srcOffset + 1]] << 24) >>> 12)
-              | ((decodabet[source[srcOffset + 2]] << 24) >>> 18)
-              | ((decodabet[source[srcOffset + 3]] << 24) >>> 24);
-
-      destination[destOffset] = (byte) (outBuff >> 16);
-      destination[destOffset + 1] = (byte) (outBuff >> 8);
-      destination[destOffset + 2] = (byte) (outBuff);
-      return 3;
+    /**
+     * Encodes a byte array into web safe Base64 notation.
+     *
+     * @param source    The data to convert
+     * @param doPadding is {@code true} to pad result with '=' chars
+     *                  if it does not fall on 3 byte boundaries
+     */
+    public static String encodeWebSafe(byte[] source, boolean doPadding) {
+        return encode(source, 0, source.length, WEBSAFE_ALPHABET, doPadding);
     }
-  } // end decodeToBytes
-
-
-  /**
-   * Decodes data from Base64 notation.
-   *
-   * @param s the string to decode (decoded in default encoding)
-   * @return the decoded data
-   * @since 1.4
-   */
-  public static byte[] decode(String s) throws Base64DecoderException {
-    byte[] bytes = s.getBytes();
-    return decode(bytes, 0, bytes.length);
-  }
-
-  /**
-   * Decodes data from web safe Base64 notation.
-   * Web safe encoding uses '-' instead of '+', '_' instead of '/'
-   *
-   * @param s the string to decode (decoded in default encoding)
-   * @return the decoded data
-   */
-  public static byte[] decodeWebSafe(String s) throws Base64DecoderException {
-    byte[] bytes = s.getBytes();
-    return decodeWebSafe(bytes, 0, bytes.length);
-  }
-
-  /**
-   * Decodes Base64 content in byte array format and returns
-   * the decoded byte array.
-   *
-   * @param source The Base64 encoded data
-   * @return decoded data
-   * @since 1.3
-   * @throws Base64DecoderException
-   */
-  public static byte[] decode(byte[] source) throws Base64DecoderException {
-    return decode(source, 0, source.length);
-  }
-
-  /**
-   * Decodes web safe Base64 content in byte array format and returns
-   * the decoded data.
-   * Web safe encoding uses '-' instead of '+', '_' instead of '/'
-   *
-   * @param source the string to decode (decoded in default encoding)
-   * @return the decoded data
-   */
-  public static byte[] decodeWebSafe(byte[] source)
-      throws Base64DecoderException {
-    return decodeWebSafe(source, 0, source.length);
-  }
-
-  /**
-   * Decodes Base64 content in byte array format and returns
-   * the decoded byte array.
-   *
-   * @param source The Base64 encoded data
-   * @param off    The offset of where to begin decoding
-   * @param len    The length of characters to decode
-   * @return decoded data
-   * @since 1.3
-   * @throws Base64DecoderException
-   */
-  public static byte[] decode(byte[] source, int off, int len)
-      throws Base64DecoderException {
-    return decode(source, off, len, DECODABET);
-  }
-
-  /**
-   * Decodes web safe Base64 content in byte array format and returns
-   * the decoded byte array.
-   * Web safe encoding uses '-' instead of '+', '_' instead of '/'
-   *
-   * @param source The Base64 encoded data
-   * @param off    The offset of where to begin decoding
-   * @param len    The length of characters to decode
-   * @return decoded data
-   */
-  public static byte[] decodeWebSafe(byte[] source, int off, int len)
-      throws Base64DecoderException {
-    return decode(source, off, len, WEBSAFE_DECODABET);
-  }
-
-  /**
-   * Decodes Base64 content using the supplied decodabet and returns
-   * the decoded byte array.
-   *
-   * @param source    The Base64 encoded data
-   * @param off       The offset of where to begin decoding
-   * @param len       The length of characters to decode
-   * @param decodabet the decodabet for decoding Base64 content
-   * @return decoded data
-   */
-  public static byte[] decode(byte[] source, int off, int len, byte[] decodabet)
-      throws Base64DecoderException {
-    int len34 = len * 3 / 4;
-    byte[] outBuff = new byte[2 + len34]; // Upper limit on size of output
-    int outBuffPosn = 0;
-
-    byte[] b4 = new byte[4];
-    int b4Posn = 0;
-    int i = 0;
-    byte sbiCrop = 0;
-    byte sbiDecode = 0;
-    for (i = 0; i < len; i++) {
-      sbiCrop = (byte) (source[i + off] & 0x7f); // Only the low seven bits
-      sbiDecode = decodabet[sbiCrop];
-
-      if (sbiDecode >= WHITE_SPACE_ENC) { // White space Equals sign or better
-        if (sbiDecode >= EQUALS_SIGN_ENC) {
-          // An equals sign (for padding) must not occur at position 0 or 1
-          // and must be the last byte[s] in the encoded value
-          if (sbiCrop == EQUALS_SIGN) {
-            int bytesLeft = len - i;
-            byte lastByte = (byte) (source[len - 1 + off] & 0x7f);
-            if (b4Posn == 0 || b4Posn == 1) {
-              throw new Base64DecoderException(
-                  "invalid padding byte '=' at byte offset " + i);
-            } else if ((b4Posn == 3 && bytesLeft > 2)
-                || (b4Posn == 4 && bytesLeft > 1)) {
-              throw new Base64DecoderException(
-                  "padding byte '=' falsely signals end of encoded value "
-                      + "at offset " + i);
-            } else if (lastByte != EQUALS_SIGN && lastByte != NEW_LINE) {
-              throw new Base64DecoderException(
-                  "encoded value has invalid trailing byte");
+
+    /**
+     * Encodes a byte array into Base64 notation.
+     *
+     * @param source    The data to convert
+     * @param off       Offset in array where conversion should begin
+     * @param len       Length of data to convert
+     * @param alphabet  is the encoding alphabet
+     * @param doPadding is {@code true} to pad result with '=' chars
+     *                  if it does not fall on 3 byte boundaries
+     * @since 1.4
+     */
+    public static String encode(byte[] source, int off, int len, byte[] alphabet,
+                                boolean doPadding) {
+        byte[] outBuff = encode(source, off, len, alphabet, Integer.MAX_VALUE);
+        int outLen = outBuff.length;
+
+        // If doPadding is false, set length to truncate '='
+        // padding characters
+        while (doPadding == false && outLen > 0) {
+            if (outBuff[outLen - 1] != '=') {
+                break;
             }
-            break;
-          }
+            outLen -= 1;
+        }
 
-          b4[b4Posn++] = sbiCrop;
-          if (b4Posn == 4) {
-            outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet);
-            b4Posn = 0;
-          }
+        return new String(outBuff, 0, outLen);
+    }
+
+    /**
+     * Encodes a byte array into Base64 notation.
+     *
+     * @param source        The data to convert
+     * @param off           Offset in array where conversion should begin
+     * @param len           Length of data to convert
+     * @param alphabet      is the encoding alphabet
+     * @param maxLineLength maximum length of one line.
+     * @return the BASE64-encoded byte array
+     */
+    public static byte[] encode(byte[] source, int off, int len, byte[] alphabet,
+                                int maxLineLength) {
+        int lenDiv3 = (len + 2) / 3; // ceil(len / 3)
+        int len43 = lenDiv3 * 4;
+        byte[] outBuff = new byte[len43 // Main 4:3
+            + (len43 / maxLineLength)]; // New lines
+
+        int d = 0;
+        int e = 0;
+        int len2 = len - 2;
+        int lineLength = 0;
+        for (; d < len2; d += 3, e += 4) {
+
+            // The following block of code is the same as
+            // encode3to4( source, d + off, 3, outBuff, e, alphabet );
+            // but inlined for faster encoding (~20% improvement)
+            int inBuff =
+                ((source[d + off] << 24) >>> 8)
+                    | ((source[d + 1 + off] << 24) >>> 16)
+                    | ((source[d + 2 + off] << 24) >>> 24);
+            outBuff[e] = alphabet[(inBuff >>> 18)];
+            outBuff[e + 1] = alphabet[(inBuff >>> 12) & 0x3f];
+            outBuff[e + 2] = alphabet[(inBuff >>> 6) & 0x3f];
+            outBuff[e + 3] = alphabet[(inBuff) & 0x3f];
+
+            lineLength += 4;
+            if (lineLength == maxLineLength) {
+                outBuff[e + 4] = NEW_LINE;
+                e++;
+                lineLength = 0;
+            } // end if: end of line
+        } // end for: each piece of array
+
+        if (d < len) {
+            encode3to4(source, d + off, len - d, outBuff, e, alphabet);
+
+            lineLength += 4;
+            if (lineLength == maxLineLength) {
+                // Add a last newline
+                outBuff[e + 4] = NEW_LINE;
+                e++;
+            }
+            e += 4;
+        }
+
+        assert (e == outBuff.length);
+        return outBuff;
+    }
+
+
+    /* ********  D E C O D I N G   M E T H O D S  ******** */
+
+
+    /**
+     * Decodes four bytes from array <var>source</var>
+     * and writes the resulting bytes (up to three of them)
+     * to <var>destination</var>.
+     * The source and destination arrays can be manipulated
+     * anywhere along their length by specifying
+     * <var>srcOffset</var> and <var>destOffset</var>.
+     * This method does not check to make sure your arrays
+     * are large enough to accommodate <var>srcOffset</var> + 4 for
+     * the <var>source</var> array or <var>destOffset</var> + 3 for
+     * the <var>destination</var> array.
+     * This method returns the actual number of bytes that
+     * were converted from the Base64 encoding.
+     *
+     * @param source      the array to convert
+     * @param srcOffset   the index where conversion begins
+     * @param destination the array to hold the conversion
+     * @param destOffset  the index where output will be put
+     * @param decodabet   the decodabet for decoding Base64 content
+     * @return the number of decoded bytes converted
+     * @since 1.3
+     */
+    private static int decode4to3(byte[] source, int srcOffset,
+                                  byte[] destination, int destOffset, byte[] decodabet) {
+        // Example: Dk==
+        if (source[srcOffset + 2] == EQUALS_SIGN) {
+            int outBuff =
+                ((decodabet[source[srcOffset]] << 24) >>> 6)
+                    | ((decodabet[source[srcOffset + 1]] << 24) >>> 12);
+
+            destination[destOffset] = (byte) (outBuff >>> 16);
+            return 1;
+        } else if (source[srcOffset + 3] == EQUALS_SIGN) {
+            // Example: DkL=
+            int outBuff =
+                ((decodabet[source[srcOffset]] << 24) >>> 6)
+                    | ((decodabet[source[srcOffset + 1]] << 24) >>> 12)
+                    | ((decodabet[source[srcOffset + 2]] << 24) >>> 18);
+
+            destination[destOffset] = (byte) (outBuff >>> 16);
+            destination[destOffset + 1] = (byte) (outBuff >>> 8);
+            return 2;
+        } else {
+            // Example: DkLE
+            int outBuff =
+                ((decodabet[source[srcOffset]] << 24) >>> 6)
+                    | ((decodabet[source[srcOffset + 1]] << 24) >>> 12)
+                    | ((decodabet[source[srcOffset + 2]] << 24) >>> 18)
+                    | ((decodabet[source[srcOffset + 3]] << 24) >>> 24);
+
+            destination[destOffset] = (byte) (outBuff >> 16);
+            destination[destOffset + 1] = (byte) (outBuff >> 8);
+            destination[destOffset + 2] = (byte) (outBuff);
+            return 3;
         }
-      } else {
-        throw new Base64DecoderException("Bad Base64 input character at " + i
-            + ": " + source[i + off] + "(decimal)");
-      }
+    } // end decodeToBytes
+
+
+    /**
+     * Decodes data from Base64 notation.
+     *
+     * @param s the string to decode (decoded in default encoding)
+     * @return the decoded data
+     * @since 1.4
+     */
+    public static byte[] decode(String s) throws Base64DecoderException {
+        byte[] bytes = s.getBytes();
+        return decode(bytes, 0, bytes.length);
     }
 
-    // Because web safe encoding allows non padding base64 encodes, we
-    // need to pad the rest of the b4 buffer with equal signs when
-    // b4Posn != 0.  There can be at most 2 equal signs at the end of
-    // four characters, so the b4 buffer must have two or three
-    // characters.  This also catches the case where the input is
-    // padded with EQUALS_SIGN
-    if (b4Posn != 0) {
-      if (b4Posn == 1) {
-        throw new Base64DecoderException("single trailing character at offset "
-            + (len - 1));
-      }
-      b4[b4Posn++] = EQUALS_SIGN;
-      outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet);
+    /**
+     * Decodes data from web safe Base64 notation.
+     * Web safe encoding uses '-' instead of '+', '_' instead of '/'
+     *
+     * @param s the string to decode (decoded in default encoding)
+     * @return the decoded data
+     */
+    public static byte[] decodeWebSafe(String s) throws Base64DecoderException {
+        byte[] bytes = s.getBytes();
+        return decodeWebSafe(bytes, 0, bytes.length);
     }
 
-    byte[] out = new byte[outBuffPosn];
-    System.arraycopy(outBuff, 0, out, 0, outBuffPosn);
-    return out;
-  }
+    /**
+     * Decodes Base64 content in byte array format and returns
+     * the decoded byte array.
+     *
+     * @param source The Base64 encoded data
+     * @return decoded data
+     * @throws Base64DecoderException
+     * @since 1.3
+     */
+    public static byte[] decode(byte[] source) throws Base64DecoderException {
+        return decode(source, 0, source.length);
+    }
+
+    /**
+     * Decodes web safe Base64 content in byte array format and returns
+     * the decoded data.
+     * Web safe encoding uses '-' instead of '+', '_' instead of '/'
+     *
+     * @param source the string to decode (decoded in default encoding)
+     * @return the decoded data
+     */
+    public static byte[] decodeWebSafe(byte[] source)
+        throws Base64DecoderException {
+        return decodeWebSafe(source, 0, source.length);
+    }
+
+    /**
+     * Decodes Base64 content in byte array format and returns
+     * the decoded byte array.
+     *
+     * @param source The Base64 encoded data
+     * @param off    The offset of where to begin decoding
+     * @param len    The length of characters to decode
+     * @return decoded data
+     * @throws Base64DecoderException
+     * @since 1.3
+     */
+    public static byte[] decode(byte[] source, int off, int len)
+        throws Base64DecoderException {
+        return decode(source, off, len, DECODABET);
+    }
+
+    /**
+     * Decodes web safe Base64 content in byte array format and returns
+     * the decoded byte array.
+     * Web safe encoding uses '-' instead of '+', '_' instead of '/'
+     *
+     * @param source The Base64 encoded data
+     * @param off    The offset of where to begin decoding
+     * @param len    The length of characters to decode
+     * @return decoded data
+     */
+    public static byte[] decodeWebSafe(byte[] source, int off, int len)
+        throws Base64DecoderException {
+        return decode(source, off, len, WEBSAFE_DECODABET);
+    }
+
+    /**
+     * Decodes Base64 content using the supplied decodabet and returns
+     * the decoded byte array.
+     *
+     * @param source    The Base64 encoded data
+     * @param off       The offset of where to begin decoding
+     * @param len       The length of characters to decode
+     * @param decodabet the decodabet for decoding Base64 content
+     * @return decoded data
+     */
+    public static byte[] decode(byte[] source, int off, int len, byte[] decodabet)
+        throws Base64DecoderException {
+        int len34 = len * 3 / 4;
+        byte[] outBuff = new byte[2 + len34]; // Upper limit on size of output
+        int outBuffPosn = 0;
+
+        byte[] b4 = new byte[4];
+        int b4Posn = 0;
+        int i = 0;
+        byte sbiCrop = 0;
+        byte sbiDecode = 0;
+        for (i = 0; i < len; i++) {
+            sbiCrop = (byte) (source[i + off] & 0x7f); // Only the low seven bits
+            sbiDecode = decodabet[sbiCrop];
+
+            if (sbiDecode >= WHITE_SPACE_ENC) { // White space Equals sign or better
+                if (sbiDecode >= EQUALS_SIGN_ENC) {
+                    // An equals sign (for padding) must not occur at position 0 or 1
+                    // and must be the last byte[s] in the encoded value
+                    if (sbiCrop == EQUALS_SIGN) {
+                        int bytesLeft = len - i;
+                        byte lastByte = (byte) (source[len - 1 + off] & 0x7f);
+                        if (b4Posn == 0 || b4Posn == 1) {
+                            throw new Base64DecoderException(
+                                "invalid padding byte '=' at byte offset " + i);
+                        } else if ((b4Posn == 3 && bytesLeft > 2)
+                            || (b4Posn == 4 && bytesLeft > 1)) {
+                            throw new Base64DecoderException(
+                                "padding byte '=' falsely signals end of encoded value "
+                                    + "at offset " + i);
+                        } else if (lastByte != EQUALS_SIGN && lastByte != NEW_LINE) {
+                            throw new Base64DecoderException(
+                                "encoded value has invalid trailing byte");
+                        }
+                        break;
+                    }
+
+                    b4[b4Posn++] = sbiCrop;
+                    if (b4Posn == 4) {
+                        outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet);
+                        b4Posn = 0;
+                    }
+                }
+            } else {
+                throw new Base64DecoderException("Bad Base64 input character at " + i
+                    + ": " + source[i + off] + "(decimal)");
+            }
+        }
+
+        // Because web safe encoding allows non padding base64 encodes, we
+        // need to pad the rest of the b4 buffer with equal signs when
+        // b4Posn != 0.  There can be at most 2 equal signs at the end of
+        // four characters, so the b4 buffer must have two or three
+        // characters.  This also catches the case where the input is
+        // padded with EQUALS_SIGN
+        if (b4Posn != 0) {
+            if (b4Posn == 1) {
+                throw new Base64DecoderException("single trailing character at offset "
+                    + (len - 1));
+            }
+            b4[b4Posn++] = EQUALS_SIGN;
+            outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet);
+        }
+
+        byte[] out = new byte[outBuffPosn];
+        System.arraycopy(outBuff, 0, out, 0, outBuffPosn);
+        return out;
+    }
 }

+ 7 - 7
app/src/google_services_based/java/com/google/android/vending/licensing/util/Base64DecoderException.java

@@ -41,13 +41,13 @@ package com.google.android.vending.licensing.util;
  * @author nelson
  */
 public class Base64DecoderException extends Exception {
-  public Base64DecoderException() {
-    super();
-  }
+    public Base64DecoderException() {
+        super();
+    }
 
-  public Base64DecoderException(String s) {
-    super(s);
-  }
+    public Base64DecoderException(String s) {
+        super(s);
+    }
 
-  private static final long serialVersionUID = 1L;
+    private static final long serialVersionUID = 1L;
 }

+ 22 - 22
app/src/google_services_based/java/com/google/android/vending/licensing/util/URIQueryDecoder.java

@@ -52,31 +52,31 @@ public class URIQueryDecoder {
      * Decodes the query portion of the passed-in URI.
      *
      * @param encodedURI the URI containing the query to decode
-     * @param results a map containing all query parameters. Query parameters that do not have a
-     *            value will map to a null string
+     * @param results    a map containing all query parameters. Query parameters that do not have a
+     *                   value will map to a null string
      */
     static public void DecodeQuery(URI encodedURI, Map<String, String> results) {
         try (Scanner scanner = new Scanner(encodedURI.getRawQuery())) {
-	        scanner.useDelimiter("&");
-	        try {
-		        while (scanner.hasNext()) {
-			        String param = scanner.next();
-			        String[] valuePair = param.split("=");
-			        String name, value;
-			        if (valuePair.length == 1) {
-				        value = null;
-			        } else if (valuePair.length == 2) {
-				        value = URLDecoder.decode(valuePair[1], "UTF-8");
-			        } else {
-				        throw new IllegalArgumentException("query parameter invalid");
-			        }
-			        name = URLDecoder.decode(valuePair[0], "UTF-8");
-			        results.put(name, value);
-		        }
-	        } catch (UnsupportedEncodingException e) {
-		        // This should never happen.
-		        Log.e(TAG, "UTF-8 Not Recognized as a charset.  Device configuration Error.");
-	        }
+            scanner.useDelimiter("&");
+            try {
+                while (scanner.hasNext()) {
+                    String param = scanner.next();
+                    String[] valuePair = param.split("=");
+                    String name, value;
+                    if (valuePair.length == 1) {
+                        value = null;
+                    } else if (valuePair.length == 2) {
+                        value = URLDecoder.decode(valuePair[1], "UTF-8");
+                    } else {
+                        throw new IllegalArgumentException("query parameter invalid");
+                    }
+                    name = URLDecoder.decode(valuePair[0], "UTF-8");
+                    results.put(name, value);
+                }
+            } catch (UnsupportedEncodingException e) {
+                // This should never happen.
+                Log.e(TAG, "UTF-8 Not Recognized as a charset.  Device configuration Error.");
+            }
         }
     }
 }

+ 9 - 9
app/src/green/java/ch/threema/app/activities/DownloadApkActivity.java

@@ -30,17 +30,17 @@ import androidx.appcompat.app.AppCompatActivity;
 import ch.threema.base.utils.LoggingUtil;
 
 public class DownloadApkActivity extends AppCompatActivity {
-	public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
-	// stub
+    public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
+    // stub
 
-	private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
+    private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
 
-	@Override
-	protected void onCreate(@Nullable Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
 
-		logger.error("This activity may not be used in this build variant");
+        logger.error("This activity may not be used in this build variant");
 
-		finish();
-	}
+        finish();
+    }
 }

+ 3 - 2
app/src/green/java/ch/threema/app/utils/DownloadUtil.java

@@ -24,6 +24,7 @@ package ch.threema.app.utils;
 import android.content.Context;
 
 public class DownloadUtil {
-	// stub
-	public static void deleteOldAPKs(Context context) {	}
+    // stub
+    public static void deleteOldAPKs(Context context) {
+    }
 }

+ 31 - 19
app/src/green/res/drawable-v24/ic_launcher_foreground.xml

@@ -1,41 +1,53 @@
-<vector
-    xmlns:android="http://schemas.android.com/apk/res/android"
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
     android:width="108dp"
     android:height="108dp"
     android:viewportWidth="1500"
     android:viewportHeight="1500">
-      <group android:translateX="238"
-          android:translateY="238">
-<!--    <group>-->
+    <group
+        android:translateX="238"
+        android:translateY="238">
+        <!--    <group>-->
         <!-- background color of icon -->
-        <path android:fillColor="#FFF"
+        <path
+            android:fillColor="#FFF"
             android:fillType="evenOdd"
-            android:pathData="M0,0h1024v1024h-1024z" android:strokeColor="#00000000"/>
+            android:pathData="M0,0h1024v1024h-1024z"
+            android:strokeColor="#00000000" />
         <!-- sky color -->
-        <path android:fillColor="@color/ic_launcher_sky"
+        <path
+            android:fillColor="@color/ic_launcher_sky"
             android:fillType="evenOdd"
-            android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8ZZ" android:strokeColor="#00000000"/>
+            android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8ZZ"
+            android:strokeColor="#00000000" />
         <!-- shadow of the dune -->
         <group>
-            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z"/>
-            <path android:fillColor="@color/ic_launcher_shadow"
+            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z" />
+            <path
+                android:fillColor="@color/ic_launcher_shadow"
                 android:fillType="evenOdd"
-                android:pathData="M134.7,434.5C286.6,434.5 403.7,243 512,243C618.4,245.6 774.8,438.4 882,434.5L882,730L134.7,730L134.7,434.5ZZ" android:strokeColor="#00000000"/>
+                android:pathData="M134.7,434.5C286.6,434.5 403.7,243 512,243C618.4,245.6 774.8,438.4 882,434.5L882,730L134.7,730L134.7,434.5ZZ"
+                android:strokeColor="#00000000" />
         </group>
         <!-- right side of the dune -->
         <group>
-            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z"/>
-            <path android:fillColor="@color/ic_launcher_dune"
+            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z" />
+            <path
+                android:fillColor="@color/ic_launcher_dune"
                 android:fillType="evenOdd"
-                android:pathData="M479.9,248.7C544.4,229 572.5,307.3 459.2,389.2C346,471 182.8,668 479.9,730L882,730L882,434.5C769.9,434.5 594.9,204.7 479.9,248.7ZZ" android:strokeColor="#00000000"/>
+                android:pathData="M479.9,248.7C544.4,229 572.5,307.3 459.2,389.2C346,471 182.8,668 479.9,730L882,730L882,434.5C769.9,434.5 594.9,204.7 479.9,248.7ZZ"
+                android:strokeColor="#00000000" />
         </group>
         <!-- pad lock symbol -->
-        <path android:fillColor="#FFFFFF"
+        <path
+            android:fillColor="#FFFFFF"
             android:fillType="evenOdd"
-            android:pathData="M512,274C563.6,274 605.3,315.8 605.3,367.2L605.3,404.5L609,404.5C617.3,404.5 624,411.2 624,419.5L624,551C624,559.3 617.3,566 609,566L415,566C406.7,566 400,559.3 400,551L400,419.5C400,411.2 406.7,404.5 415,404.5L418.7,404.5L418.7,367.2C418.7,315.8 460.4,274 512,274ZZM512,311.3C481.1,311.3 456,336.3 456,367.2L456,404.5L568,404.5L568,367.2C568,336.3 542.9,311.3 512,311.3ZZ" android:strokeColor="#00000000"/>
+            android:pathData="M512,274C563.6,274 605.3,315.8 605.3,367.2L605.3,404.5L609,404.5C617.3,404.5 624,411.2 624,419.5L624,551C624,559.3 617.3,566 609,566L415,566C406.7,566 400,559.3 400,551L400,419.5C400,411.2 406.7,404.5 415,404.5L418.7,404.5L418.7,367.2C418.7,315.8 460.4,274 512,274ZZM512,311.3C481.1,311.3 456,336.3 456,367.2L456,404.5L568,404.5L568,367.2C568,336.3 542.9,311.3 512,311.3ZZ"
+            android:strokeColor="#00000000" />
         <!-- threema dots -->
-        <path android:fillColor="@color/ic_launcher_dots"
+        <path
+            android:fillColor="@color/ic_launcher_dots"
             android:fillType="evenOdd"
-            android:pathData="M568,848C568,878.9 542.9,904 511.9,904C481,904 456,878.9 456,848C456,817.1 481,792 511.9,792C542.9,792 568,817.1 568,848ZZM366,848C366,878.9 341,904 310,904C279.1,904 254,878.9 254,848C254,817.1 279.1,792 310,792C341,792 366,817.1 366,848ZZM769.9,848C769.9,878.9 744.9,904 713.9,904C683,904 658,878.9 658,848C658,817.1 683,792 713.9,792C744.9,792 769.9,817.1 769.9,848ZZ" android:strokeColor="#00000000"/>
+            android:pathData="M568,848C568,878.9 542.9,904 511.9,904C481,904 456,878.9 456,848C456,817.1 481,792 511.9,792C542.9,792 568,817.1 568,848ZZM366,848C366,878.9 341,904 310,904C279.1,904 254,878.9 254,848C254,817.1 279.1,792 310,792C341,792 366,817.1 366,848ZZM769.9,848C769.9,878.9 744.9,904 713.9,904C683,904 658,878.9 658,848C658,817.1 683,792 713.9,792C744.9,792 769.9,817.1 769.9,848ZZ"
+            android:strokeColor="#00000000" />
     </group>
 </vector>

+ 1 - 2
app/src/green/res/values/firebase_messaging.xml

@@ -1,5 +1,4 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
+<?xml version="1.0" encoding="utf-8"?><!--
   ~ Copyright (c) 2019-2024 Threema GmbH
   ~ All rights reserved.
   -->

+ 40 - 38
app/src/hms/AndroidManifest.xml

@@ -1,46 +1,48 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-          xmlns:tools="http://schemas.android.com/tools"
-          android:installLocation="internalOnly"
-          android:testOnly="false">
-	<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
-
-	<queries>
-		<package android:name="com.hisilicon.android.hiRMService" />
-		<intent>
-			<action android:name="com.apptouch.intent.action.update_hms" />
-		</intent>
-		<intent>
-			<action android:name="com.huawei.appmarket.intent.action.AppDetail" />
-		</intent>
-
-		<intent>
-			<action android:name="com.huawei.hms.core.aidlservice" />
-		</intent>
-
-		<intent>
-			<action android:name="com.huawei.hms.core" />
-		</intent>
-	</queries>
-
-	<application tools:ignore="GoogleAppIndexingWarning">
-
-		<activity
-			android:name="com.DrmSDK.DrmDialogActivity"
-			android:theme="@style/Theme.Threema.WithToolbar"/>
-
-		<service
-			android:name="ch.threema.app.push.PushService"
-			android:exported="false">
-			<intent-filter>
-				<action android:name="com.huawei.push.action.MESSAGING_EVENT" />
-			</intent-filter>
-		</service>
+    xmlns:tools="http://schemas.android.com/tools"
+    android:installLocation="internalOnly"
+    android:testOnly="false">
+
+    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
+
+    <queries>
+        <package android:name="com.hisilicon.android.hiRMService" />
+
+        <intent>
+            <action android:name="com.apptouch.intent.action.update_hms" />
+        </intent>
+        <intent>
+            <action android:name="com.huawei.appmarket.intent.action.AppDetail" />
+        </intent>
+
+        <intent>
+            <action android:name="com.huawei.hms.core.aidlservice" />
+        </intent>
+
+        <intent>
+            <action android:name="com.huawei.hms.core" />
+        </intent>
+    </queries>
+
+    <application tools:ignore="GoogleAppIndexingWarning">
+
+        <activity
+            android:name="com.DrmSDK.DrmDialogActivity"
+            android:theme="@style/Theme.Threema.WithToolbar" />
+
+        <service
+            android:name="ch.threema.app.push.PushService"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="com.huawei.push.action.MESSAGING_EVENT" />
+            </intent-filter>
+        </service>
 
         <meta-data
             android:name="com.huawei.hms.client.appid"
-            android:value="103713829"/>
+            android:value="103713829" />
 
-	</application>
+    </application>
 
 </manifest>

+ 63 - 63
app/src/hms/agconnect-services.json

@@ -1,65 +1,65 @@
 {
-	"agcgw":{
-		"backurl":"connect-dre.hispace.hicloud.com",
-		"url":"connect-dre.dbankcloud.cn",
-		"websocketbackurl":"connect-ws-dre.hispace.dbankcloud.com",
-		"websocketurl":"connect-ws-dre.hispace.dbankcloud.cn"
-	},
-	"agcgw_all":{
-		"CN":"connect-drcn.dbankcloud.cn",
-		"CN_back":"connect-drcn.hispace.hicloud.com",
-		"DE":"connect-dre.dbankcloud.cn",
-		"DE_back":"connect-dre.hispace.hicloud.com",
-		"RU":"connect-drru.hispace.dbankcloud.ru",
-		"RU_back":"connect-drru.hispace.dbankcloud.cn",
-		"SG":"connect-dra.dbankcloud.cn",
-		"SG_back":"connect-dra.hispace.hicloud.com"
-	},
-	"websocketgw_all":{
-		"CN":"connect-ws-drcn.hispace.dbankcloud.cn",
-		"CN_back":"connect-ws-drcn.hispace.dbankcloud.com",
-		"DE":"connect-ws-dre.hispace.dbankcloud.cn",
-		"DE_back":"connect-ws-dre.hispace.dbankcloud.com",
-		"RU":"connect-ws-drru.hispace.dbankcloud.ru",
-		"RU_back":"connect-ws-drru.hispace.dbankcloud.cn",
-		"SG":"connect-ws-dra.hispace.dbankcloud.cn",
-		"SG_back":"connect-ws-dra.hispace.dbankcloud.com"
-	},
-	"client":{
-		"cp_id":"5190041000024384032",
-		"product_id":"736430079244787738",
-		"client_id":"543649526116779072",
-		"project_id":"736430079244787738",
-		"app_id":"103713829",
-		"api_key":"CgB6e3x98OfTmUe8UCBVyRYd0YNHT43DjNTgXXxNV3MEWkr8+vKRC5vhyWbdX/JFZqDA+MTdmBPjCrx6YQWHm6aC",
-		"package_name":"ch.threema.app.hms"
-	},
-	"app_info":{
-		"app_id":"103713829",
-		"package_name":"ch.threema.app.hms"
-	},
-	"region":"DE",
-	"configuration_version":"3.0",
-	"appInfos":[
-		{
-			"package_name":"ch.threema.app.hms",
-			"client":{
-				"app_id":"103713829"
-			},
-			"app_info":{
-				"package_name":"ch.threema.app.hms",
-				"app_id":"103713829"
-			}
-		},
-		{
-			"package_name":"ch.threema.app.work.hms",
-			"client":{
-				"app_id":"103858571"
-			},
-			"app_info":{
-				"package_name":"ch.threema.app.work.hms",
-				"app_id":"103858571"
-			}
-		}
-	]
+    "agcgw": {
+        "backurl": "connect-dre.hispace.hicloud.com",
+        "url": "connect-dre.dbankcloud.cn",
+        "websocketbackurl": "connect-ws-dre.hispace.dbankcloud.com",
+        "websocketurl": "connect-ws-dre.hispace.dbankcloud.cn"
+    },
+    "agcgw_all": {
+        "CN": "connect-drcn.dbankcloud.cn",
+        "CN_back": "connect-drcn.hispace.hicloud.com",
+        "DE": "connect-dre.dbankcloud.cn",
+        "DE_back": "connect-dre.hispace.hicloud.com",
+        "RU": "connect-drru.hispace.dbankcloud.ru",
+        "RU_back": "connect-drru.hispace.dbankcloud.cn",
+        "SG": "connect-dra.dbankcloud.cn",
+        "SG_back": "connect-dra.hispace.hicloud.com"
+    },
+    "websocketgw_all": {
+        "CN": "connect-ws-drcn.hispace.dbankcloud.cn",
+        "CN_back": "connect-ws-drcn.hispace.dbankcloud.com",
+        "DE": "connect-ws-dre.hispace.dbankcloud.cn",
+        "DE_back": "connect-ws-dre.hispace.dbankcloud.com",
+        "RU": "connect-ws-drru.hispace.dbankcloud.ru",
+        "RU_back": "connect-ws-drru.hispace.dbankcloud.cn",
+        "SG": "connect-ws-dra.hispace.dbankcloud.cn",
+        "SG_back": "connect-ws-dra.hispace.dbankcloud.com"
+    },
+    "client": {
+        "cp_id": "5190041000024384032",
+        "product_id": "736430079244787738",
+        "client_id": "543649526116779072",
+        "project_id": "736430079244787738",
+        "app_id": "103713829",
+        "api_key": "CgB6e3x98OfTmUe8UCBVyRYd0YNHT43DjNTgXXxNV3MEWkr8+vKRC5vhyWbdX/JFZqDA+MTdmBPjCrx6YQWHm6aC",
+        "package_name": "ch.threema.app.hms"
+    },
+    "app_info": {
+        "app_id": "103713829",
+        "package_name": "ch.threema.app.hms"
+    },
+    "region": "DE",
+    "configuration_version": "3.0",
+    "appInfos": [
+        {
+            "package_name": "ch.threema.app.hms",
+            "client": {
+                "app_id": "103713829"
+            },
+            "app_info": {
+                "package_name": "ch.threema.app.hms",
+                "app_id": "103713829"
+            }
+        },
+        {
+            "package_name": "ch.threema.app.work.hms",
+            "client": {
+                "app_id": "103858571"
+            },
+            "app_info": {
+                "package_name": "ch.threema.app.work.hms",
+                "app_id": "103858571"
+            }
+        }
+    ]
 }

+ 9 - 9
app/src/hms/java/ch/threema/app/activities/DownloadApkActivity.java

@@ -30,17 +30,17 @@ import androidx.appcompat.app.AppCompatActivity;
 import ch.threema.base.utils.LoggingUtil;
 
 public class DownloadApkActivity extends AppCompatActivity {
-	public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
-	// stub
+    public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
+    // stub
 
-	private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
+    private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
 
-	@Override
-	protected void onCreate(@Nullable Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
 
-		logger.error("This activity may not be used in this build variant");
+        logger.error("This activity may not be used in this build variant");
 
-		finish();
-	}
+        finish();
+    }
 }

+ 3 - 2
app/src/hms/java/ch/threema/app/utils/DownloadUtil.java

@@ -24,6 +24,7 @@ package ch.threema.app.utils;
 import android.content.Context;
 
 public class DownloadUtil {
-	// stub
-	public static void deleteOldAPKs(Context context) {	}
+    // stub
+    public static void deleteOldAPKs(Context context) {
+    }
 }

+ 3 - 3
app/src/hms_services_based/java/ch/threema/app/activities/VoiceActionActivity.java

@@ -25,7 +25,7 @@ import android.app.Activity;
 
 public class VoiceActionActivity extends Activity {
 
-	public VoiceActionActivity() {
-		// stub, no voice assistant api in hms build
-	}
+    public VoiceActionActivity() {
+        // stub, no voice assistant api in hms build
+    }
 }

+ 29 - 28
app/src/hms_services_based/java/ch/threema/app/licensing/StoreLicenseCheck.java

@@ -34,36 +34,37 @@ import ch.threema.app.services.UserService;
 import ch.threema.base.utils.LoggingUtil;
 
 public class StoreLicenseCheck implements CheckLicenseRoutine.StoreLicenseChecker {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("StoreLicenseCheck");
+    private static final Logger logger = LoggingUtil.getThreemaLogger("StoreLicenseCheck");
 
-	private static final String HMS_ID = "5190041000024384032";
-	private static final String HMS_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA26ccdC7mLHomHTnKvSRGg7Vuex19xD3qv8CEOUj5lcT5Z81ARby5CVhM/ZM9zKCQcrKmenn1aih6X+uZoNsvBziDUySkrzXPTX/NfoFDQlHgyXan/xsoIPlE1v0D9dLV7fgPOllHxmN8wiwF+woACo3ao/ra2VY38PCZTmfMX/V+hOLHsdRakgWVshzeYTtzMjlLrnYOp5AFXEjFhF0dB92ozAmLzjFJtwyMdpbVD+yRVr+fnLJ6ADhBpoKLjvpn8A7PhpT5wsvogovdr16u/uKhPy5an4DXE0bjWc76bE2SEse/bQTvPoGRw5TjHVWi7uDMFSz3OOGUqLSygucPdwIDAQAB";
+    private static final String HMS_ID = "5190041000024384032";
+    private static final String HMS_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA26ccdC7mLHomHTnKvSRGg7Vuex19xD3qv8CEOUj5lcT5Z81ARby5CVhM/ZM9zKCQcrKmenn1aih6X+uZoNsvBziDUySkrzXPTX/NfoFDQlHgyXan/xsoIPlE1v0D9dLV7fgPOllHxmN8wiwF+woACo3ao/ra2VY38PCZTmfMX/V+hOLHsdRakgWVshzeYTtzMjlLrnYOp5AFXEjFhF0dB92ozAmLzjFJtwyMdpbVD+yRVr+fnLJ6ADhBpoKLjvpn8A7PhpT5wsvogovdr16u/uKhPy5an4DXE0bjWc76bE2SEse/bQTvPoGRw5TjHVWi7uDMFSz3OOGUqLSygucPdwIDAQAB";
 
-	private StoreLicenseCheck() {}
+    private StoreLicenseCheck() {
+    }
 
-	public static void checkLicense(Context context, UserService userService) {
-		logger.debug("Check HMS license");
-		DrmCheckCallback callback = new DrmCheckCallback() {
-			@Override
-			public void onCheckSuccess(String signData, String signature) {
-				logger.info("HMS License OK");
-				userService.setPolicyResponse(
-					signData,
-					signature,
-					0
-				);
-			}
+    public static void checkLicense(Context context, UserService userService) {
+        logger.debug("Check HMS license");
+        DrmCheckCallback callback = new DrmCheckCallback() {
+            @Override
+            public void onCheckSuccess(String signData, String signature) {
+                logger.info("HMS License OK");
+                userService.setPolicyResponse(
+                    signData,
+                    signature,
+                    0
+                );
+            }
 
-			@Override
-			public void onCheckFailed(int errorCode) {
-				logger.debug("HMS License failed errorCode: {}", errorCode);
-				userService.setPolicyResponse(
-					null,
-					null,
-					errorCode
-				);
-			}
-		};
-		Drm.check((Activity) context, context.getPackageName(), HMS_ID, HMS_PUBLIC_KEY, callback);
-	}
+            @Override
+            public void onCheckFailed(int errorCode) {
+                logger.debug("HMS License failed errorCode: {}", errorCode);
+                userService.setPolicyResponse(
+                    null,
+                    null,
+                    errorCode
+                );
+            }
+        };
+        Drm.check((Activity) context, context.getPackageName(), HMS_ID, HMS_PUBLIC_KEY, callback);
+    }
 }

+ 4 - 1
app/src/hms_services_based/java/ch/threema/app/push/HmsTokenUtil.kt

@@ -56,7 +56,10 @@ object HmsTokenUtil {
                 .getString(APP_ID_CONFIG_FIELD)
                 ?: appIdHardcoded
         } catch (e: Exception) {
-            logger.error("Could not obtain HMS-App-ID from config file. Fallback to hardcoded ID.", e)
+            logger.error(
+                "Could not obtain HMS-App-ID from config file. Fallback to hardcoded ID.",
+                e
+            )
             appIdHardcoded
         }
     }

+ 75 - 73
app/src/hms_services_based/java/ch/threema/app/push/PushService.java

@@ -46,77 +46,79 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushService extends HmsMessageService {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("PushService");
-
-	public static void deleteToken(Context context) {
-		logger.info("Delete HMS push token");
-		try {
-			String appId = Objects.requireNonNull(HmsTokenUtil.getHmsAppId(context));
-			HmsInstanceId.getInstance(ThreemaApplication.getAppContext()).deleteToken(appId, HmsTokenUtil.TOKEN_SCOPE);
-			PushUtil.sendTokenToServer("", ProtocolDefines.PUSHTOKEN_TYPE_NONE);
-		} catch (ApiException | ThreemaException | NullPointerException e) {
-			logger.error("Could not delete hms token", e);
-		}
-	}
-
-	@Override
-	public void onNewToken(@Nullable String token) {
-		logger.info("New HMS token received");
-		try {
-			String formattedToken = HmsTokenUtil.obtainAndPrependHmsAppId(getApplicationContext(), token);
-			if (formattedToken != null) {
-				PushUtil.sendTokenToServer(formattedToken, ProtocolDefines.PUSHTOKEN_TYPE_HMS);
-			} else {
-				logger.warn("Could not send new token to server: app id could not be prepended or token is null");
-			}
-		} catch (ThreemaException e) {
-			logger.error("Could not send token to server ", e);
-		}
-	}
-
-	@Override
-	public void onMessageReceived(RemoteMessage remoteMessage) {
-		logger.info("Handling incoming HMS message.");
-
-		RuntimeUtil.runInWakelock(getApplicationContext(), DateUtils.MINUTE_IN_MILLIS * 10, "PushService", () -> processHMSMessage(remoteMessage));
-	}
-
-	private void processHMSMessage(RemoteMessage remoteMessage) {
-		logger.info("Received HMS message: {}", remoteMessage.getMessageId());
-		// Log message sent time
-		try {
-			Date sentDate = new Date(remoteMessage.getSentTime());
-			logger.info("*** Message sent     :  {}", sentDate);
-			logger.info("*** Message received : {}", new Date());
-			logger.info("*** Original priority: {}", remoteMessage.getOriginalUrgency());
-			logger.info("*** Current priority: {}", remoteMessage.getUrgency());
-		} catch (Exception ignore) {
-		}
-
-		Map<String, String> data = remoteMessage.getDataOfMap();
-		PushUtil.processRemoteMessage(data);
-	}
-
-	// following services check are handled here and not in ConfigUtils to minimize number of duplicating classes
-	/**
-	 * check for specific huawei services
-	 */
-	public static boolean hmsServicesInstalled(Context context) {
-		return RuntimeUtil.isInTest() || (HuaweiMobileServicesUtil.isHuaweiMobileServicesAvailable(context) == ConnectionResult.SUCCESS);
-	}
-
-	/**
-	 * check for specific google services
-	 * @noinspection unused
-	 */
-	public static boolean playServicesInstalled(Context context) {
-		return false;
-	}
-
-	/**
-	 * check for available push service
-	 */
-	public static boolean servicesInstalled(Context context) {
-		return playServicesInstalled(context) || hmsServicesInstalled(context);
-	}
+    private static final Logger logger = LoggingUtil.getThreemaLogger("PushService");
+
+    public static void deleteToken(Context context) {
+        logger.info("Delete HMS push token");
+        try {
+            String appId = Objects.requireNonNull(HmsTokenUtil.getHmsAppId(context));
+            HmsInstanceId.getInstance(ThreemaApplication.getAppContext()).deleteToken(appId, HmsTokenUtil.TOKEN_SCOPE);
+            PushUtil.sendTokenToServer("", ProtocolDefines.PUSHTOKEN_TYPE_NONE);
+        } catch (ApiException | ThreemaException | NullPointerException e) {
+            logger.error("Could not delete hms token", e);
+        }
+    }
+
+    @Override
+    public void onNewToken(@Nullable String token) {
+        logger.info("New HMS token received");
+        try {
+            String formattedToken = HmsTokenUtil.obtainAndPrependHmsAppId(getApplicationContext(), token);
+            if (formattedToken != null) {
+                PushUtil.sendTokenToServer(formattedToken, ProtocolDefines.PUSHTOKEN_TYPE_HMS);
+            } else {
+                logger.warn("Could not send new token to server: app id could not be prepended or token is null");
+            }
+        } catch (ThreemaException e) {
+            logger.error("Could not send token to server ", e);
+        }
+    }
+
+    @Override
+    public void onMessageReceived(RemoteMessage remoteMessage) {
+        logger.info("Handling incoming HMS message.");
+
+        RuntimeUtil.runInWakelock(getApplicationContext(), DateUtils.MINUTE_IN_MILLIS * 10, "PushService", () -> processHMSMessage(remoteMessage));
+    }
+
+    private void processHMSMessage(RemoteMessage remoteMessage) {
+        logger.info("Received HMS message: {}", remoteMessage.getMessageId());
+        // Log message sent time
+        try {
+            Date sentDate = new Date(remoteMessage.getSentTime());
+            logger.info("*** Message sent     :  {}", sentDate);
+            logger.info("*** Message received : {}", new Date());
+            logger.info("*** Original priority: {}", remoteMessage.getOriginalUrgency());
+            logger.info("*** Current priority: {}", remoteMessage.getUrgency());
+        } catch (Exception ignore) {
+        }
+
+        Map<String, String> data = remoteMessage.getDataOfMap();
+        PushUtil.processRemoteMessage(data);
+    }
+
+    // following services check are handled here and not in ConfigUtils to minimize number of duplicating classes
+
+    /**
+     * check for specific huawei services
+     */
+    public static boolean hmsServicesInstalled(Context context) {
+        return RuntimeUtil.isInTest() || (HuaweiMobileServicesUtil.isHuaweiMobileServicesAvailable(context) == ConnectionResult.SUCCESS);
+    }
+
+    /**
+     * check for specific google services
+     *
+     * @noinspection unused
+     */
+    public static boolean playServicesInstalled(Context context) {
+        return false;
+    }
+
+    /**
+     * check for available push service
+     */
+    public static boolean servicesInstalled(Context context) {
+        return playServicesInstalled(context) || hmsServicesInstalled(context);
+    }
 }

+ 8 - 8
app/src/hms_services_based/java/ch/threema/app/services/VoiceActionService.java

@@ -28,13 +28,13 @@ import android.os.IBinder;
 import androidx.annotation.Nullable;
 
 public class VoiceActionService extends Service {
-	public VoiceActionService() {
-		// stub, no voice assistant api in hms build
-	}
+    public VoiceActionService() {
+        // stub, no voice assistant api in hms build
+    }
 
-	@Nullable
-	@Override
-	public IBinder onBind(Intent intent) {
-		return null;
-	}
+    @Nullable
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
 }

+ 2 - 2
app/src/hms_services_based/java/com/DrmSDK/DialogTrigger.java

@@ -24,13 +24,13 @@ import org.slf4j.LoggerFactory;
 
 /**
  * 弹框触发器
- *Pop-up trigger
+ * Pop-up trigger
  *
  * @since 2020/07/01
  */
 public final class DialogTrigger {
 
-	private final Logger logger = LoggerFactory.getLogger(DialogTrigger.class);
+    private final Logger logger = LoggerFactory.getLogger(DialogTrigger.class);
     /**
      * 单例
      * singleton

+ 1 - 0
app/src/hms_services_based/java/com/DrmSDK/Drm.java

@@ -31,6 +31,7 @@ import com.DrmSDK.util.DeviceSession;
 public class Drm {
 
     private static final String TAG = "DrmLite";
+
     /**
      * Checking whether user has purchased this application or not.
      *

+ 1 - 1
app/src/hms_services_based/java/com/DrmSDK/DrmCheckCallback.java

@@ -27,7 +27,7 @@ public interface DrmCheckCallback {
      * Check successfully in the SDK,the developer can uses the callback parameters signData and signature
      * to carry out their own business processing, such as visiting their own business server for signature verification.
      *
-     * @param signData signed data for verification
+     * @param signData  signed data for verification
      * @param signature server signature
      */
     void onCheckSuccess(String signData, String signature);

+ 6 - 6
app/src/hms_services_based/java/com/DrmSDK/DrmDialogActivity.java

@@ -36,7 +36,7 @@ import org.slf4j.LoggerFactory;
  * @since 2020/07/01
  */
 public class DrmDialogActivity extends Activity implements DialogObserver {
-	private final Logger logger = LoggerFactory.getLogger(DrmDialogActivity.class);
+    private final Logger logger = LoggerFactory.getLogger(DrmDialogActivity.class);
     /**
      * 透明状态栏属性
      * Transparent Status Bar Properties
@@ -58,7 +58,7 @@ public class DrmDialogActivity extends Activity implements DialogObserver {
         super.onCreate(savedInstanceState);
         View view = new View(this);
         view.setBackgroundColor(getResources().getColor(
-                android.R.color.transparent));
+            android.R.color.transparent));
         setContentView(view);
         // 根据传递的Action启动Activity
         // Initiate an activity based on the transferred action.
@@ -83,7 +83,7 @@ public class DrmDialogActivity extends Activity implements DialogObserver {
         }
 
         int dialog = data.getInt(Constants.KEY_EXTRA_DIALOG,
-                ViewHelper.NO_DIALOG_INFO);
+            ViewHelper.NO_DIALOG_INFO);
         logger.info("DrmDialogActivity dialog" + dialog);
         String extra = data.getString(Constants.KEY_EXTRA_EXTRA);
         switch (dialog) {
@@ -126,7 +126,7 @@ public class DrmDialogActivity extends Activity implements DialogObserver {
                 break;
             default:
                 int errorCode = data.getInt(Constants.KEY_EXTRA_CODE,
-                        DrmStatusCodes.CODE_DEFAULT);
+                    DrmStatusCodes.CODE_DEFAULT);
                 ViewHelper.showDailog(this, dialog, extra, errorCode);
                 break;
         }
@@ -194,8 +194,8 @@ public class DrmDialogActivity extends Activity implements DialogObserver {
         try {
             Window w = activity.getWindow();
             HwInvoke.invokeFun(w.getClass(), w, "setHwFloating", new Class[]
-                    {boolean.class}, new Object[]
-                    {boolHwFloating});
+                {boolean.class}, new Object[]
+                {boolHwFloating});
             return true;
         } catch (Exception e) {
             Log.e("DrmDialogActivity", "Exception");

+ 59 - 59
app/src/hms_services_based/java/com/DrmSDK/DrmKernel.java

@@ -187,10 +187,10 @@ public class DrmKernel {
      * Connectivity to markets connected via AIDL
      */
     private static DrmServiceConnection conn = new DrmServiceConnection();
-	/**
-	 * Coherent slf4j logger
-	 */
-	private static final Logger logger = LoggerFactory.getLogger(DrmKernel.class);
+    /**
+     * Coherent slf4j logger
+     */
+    private static final Logger logger = LoggerFactory.getLogger(DrmKernel.class);
 
     /**
      * Checking whether user has purchased this application or not.
@@ -203,7 +203,7 @@ public class DrmKernel {
      * @param callback        Public key that assigned by The Huawei Developer.
      */
     public static void check(Activity activity, String pkgName, String drmId, String publicKey, String appStoreName,
-                                Boolean showErrorDailog, DrmCheckCallback callback) {
+                             Boolean showErrorDailog, DrmCheckCallback callback) {
         sCallback = callback;
         // Multiple authentication operations cannot be performed at the same time.
         if (sIsRunning) {
@@ -257,14 +257,14 @@ public class DrmKernel {
             case Constants.RESULT_CODE_AGREEMENT_DECLINED:
                 logger.info("RESULT_CODE_AGREEMENT_DECLINED Hiapp");
                 report(ReportUtils.KEY_GET_SIGN_FAILED,
-                        ReportUtils.CODE_HIAPP_AGREEMENT_DECLINED, null);
+                    ReportUtils.CODE_HIAPP_AGREEMENT_DECLINED, null);
                 showDialogOrReturnCode(ViewHelper.DIALOG_HIAPP_AGREEMENT_DECLINED, DrmStatusCodes.CODE_PROTOCAL_UNAGREE);
                 break;
             // 不登陆不能继续使用程序
             // You cannot continue using the program without logging in.
             case Constants.RESULT_CODE_LOGIN_FAILED:
                 report(ReportUtils.KEY_GET_SIGN_FAILED,
-                        ReportUtils.CODE_ACCOUNT_NOT_LOGGED, null);
+                    ReportUtils.CODE_ACCOUNT_NOT_LOGGED, null);
                 showDialogOrReturnCode(ViewHelper.DIALOG_NOT_LOGGED, DrmStatusCodes.CODE_LOGIN_FAILED);
                 break;
             case Constants.RESULT_CODE_JOIN_MEMBER_FAILED:
@@ -275,7 +275,7 @@ public class DrmKernel {
             case Activity.RESULT_CANCELED:
                 sIsInterrupt = true;
                 report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                        ReportUtils.CODE_USER_INTERRUPT, null);
+                    ReportUtils.CODE_USER_INTERRUPT, null);
                 unbindService();
                 showDialogOrReturnCode(ViewHelper.DIALOG_USER_INTERRUPT, DrmStatusCodes.CODE_CANCEL);
                 break;
@@ -321,7 +321,7 @@ public class DrmKernel {
                     logger.debug("downloadurl: " + downloadurl);
                     try {
                         Intent intent = new Intent(Intent.ACTION_VIEW,
-                                Uri.parse(downloadurl));
+                            Uri.parse(downloadurl));
                         sActivity.startActivity(intent);
                     } catch (ActivityNotFoundException e) {
                         logger.error("OPERATION_INSTALL ActivityNotFoundException!");
@@ -333,7 +333,7 @@ public class DrmKernel {
             case ViewHelper.OPERATION_USER_INTERRUPT:
                 sIsInterrupt = true;
                 report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                        ReportUtils.CODE_USER_INTERRUPT, null);
+                    ReportUtils.CODE_USER_INTERRUPT, null);
                 unbindService();
                 showDialogOrReturnCode(ViewHelper.DIALOG_USER_INTERRUPT, DrmStatusCodes.CODE_CANCEL);
                 break;
@@ -344,16 +344,16 @@ public class DrmKernel {
                     // 获取HMS包名
                     // Obtaining the HMS Package Name
                     List<ResolveInfo> resolveInfoList = sActivity.getApplicationContext()
-                            .getPackageManager()
-                            .queryIntentServices(new Intent("com.huawei.hms.core.aidlservice"),
-                                    PackageManager.GET_META_DATA);
+                        .getPackageManager()
+                        .queryIntentServices(new Intent("com.huawei.hms.core.aidlservice"),
+                            PackageManager.GET_META_DATA);
                     if (resolveInfoList != null && resolveInfoList.size() > 0) {
-                    	ResolveInfo resolveInfo = resolveInfoList.get(0);
-                    	String packageName = "";
+                        ResolveInfo resolveInfo = resolveInfoList.get(0);
+                        String packageName = "";
 
-                    	if (resolveInfo != null) {
-		                    packageName = resolveInfo.serviceInfo.applicationInfo.packageName;
-	                    }
+                        if (resolveInfo != null) {
+                            packageName = resolveInfo.serviceInfo.applicationInfo.packageName;
+                        }
 
                         if (!TextUtils.isEmpty(packageName)) {
                             Intent intent = new Intent("com.huawei.hwid.ACTION_MAIN_SETTINGS");
@@ -417,7 +417,7 @@ public class DrmKernel {
         serviceIntent.setPackage(pkgName);
 
         isSuccess = sActivity.getApplicationContext().bindService(serviceIntent, conn,
-                Context.BIND_AUTO_CREATE);
+            Context.BIND_AUTO_CREATE);
         sIsBound = isSuccess;
         return isSuccess;
     }
@@ -448,8 +448,8 @@ public class DrmKernel {
      * @throws SignatureException
      */
     private static boolean verify(String payDeviceId, String publicKey, String signData, String sign)
-            throws NoSuchAlgorithmException, InvalidKeySpecException, InvalidKeyException,
-            SignatureException, UnsupportedEncodingException, IllegalArgumentException {
+        throws NoSuchAlgorithmException, InvalidKeySpecException, InvalidKeyException,
+        SignatureException, UnsupportedEncodingException, IllegalArgumentException {
 
         if (TextUtils.isEmpty(publicKey) || TextUtils.isEmpty(sign)) {
             logger.error("publicKey empty or sign empty");
@@ -467,10 +467,10 @@ public class DrmKernel {
         }
 
         PublicKey pubKey = keyFactory.generatePublic(new X509EncodedKeySpec(
-                encodedKey));
+            encodedKey));
 
         java.security.Signature signature = java.security.Signature
-                .getInstance("SHA256WithRSA");
+            .getInstance("SHA256WithRSA");
         signature.initVerify(pubKey);
         signature.update(signData.getBytes(CHARSET));
 
@@ -525,11 +525,11 @@ public class DrmKernel {
     private static void showDialogWhenStoreNotAvailable() {
         if (NetUtils.isNetworkAvailable(sActivity)) {
             report(ReportUtils.KEY_GET_SIGN_FAILED,
-                    ReportUtils.CODE_STORE_NOT_AVAILABLE, null);
+                ReportUtils.CODE_STORE_NOT_AVAILABLE, null);
             showDialogOrReturnCode(ViewHelper.DIALOG_STORE_NOT_AVAILABLE, DrmStatusCodes.CODE_APP_UNSUPPORT);
         } else {
             report(ReportUtils.KEY_GET_SIGN_FAILED,
-                    ReportUtils.CODE_NO_NETWORK, null);
+                ReportUtils.CODE_NO_NETWORK, null);
             showDialogOrReturnCode(ViewHelper.DIALOG_NO_NETWORK, DrmStatusCodes.CODE_NEED_INTERNET);
         }
     }
@@ -547,7 +547,7 @@ public class DrmKernel {
         if (result == null) {
             logger.error("result empty");
             report(ReportUtils.KEY_GET_SIGN_FAILED,
-                    ReportUtils.CODE_GET_SIGN_FAILED, null);
+                ReportUtils.CODE_GET_SIGN_FAILED, null);
             showDialogOrReturnCode(ViewHelper.DIALOG_GET_DRM_SIGN_FAILED, DrmStatusCodes.CODE_DEFAULT);
             // 解除service绑定
             // Unbind a service.
@@ -605,7 +605,7 @@ public class DrmKernel {
         if (signList == null || signList.size() == 0) {
             logger.error("signList empty");
             report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                    ReportUtils.CODE_SIGN_EMPTY, null);
+                ReportUtils.CODE_SIGN_EMPTY, null);
             showDialogOrReturnCode(ViewHelper.DIALOG_GET_DRM_SIGN_FAILED, DrmStatusCodes.CODE_DEFAULT);
             return;
         }
@@ -616,7 +616,7 @@ public class DrmKernel {
             if (map == null) {
                 logger.error("map empty");
                 report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                        ReportUtils.CODE_SIGN_EMPTY, null);
+                    ReportUtils.CODE_SIGN_EMPTY, null);
                 showDialogOrReturnCode(ViewHelper.DIALOG_GET_DRM_SIGN_FAILED, DrmStatusCodes.CODE_DEFAULT);
                 return;
             }
@@ -625,40 +625,40 @@ public class DrmKernel {
             String signItem = (String) map.get("signItem");
             // 构造验签的明文信息(顺序不能改变)
             String signData = payDeviceId + sPkgName + sDeveloperId + ts;
-	        final String formattedSignData = payDeviceId + '|' + sPkgName + '|' + sDeveloperId + '|' + ts;
+            final String formattedSignData = payDeviceId + '|' + sPkgName + '|' + sDeveloperId + '|' + ts;
 
             // 验签
             try {
                 verifyResult = verify(payDeviceId,
-                        sPublicKey, signData, signItem) || verifyResult;
+                    sPublicKey, signData, signItem) || verifyResult;
 
             } catch (InvalidKeyException e) {
                 report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                        ReportUtils.CODE_CHECK_FAILED, "InvalidKeyException");
+                    ReportUtils.CODE_CHECK_FAILED, "InvalidKeyException");
                 logger.error("InvalidKeyException ");
             } catch (NoSuchAlgorithmException e) {
                 report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                        ReportUtils.CODE_CHECK_FAILED,
-                        "NoSuchAlgorithmException");
+                    ReportUtils.CODE_CHECK_FAILED,
+                    "NoSuchAlgorithmException");
                 logger.error("NoSuchAlgorithmException ");
             } catch (InvalidKeySpecException e) {
                 report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                        ReportUtils.CODE_CHECK_FAILED,
-                        "InvalidKeySpecException");
+                    ReportUtils.CODE_CHECK_FAILED,
+                    "InvalidKeySpecException");
                 logger.error("InvalidKeySpecException ");
             } catch (SignatureException e) {
                 report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                        ReportUtils.CODE_CHECK_FAILED, "SignatureException");
+                    ReportUtils.CODE_CHECK_FAILED, "SignatureException");
                 logger.error("SignatureException ");
             } catch (UnsupportedEncodingException e) {
                 report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                        ReportUtils.CODE_CHECK_FAILED,
-                        "UnsupportedEncodingException");
+                    ReportUtils.CODE_CHECK_FAILED,
+                    "UnsupportedEncodingException");
                 logger.error("UnsupportedEncodingException ");
             } catch (IllegalArgumentException e) {
                 report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                        ReportUtils.CODE_CHECK_FAILED,
-                        "IllegalArgumentException");
+                    ReportUtils.CODE_CHECK_FAILED,
+                    "IllegalArgumentException");
                 logger.error("IllegalArgumentException:", e);
             }
             // 验签通过的话,直接返回
@@ -666,7 +666,7 @@ public class DrmKernel {
             if (verifyResult) {
                 logger.info("verifyResult success");
                 report(ReportUtils.KEY_CHECK_SIGN_SUCCESS,
-                        ReportUtils.CODE_SUCCESS, null);
+                    ReportUtils.CODE_SUCCESS, null);
                 final String successTips = (String) result.get("success_tips");
                 if (!TextUtils.isEmpty(successTips)) {
                     sActivity.runOnUiThread(new Runnable() {
@@ -685,11 +685,11 @@ public class DrmKernel {
         if (sTimes > 0) {
             logger.error("verifyResult failure CODE_SIGN_INVALID_WITH_INTERNET");
             report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                    ReportUtils.CODE_SIGN_INVALID_WITH_INTERNET, null);
+                ReportUtils.CODE_SIGN_INVALID_WITH_INTERNET, null);
         } else {
             logger.error("verifyResult failure CODE_CHECK_FAILED");
             report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                    ReportUtils.CODE_CHECK_FAILED, null);
+                ReportUtils.CODE_CHECK_FAILED, null);
         }
         showDialogOrReturnCode(ViewHelper.DIALOG_CHECK_FAILED, DrmStatusCodes.CODE_NO_ORDER);
     }
@@ -705,7 +705,7 @@ public class DrmKernel {
         if (ts == null || "".equals(ts)) {
             logger.error("ts empty");
             report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                    ReportUtils.CODE_TS_INVALID, null);
+                ReportUtils.CODE_TS_INVALID, null);
             showDialogOrReturnCode(ViewHelper.DIALOG_GET_DRM_SIGN_FAILED, DrmStatusCodes.CODE_DEFAULT);
             return;
         }
@@ -716,7 +716,7 @@ public class DrmKernel {
         } catch (NumberFormatException e) {
             logger.error("NumberFormatException");
             report(ReportUtils.KEY_CHECK_SIGN_FAILED,
-                    ReportUtils.CODE_TS_INVALID, "NumberFormatException");
+                ReportUtils.CODE_TS_INVALID, "NumberFormatException");
             showDialogOrReturnCode(ViewHelper.DIALOG_GET_DRM_SIGN_FAILED, DrmStatusCodes.CODE_DEFAULT);
             return;
         }
@@ -756,19 +756,19 @@ public class DrmKernel {
             // Signature obtained successfully.
             case DrmStatusCodes.CODE_SUCCESS:
                 report(ReportUtils.KEY_GET_SIGN_SUCCESS, ReportUtils.CODE_SUCCESS,
-                        null);
+                    null);
                 handleResultSuccess(result);
                 break;
             // No network connection
             case DrmStatusCodes.CODE_NEED_INTERNET:
                 report(ReportUtils.KEY_GET_SIGN_FAILED,
-                        ReportUtils.CODE_NO_NETWORK, null);
+                    ReportUtils.CODE_NO_NETWORK, null);
                 showDialogOrReturnCode(ViewHelper.DIALOG_NO_NETWORK, code);
                 break;
             // Failed to log in to the HUAWEI ID.
             case DrmStatusCodes.CODE_LOGIN_FAILED:
                 report(ReportUtils.KEY_GET_SIGN_FAILED,
-                        ReportUtils.CODE_STORE_NOT_LOGGED, null);
+                    ReportUtils.CODE_STORE_NOT_LOGGED, null);
                 handleResultLoginFail(code);
                 break;
             case DrmStatusCodes.CODE_NOT_LOGIN:
@@ -777,7 +777,7 @@ public class DrmKernel {
             case DrmStatusCodes.CODE_PROTOCAL_UNAGREE:
                 logger.info("RETURN_CODE_PROTOCAL_DISAGREE Hiapp");
                 report(ReportUtils.KEY_GET_SIGN_FAILED,
-                        ReportUtils.CODE_HIAPP_AGREEMENT_DECLINED, null);
+                    ReportUtils.CODE_HIAPP_AGREEMENT_DECLINED, null);
                 showDialogOrReturnCode(ViewHelper.DIALOG_HIAPP_AGREEMENT_DECLINED, code);
                 break;
             // The activity needs to be started, and the app store needs to be started if the agreement is not agreed or the app store is not logged in.
@@ -799,20 +799,20 @@ public class DrmKernel {
                 sAppAction = (String) result.get("activity_action");
                 String extra = (String) result.get("account_name");
                 report(ReportUtils.KEY_GET_SIGN_FAILED, ReportUtils.CODE_NO_ORDER,
-                        null);
+                    null);
                 showDialogOrReturnCode(ViewHelper.DIALOG_CHECK_FAILED, extra, code);
                 break;
             case DrmStatusCodes.CODE_OVER_LIMIT:
                 String nameextra = (String) result.get("account_name");
                 report(ReportUtils.KEY_GET_SIGN_FAILED, ReportUtils.CODE_OVER_LIMIT,
-                        null);
+                    null);
                 showDialogOrReturnCode(ViewHelper.DIALOG_OVER_LIMIT, nameextra, code);
 
                 break;
             // Other Errors
             default:
                 report(ReportUtils.KEY_GET_SIGN_FAILED, ReportUtils.CODE_OTHERS,
-                        null);
+                    null);
                 showDialogOrReturnCode(ViewHelper.DIALOG_UNKNOW_ERROR, code + "", code);
                 break;
         }
@@ -875,18 +875,18 @@ public class DrmKernel {
             String[] args = null;
             if (ReportUtils.KEY_GET_SIGN_SUCCESS.equals(key)) {
                 args = new String[]
-                        {VERSION_CODE, sPkgName, sDeveloperId, String.valueOf(code)};
+                    {VERSION_CODE, sPkgName, sDeveloperId, String.valueOf(code)};
             } else if (ReportUtils.KEY_CHECK_SIGN_SUCCESS.equals(key)) {
                 args = new String[]
-                        {VERSION_CODE, sPkgName, sDeveloperId, String.valueOf(code),
-                                sPayDeviceId};
+                    {VERSION_CODE, sPkgName, sDeveloperId, String.valueOf(code),
+                        sPayDeviceId};
             } else if (ReportUtils.KEY_GET_SIGN_FAILED.equals(key)) {
                 args = new String[]
-                        {VERSION_CODE, sPkgName, sDeveloperId, String.valueOf(code)};
+                    {VERSION_CODE, sPkgName, sDeveloperId, String.valueOf(code)};
             } else if (ReportUtils.KEY_CHECK_SIGN_FAILED.equals(key)) {
                 args = new String[]
-                        {VERSION_CODE, sPkgName, sDeveloperId, String.valueOf(code),
-                                sPayDeviceId, reason};
+                    {VERSION_CODE, sPkgName, sDeveloperId, String.valueOf(code),
+                        sPayDeviceId, reason};
             }
             Map params = ReportUtils.generateReport(key, args);
             if (params != null && sSignService != null) {
@@ -931,7 +931,7 @@ public class DrmKernel {
             } catch (RemoteException e) {
                 logger.error("RemoteException");
                 report(ReportUtils.KEY_GET_SIGN_FAILED,
-                        ReportUtils.CODE_GET_SIGN_FAILED, null);
+                    ReportUtils.CODE_GET_SIGN_FAILED, null);
                 showDialogOrReturnCode(ViewHelper.DIALOG_GET_DRM_SIGN_FAILED, DrmStatusCodes.CODE_DEFAULT);
             }
 

+ 10 - 10
app/src/hms_services_based/java/com/DrmSDK/EMUISupportUtil.java

@@ -34,7 +34,7 @@ import org.slf4j.LoggerFactory;
  * @since 2020/07/01
  */
 public final class EMUISupportUtil {
-	private static final Logger logger = LoggerFactory.getLogger(EMUISupportUtil.class);
+    private static final Logger logger = LoggerFactory.getLogger(EMUISupportUtil.class);
 
     private static final String PATTERN = "^EmotionUI_[1-9]{1}";
 
@@ -168,19 +168,19 @@ public final class EMUISupportUtil {
 
         } catch (IllegalArgumentException iAE) {
             logger.error("IllegalArgumentException get system properties error!",
-                    iAE);
+                iAE);
         } catch (ClassNotFoundException iAE) {
-	        logger.error("ClassNotFoundException get system properties error!",
-                    iAE);
+            logger.error("ClassNotFoundException get system properties error!",
+                iAE);
         } catch (NoSuchMethodException iAE) {
-	        logger.error("NoSuchMethodException get system properties error!",
-                    iAE);
+            logger.error("NoSuchMethodException get system properties error!",
+                iAE);
         } catch (IllegalAccessException iAE) {
-	        logger.error("IllegalAccessException get system properties error!",
-                    iAE);
+            logger.error("IllegalAccessException get system properties error!",
+                iAE);
         } catch (InvocationTargetException iAE) {
-	        logger.error("InvocationTargetException get system properties error!",
-		        iAE);
+            logger.error("InvocationTargetException get system properties error!",
+                iAE);
         }
         return ret;
     }

Vissa filer visades inte eftersom för många filer har ändrats