diff --git a/app/build.gradle b/app/build.gradle index d4ad9ef0a..b50ff455d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -455,6 +455,7 @@ dependencies { implementation project(':image-editor') implementation project(':donations') implementation project(':contacts') + implementation project(':qr') implementation libs.libsignal.android implementation libs.google.protobuf.javalite diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java index ea9174873..67eb27b2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java @@ -25,11 +25,11 @@ import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey; +import org.signal.qr.kitkat.ScanListener; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.qr.ScanListener; import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java index 0ee804408..5d7e4ad1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceAddFragment.java @@ -15,26 +15,25 @@ import android.widget.LinearLayout; import androidx.annotation.NonNull; -import org.thoughtcrime.securesms.components.camera.CameraView; -import org.thoughtcrime.securesms.qr.ScanListener; -import org.thoughtcrime.securesms.qr.ScanningThread; +import org.signal.qr.QrScannerView; +import org.signal.qr.kitkat.ScanListener; +import org.thoughtcrime.securesms.util.LifecycleDisposable; import org.thoughtcrime.securesms.util.ViewUtil; public class DeviceAddFragment extends LoggingFragment { - private ViewGroup container; - private LinearLayout overlay; - private ImageView devicesImage; - private CameraView scannerView; - private ScanningThread scanningThread; - private ScanListener scanListener; + private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable(); + + private LinearLayout overlay; + private ImageView devicesImage; + private ScanListener scanListener; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { - this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment); - this.overlay = this.container.findViewById(R.id.overlay); - this.scannerView = this.container.findViewById(R.id.scanner); - this.devicesImage = this.container.findViewById(R.id.devices); + ViewGroup container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment); + this.overlay = container.findViewById(R.id.overlay); + QrScannerView scannerView = container.findViewById(R.id.scanner); + this.devicesImage = container.findViewById(R.id.devices); if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { this.overlay.setOrientation(LinearLayout.HORIZONTAL); @@ -43,7 +42,7 @@ public class DeviceAddFragment extends LoggingFragment { } if (Build.VERSION.SDK_INT >= 21) { - this.container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { + container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { @TargetApi(21) @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, @@ -59,52 +58,34 @@ public class DeviceAddFragment extends LoggingFragment { }); } - return this.container; - } + scannerView.start(getViewLifecycleOwner()); - @Override - public void onResume() { - super.onResume(); - this.scanningThread = new ScanningThread(); - this.scanningThread.setScanListener(scanListener); - this.scannerView.onResume(); - this.scannerView.setPreviewCallback(scanningThread); - this.scanningThread.start(); - } + lifecycleDisposable.bindTo(getViewLifecycleOwner()); + lifecycleDisposable.add(scannerView.getQrData().subscribe(qrData -> { + if (scanListener != null) { + scanListener.onQrDataFound(qrData); + } + })); - @Override - public void onPause() { - super.onPause(); - this.scannerView.onPause(); - this.scanningThread.stopScanning(); + return container; } @Override public void onConfigurationChanged(@NonNull Configuration newConfiguration) { super.onConfigurationChanged(newConfiguration); - this.scannerView.onPause(); - if (newConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE) { overlay.setOrientation(LinearLayout.HORIZONTAL); } else { overlay.setOrientation(LinearLayout.VERTICAL); } - - this.scannerView.onResume(); - this.scannerView.setPreviewCallback(scanningThread); } - public ImageView getDevicesImage() { return devicesImage; } public void setScanListener(ScanListener scanListener) { this.scanListener = scanListener; - - if (this.scanningThread != null) { - this.scanningThread.setScanListener(scanListener); - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java index a7d84a2e6..c89e48d30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java @@ -38,6 +38,7 @@ import androidx.annotation.Nullable; import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; +import org.signal.qr.kitkat.QrCameraView; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -228,7 +229,7 @@ public class CameraView extends ViewGroup { listeners.add(listener); } - public void setPreviewCallback(final @NonNull PreviewCallback previewCallback) { + public void setPreviewCallback(final @NonNull QrCameraView.PreviewCallback previewCallback) { enqueueTask(new PostInitializationTask() { @Override protected void onPostMain(Void avoid) { @@ -243,7 +244,7 @@ public class CameraView extends ViewGroup { final int rotation = getCameraPictureOrientation(); final Size previewSize = camera.getParameters().getPreviewSize(); if (data != null) { - previewCallback.onPreviewFrame(new PreviewFrame(data, previewSize.width, previewSize.height, rotation)); + previewCallback.onPreviewFrame(new QrCameraView.PreviewFrame(data, previewSize.width, previewSize.height, rotation)); } } }); @@ -568,40 +569,6 @@ public class CameraView extends ViewGroup { void onCameraStop(); } - public interface PreviewCallback { - void onPreviewFrame(@NonNull PreviewFrame frame); - } - - public static class PreviewFrame { - private final @NonNull byte[] data; - private final int width; - private final int height; - private final int orientation; - - private PreviewFrame(@NonNull byte[] data, int width, int height, int orientation) { - this.data = data; - this.width = width; - this.height = height; - this.orientation = orientation; - } - - public @NonNull byte[] getData() { - return data; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - - public int getOrientation() { - return orientation; - } - } - private enum State { PAUSED, RESUMED, ACTIVE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java index ef47233b5..d959dc907 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java @@ -13,11 +13,11 @@ import androidx.navigation.Navigation; import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; +import org.signal.qr.kitkat.ScanningThread; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.camera.CameraView; import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; -import org.thoughtcrime.securesms.qr.ScanningThread; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; public final class PaymentsTransferQrScanFragment extends LoggingFragment { diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanningThread.java b/app/src/main/java/org/thoughtcrime/securesms/qr/ScanningThread.java deleted file mode 100644 index ecb1d2e2f..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanningThread.java +++ /dev/null @@ -1,126 +0,0 @@ -package org.thoughtcrime.securesms.qr; - -import android.content.res.Configuration; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.zxing.BinaryBitmap; -import com.google.zxing.ChecksumException; -import com.google.zxing.DecodeHintType; -import com.google.zxing.FormatException; -import com.google.zxing.NotFoundException; -import com.google.zxing.PlanarYUVLuminanceSource; -import com.google.zxing.Result; -import com.google.zxing.common.HybridBinarizer; -import com.google.zxing.qrcode.QRCodeReader; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.components.camera.CameraView; -import org.thoughtcrime.securesms.components.camera.CameraView.PreviewFrame; -import org.thoughtcrime.securesms.util.Util; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; - -public class ScanningThread extends Thread implements CameraView.PreviewCallback { - - private static final String TAG = Log.tag(ScanningThread.class); - - private final QRCodeReader reader = new QRCodeReader(); - private final AtomicReference scanListener = new AtomicReference<>(); - private final Map hints = new HashMap<>(); - - private boolean scanning = true; - private PreviewFrame previewFrame; - - public void setCharacterSet(String characterSet) { - hints.put(DecodeHintType.CHARACTER_SET, characterSet); - } - - public void setScanListener(ScanListener scanListener) { - this.scanListener.set(scanListener); - } - - @Override - public void onPreviewFrame(@NonNull PreviewFrame previewFrame) { - try { - synchronized (this) { - this.previewFrame = previewFrame; - this.notify(); - } - } catch (RuntimeException e) { - Log.w(TAG, e); - } - } - - - @Override - public void run() { - while (true) { - PreviewFrame ourFrame; - - synchronized (this) { - while (scanning && previewFrame == null) { - Util.wait(this, 0); - } - - if (!scanning) return; - else ourFrame = previewFrame; - - previewFrame = null; - } - - String data = getScannedData(ourFrame.getData(), ourFrame.getWidth(), ourFrame.getHeight(), ourFrame.getOrientation()); - ScanListener scanListener = this.scanListener.get(); - - if (data != null && scanListener != null) { - scanListener.onQrDataFound(data); - return; - } - } - } - - public void stopScanning() { - synchronized (this) { - scanning = false; - notify(); - } - } - - private @Nullable String getScannedData(byte[] data, int width, int height, int orientation) { - try { - if (orientation == Configuration.ORIENTATION_PORTRAIT) { - byte[] rotatedData = new byte[data.length]; - - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - rotatedData[x * height + height - y - 1] = data[x + y * width]; - } - } - - int tmp = width; - width = height; - height = tmp; - data = rotatedData; - } - - PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource(data, width, height, - 0, 0, width, height, - false); - - BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); - Result result = reader.decode(bitmap, hints); - - if (result != null) return result.getText(); - - } catch (NullPointerException | ChecksumException | FormatException e) { - Log.w(TAG, e); - } catch (NotFoundException e) { - // Thanks ZXing... - } - - return null; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityFragment.kt index 1b3f9b38c..e6d8fdcf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityFragment.kt @@ -6,11 +6,11 @@ import android.view.View import android.widget.Toast import androidx.fragment.app.Fragment import org.signal.core.util.ThreadUtil +import org.signal.qr.kitkat.ScanListener import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.permissions.Permissions -import org.thoughtcrime.securesms.qr.ScanListener import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.ServiceUtil diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt index e27f5a3c6..98ee2b3d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt @@ -9,11 +9,11 @@ import android.view.ViewGroup import android.widget.ImageView import androidx.core.view.OneShotPreDrawListener import androidx.fragment.app.Fragment +import org.signal.qr.kitkat.ScanListener +import org.signal.qr.kitkat.ScanningThread import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ShapeScrim import org.thoughtcrime.securesms.components.camera.CameraView -import org.thoughtcrime.securesms.qr.ScanListener -import org.thoughtcrime.securesms.qr.ScanningThread import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.fragments.requireListener diff --git a/app/src/main/res/layout/device_add_fragment.xml b/app/src/main/res/layout/device_add_fragment.xml index 15900e0e0..4b3482ec1 100644 --- a/app/src/main/res/layout/device_add_fragment.xml +++ b/app/src/main/res/layout/device_add_fragment.xml @@ -1,51 +1,52 @@ - + - + - + + android:layout_height="match_parent" + android:layout_weight="1" /> - + android:gravity="center" + android:orientation="vertical" + android:paddingStart="16dp" + android:paddingEnd="16dp"> + android:transitionName="devices" /> - + diff --git a/qr/app/build.gradle b/qr/app/build.gradle new file mode 100644 index 000000000..2652aaf47 --- /dev/null +++ b/qr/app/build.gradle @@ -0,0 +1,14 @@ +apply from: "$rootProject.projectDir/signalModuleApp.gradle" + +android { + defaultConfig { + applicationId "org.signal.qrtest" + } +} + +dependencies { + implementation project(':qr') + implementation libs.rxjava3.rxjava + implementation libs.rxjava3.rxandroid + implementation libs.rxjava3.rxkotlin +} \ No newline at end of file diff --git a/qr/app/src/main/AndroidManifest.xml b/qr/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e6dda092b --- /dev/null +++ b/qr/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/qr/app/src/main/ic_launcher-playstore.png b/qr/app/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..3002aeece Binary files /dev/null and b/qr/app/src/main/ic_launcher-playstore.png differ diff --git a/qr/app/src/main/java/org/signal/qrtest/MainActivity.kt b/qr/app/src/main/java/org/signal/qrtest/MainActivity.kt new file mode 100644 index 000000000..d56145eae --- /dev/null +++ b/qr/app/src/main/java/org/signal/qrtest/MainActivity.kt @@ -0,0 +1,28 @@ +package org.signal.qrtest + +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.qr.QrScannerView + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + if (Build.VERSION.SDK_INT >= 23) { + requestPermissions(arrayOf(android.Manifest.permission.CAMERA), 1) + } + + val scanner = findViewById(R.id.scanner) + scanner.start(this) + + scanner.qrData + .distinctUntilChanged() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { Toast.makeText(this, it, Toast.LENGTH_SHORT).show() } + } +} diff --git a/qr/app/src/main/res/drawable/ic_launcher_background.xml b/qr/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..ca3826a46 --- /dev/null +++ b/qr/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/qr/app/src/main/res/drawable/ic_launcher_foreground.xml b/qr/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..ef6972e03 --- /dev/null +++ b/qr/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/qr/app/src/main/res/layout/activity_main.xml b/qr/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..d37c2a1bc --- /dev/null +++ b/qr/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/qr/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/qr/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..bbd3e0212 --- /dev/null +++ b/qr/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/qr/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/qr/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..bbd3e0212 --- /dev/null +++ b/qr/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/qr/app/src/main/res/mipmap-hdpi/ic_launcher.png b/qr/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..7a12bc6f0 Binary files /dev/null and b/qr/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/qr/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/qr/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..0330f9fef Binary files /dev/null and b/qr/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/qr/app/src/main/res/mipmap-mdpi/ic_launcher.png b/qr/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..3569ae9c7 Binary files /dev/null and b/qr/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/qr/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/qr/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..acc4ee6e0 Binary files /dev/null and b/qr/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/qr/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/qr/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..b21d7a326 Binary files /dev/null and b/qr/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/qr/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/qr/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..f4c75f5c1 Binary files /dev/null and b/qr/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/qr/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/qr/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..eeff867a6 Binary files /dev/null and b/qr/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/qr/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/qr/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..1f1d9f791 Binary files /dev/null and b/qr/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/qr/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/qr/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..97a63cdc9 Binary files /dev/null and b/qr/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/qr/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/qr/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..b8c128f18 Binary files /dev/null and b/qr/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/qr/app/src/main/res/values-night/themes.xml b/qr/app/src/main/res/values-night/themes.xml new file mode 100644 index 000000000..db1e50b5e --- /dev/null +++ b/qr/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/qr/app/src/main/res/values/colors.xml b/qr/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..09837df62 --- /dev/null +++ b/qr/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/qr/app/src/main/res/values/strings.xml b/qr/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..94b7f4a70 --- /dev/null +++ b/qr/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + QR + \ No newline at end of file diff --git a/qr/app/src/main/res/values/themes.xml b/qr/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..6cad716a6 --- /dev/null +++ b/qr/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/qr/lib/build.gradle b/qr/lib/build.gradle new file mode 100644 index 000000000..9b34c8ae6 --- /dev/null +++ b/qr/lib/build.gradle @@ -0,0 +1,18 @@ +apply from: "$rootProject.projectDir/signalModule.gradle" + +dependencies { + implementation libs.androidx.camera.core + implementation libs.androidx.camera.camera2 + implementation libs.androidx.camera.lifecycle + implementation libs.androidx.camera.view + implementation libs.androidx.lifecycle.common.java8 + + implementation libs.google.zxing.android.integration + implementation libs.google.zxing.core + + implementation libs.rxjava3.rxjava + + // Included to force proper versions for verification and match main app + implementation 'androidx.lifecycle:lifecycle-livedata:2.3.1' + implementation 'com.google.guava:guava:30.0-android' +} \ No newline at end of file diff --git a/qr/lib/src/main/AndroidManifest.xml b/qr/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..698218b32 --- /dev/null +++ b/qr/lib/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt b/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt new file mode 100644 index 000000000..a0b645dbb --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt @@ -0,0 +1,50 @@ +package org.signal.qr + +import com.google.zxing.BinaryBitmap +import com.google.zxing.ChecksumException +import com.google.zxing.DecodeHintType +import com.google.zxing.FormatException +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.Result +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeReader +import org.signal.core.util.logging.Log + +/** + * Wraps [QRCodeReader] for use from API19 or API21+. + */ +class QrProcessor { + + private val reader = QRCodeReader() + + fun getScannedData( + data: ByteArray, + width: Int, + height: Int + ): String? { + try { + val source = PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false) + + val bitmap = BinaryBitmap(HybridBinarizer(source)) + val result: Result? = reader.decode(bitmap, emptyMap()) + + if (result != null) { + return result.text + } + } catch (e: NullPointerException) { + Log.w(TAG, e) + } catch (e: ChecksumException) { + Log.w(TAG, e) + } catch (e: FormatException) { + Log.w(TAG, e) + } catch (e: NotFoundException) { + // Thanks ZXing... + } + return null + } + + companion object { + private val TAG = Log.tag(QrProcessor::class.java) + } +} diff --git a/qr/lib/src/main/java/org/signal/qr/QrScannerView.kt b/qr/lib/src/main/java/org/signal/qr/QrScannerView.kt new file mode 100644 index 000000000..588e6371d --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/QrScannerView.kt @@ -0,0 +1,50 @@ +package org.signal.qr + +import android.content.Context +import android.os.Build +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.PublishSubject + +/** + * View for starting up a camera and scanning a QR-Code. Safe to use on an API version and + * will delegate to legacy camera APIs or CameraX APIs when appropriate. + * + * QR-code data is emitted via [qrData] observable. + */ +class QrScannerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr), ScannerView { + + private val scannerView: ScannerView + private val qrDataPublish: PublishSubject = PublishSubject.create() + + val qrData: Observable = qrDataPublish + + init { + val scannerView: FrameLayout = if (Build.VERSION.SDK_INT >= 21) { + ScannerView21(context) { qrDataPublish.onNext(it) } + } else { + ScannerView19(context) { qrDataPublish.onNext(it) } + } + + scannerView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + addView(scannerView) + + this.scannerView = (scannerView as ScannerView) + } + + override fun start(lifecycleOwner: LifecycleOwner) { + scannerView.start(lifecycleOwner) + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + qrDataPublish.onComplete() + } + }) + } +} diff --git a/qr/lib/src/main/java/org/signal/qr/ScannerView.kt b/qr/lib/src/main/java/org/signal/qr/ScannerView.kt new file mode 100644 index 000000000..2e064edef --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/ScannerView.kt @@ -0,0 +1,10 @@ +package org.signal.qr + +import androidx.lifecycle.LifecycleOwner + +/** + * Common interface for interacting with QR scanning views. + */ +interface ScannerView { + fun start(lifecycleOwner: LifecycleOwner) +} diff --git a/qr/lib/src/main/java/org/signal/qr/ScannerView19.kt b/qr/lib/src/main/java/org/signal/qr/ScannerView19.kt new file mode 100644 index 000000000..d27adf480 --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/ScannerView19.kt @@ -0,0 +1,49 @@ +package org.signal.qr + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.FrameLayout +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import org.signal.qr.kitkat.QrCameraView +import org.signal.qr.kitkat.ScanListener +import org.signal.qr.kitkat.ScanningThread + +/** + * API19 version of QR scanning. Uses deprecated camera APIs. + */ +@SuppressLint("ViewConstructor") +internal class ScannerView19 constructor( + context: Context, + private val scanListener: ScanListener +) : FrameLayout(context), ScannerView { + + private var scanningThread: ScanningThread? = null + private val cameraView: QrCameraView + + init { + cameraView = QrCameraView(context) + cameraView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + addView(cameraView) + } + + override fun start(lifecycleOwner: LifecycleOwner) { + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + val scanningThread = ScanningThread() + scanningThread.setScanListener(scanListener) + cameraView.onResume() + cameraView.setPreviewCallback(scanningThread) + scanningThread.start() + + this@ScannerView19.scanningThread = scanningThread + } + + override fun onPause(owner: LifecycleOwner) { + cameraView.onPause() + scanningThread?.stopScanning() + scanningThread = null + } + }) + } +} diff --git a/qr/lib/src/main/java/org/signal/qr/ScannerView21.kt b/qr/lib/src/main/java/org/signal/qr/ScannerView21.kt new file mode 100644 index 000000000..3831fa960 --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/ScannerView21.kt @@ -0,0 +1,103 @@ +package org.signal.qr + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.FrameLayout +import androidx.annotation.RequiresApi +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import org.signal.core.util.logging.Log +import org.signal.qr.kitkat.ScanListener +import java.util.concurrent.Executors + +/** + * API21+ version of QR scanning view. Uses camerax APIs. + */ +@SuppressLint("ViewConstructor") +@RequiresApi(21) +internal class ScannerView21 constructor( + context: Context, + private val listener: ScanListener +) : FrameLayout(context), ScannerView { + + private val analyzerExecutor = Executors.newSingleThreadExecutor() + private var cameraProvider: ProcessCameraProvider? = null + private var camera: Camera? = null + private var previewView: PreviewView + private val qrProcessor = QrProcessor() + + init { + previewView = PreviewView(context) + previewView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + addView(previewView) + } + + override fun start(lifecycleOwner: LifecycleOwner) { + previewView.post { + Log.i(TAG, "Starting") + ProcessCameraProvider.getInstance(context).apply { + addListener({ + try { + onCameraProvider(lifecycleOwner, get()) + } catch (e: Exception) { + Log.w(TAG, e) + } + }, ContextCompat.getMainExecutor(context)) + } + } + + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + cameraProvider = null + camera = null + analyzerExecutor.shutdown() + } + }) + } + + private fun onCameraProvider(lifecycle: LifecycleOwner, cameraProvider: ProcessCameraProvider?) { + if (cameraProvider == null) { + Log.w(TAG, "Camera provider is null") + return + } + + Log.i(TAG, "Initializing use cases") + + val preview = Preview.Builder().build() + + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + imageAnalysis.setAnalyzer(analyzerExecutor) { proxy -> + val buffer = proxy.planes[0].buffer + val bytes = ByteArray(buffer.capacity()) + buffer.get(bytes) + + val data: String? = qrProcessor.getScannedData(bytes, proxy.width, proxy.height) + if (data != null) { + listener.onQrDataFound(data) + } + + proxy.close() + } + + cameraProvider.unbindAll() + camera = cameraProvider.bindToLifecycle(lifecycle, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis) + + preview.setSurfaceProvider(previewView.surfaceProvider) + + this.cameraProvider = cameraProvider + } + + companion object { + private val TAG = Log.tag(ScannerView21::class.java) + } +} diff --git a/qr/lib/src/main/java/org/signal/qr/kitkat/CameraSurfaceView.java b/qr/lib/src/main/java/org/signal/qr/kitkat/CameraSurfaceView.java new file mode 100644 index 000000000..fcdc4bbcd --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/kitkat/CameraSurfaceView.java @@ -0,0 +1,33 @@ +package org.signal.qr.kitkat; + +import android.content.Context; +import android.view.SurfaceHolder; +import android.view.SurfaceView; + +public class CameraSurfaceView extends SurfaceView implements SurfaceHolder.Callback { + private boolean ready; + + @SuppressWarnings("deprecation") + public CameraSurfaceView(Context context) { + super(context); + getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS); + getHolder().addCallback(this); + } + + public boolean isReady() { + return ready; + } + + @Override + public void surfaceCreated(SurfaceHolder holder) { + ready = true; + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {} + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + ready = false; + } +} diff --git a/qr/lib/src/main/java/org/signal/qr/kitkat/CameraUtils.java b/qr/lib/src/main/java/org/signal/qr/kitkat/CameraUtils.java new file mode 100644 index 000000000..13407c69b --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/kitkat/CameraUtils.java @@ -0,0 +1,109 @@ +package org.signal.qr.kitkat; + +import android.app.Activity; +import android.hardware.Camera.CameraInfo; +import android.hardware.Camera.Parameters; +import android.hardware.Camera.Size; +import android.util.DisplayMetrics; +import android.view.Surface; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; + +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; + +@SuppressWarnings("deprecation") +public class CameraUtils { + private static final String TAG = Log.tag(CameraUtils.class); + /* + * modified from: https://github.com/commonsguy/cwac-camera/blob/master/camera/src/com/commonsware/cwac/camera/CameraUtils.java + */ + public static @Nullable Size getPreferredPreviewSize(int displayOrientation, + int width, + int height, + @NonNull Parameters parameters) { + final int targetWidth = displayOrientation % 180 == 90 ? height : width; + final int targetHeight = displayOrientation % 180 == 90 ? width : height; + final double targetRatio = (double) targetWidth / targetHeight; + + Log.d(TAG, String.format(Locale.US, + "getPreferredPreviewSize(%d, %d, %d) -> target %dx%d, AR %.02f", + displayOrientation, width, height, + targetWidth, targetHeight, targetRatio)); + + List sizes = parameters.getSupportedPreviewSizes(); + List ideals = new LinkedList<>(); + List bigEnough = new LinkedList<>(); + + for (Size size : sizes) { + Log.d(TAG, String.format(Locale.US, " %dx%d (%.02f)", size.width, size.height, (float)size.width / size.height)); + + if (size.height == size.width * targetRatio && size.height >= targetHeight && size.width >= targetWidth) { + ideals.add(size); + Log.d(TAG, " (ideal ratio)"); + } else if (size.width >= targetWidth && size.height >= targetHeight) { + bigEnough.add(size); + Log.d(TAG, " (good size, suboptimal ratio)"); + } + } + + if (!ideals.isEmpty()) return Collections.min(ideals, new AreaComparator()); + else if (!bigEnough.isEmpty()) return Collections.min(bigEnough, new AspectRatioComparator(targetRatio)); + else return Collections.max(sizes, new AreaComparator()); + } + + // based on + // http://developer.android.com/reference/android/hardware/Camera.html#setDisplayOrientation(int) + // and http://stackoverflow.com/a/10383164/115145 + public static int getCameraDisplayOrientation(@NonNull Activity activity, + @NonNull CameraInfo info) + { + int rotation = activity.getWindowManager().getDefaultDisplay().getRotation(); + int degrees = 0; + DisplayMetrics dm = new DisplayMetrics(); + + activity.getWindowManager().getDefaultDisplay().getMetrics(dm); + + switch (rotation) { + case Surface.ROTATION_0: degrees = 0; break; + case Surface.ROTATION_90: degrees = 90; break; + case Surface.ROTATION_180: degrees = 180; break; + case Surface.ROTATION_270: degrees = 270; break; + } + + if (info.facing == CameraInfo.CAMERA_FACING_FRONT) { + return (360 - ((info.orientation + degrees) % 360)) % 360; + } else { + return (info.orientation - degrees + 360) % 360; + } + } + + private static class AreaComparator implements Comparator { + @Override + public int compare(Size lhs, Size rhs) { + return Long.signum(lhs.width * lhs.height - rhs.width * rhs.height); + } + } + + private static class AspectRatioComparator extends AreaComparator { + private final double target; + public AspectRatioComparator(double target) { + this.target = target; + } + + @Override + public int compare(Size lhs, Size rhs) { + final double lhsDiff = Math.abs(target - (double) lhs.width / lhs.height); + final double rhsDiff = Math.abs(target - (double) rhs.width / rhs.height); + if (lhsDiff < rhsDiff) return -1; + else if (lhsDiff > rhsDiff) return 1; + else return super.compare(lhs, rhs); + } + } +} diff --git a/qr/lib/src/main/java/org/signal/qr/kitkat/QrCameraView.java b/qr/lib/src/main/java/org/signal/qr/kitkat/QrCameraView.java new file mode 100644 index 000000000..3172d77a4 --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/kitkat/QrCameraView.java @@ -0,0 +1,472 @@ +/* + Copyright (c) 2013-2014 CommonsWare, LLC + Portions Copyright (C) 2007 The Android Open Source Project +

+ Licensed under the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain + a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +package org.signal.qr.kitkat; + +import android.app.Activity; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.graphics.Color; +import android.hardware.Camera; +import android.hardware.Camera.CameraInfo; +import android.hardware.Camera.Parameters; +import android.hardware.Camera.Size; +import android.os.AsyncTask; +import android.util.AttributeSet; +import android.view.OrientationEventListener; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.ThreadUtil; +import org.signal.core.util.logging.Log; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; + +@SuppressWarnings("deprecation") +public class QrCameraView extends ViewGroup { + private static final String TAG = Log.tag(QrCameraView.class); + + private final CameraSurfaceView surface; + private final OnOrientationChange onOrientationChange; + + private volatile Optional camera = Optional.empty(); + private final int cameraId = CameraInfo.CAMERA_FACING_BACK; + private volatile int displayOrientation = -1; + + private @NonNull State state = State.PAUSED; + private @Nullable Size previewSize; + private final List listeners = Collections.synchronizedList(new LinkedList<>()); + private int outputOrientation = -1; + + public QrCameraView(Context context) { + this(context, null); + } + + public QrCameraView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public QrCameraView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + setBackgroundColor(Color.BLACK); + + surface = new CameraSurfaceView(getContext()); + onOrientationChange = new OnOrientationChange(context.getApplicationContext()); + addView(surface); + } + + public void onResume() { + if (state != State.PAUSED) return; + state = State.RESUMED; + Log.i(TAG, "onResume() queued"); + enqueueTask(new SerialAsyncTask() { + @Override + protected + @Nullable + Void onRunBackground() { + try { + long openStartMillis = System.currentTimeMillis(); + camera = Optional.ofNullable(Camera.open(cameraId)); + Log.i(TAG, "camera.open() -> " + (System.currentTimeMillis() - openStartMillis) + "ms"); + synchronized (QrCameraView.this) { + QrCameraView.this.notifyAll(); + } + camera.ifPresent(value -> onCameraReady(value)); + } catch (Exception e) { + Log.w(TAG, e); + } + return null; + } + + @Override + protected void onPostMain(Void avoid) { + if (!camera.isPresent()) { + Log.w(TAG, "tried to open camera but got null"); + for (CameraViewListener listener : listeners) { + listener.onCameraFail(); + } + return; + } + + if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { + onOrientationChange.enable(); + } + Log.i(TAG, "onResume() completed"); + } + }); + } + + public void onPause() { + if (state == State.PAUSED) return; + state = State.PAUSED; + Log.i(TAG, "onPause() queued"); + + enqueueTask(new SerialAsyncTask() { + private Optional cameraToDestroy; + + @Override + protected void onPreMain() { + cameraToDestroy = camera; + camera = Optional.empty(); + } + + @Override + protected Void onRunBackground() { + if (cameraToDestroy.isPresent()) { + try { + stopPreview(); + cameraToDestroy.get().setPreviewCallback(null); + cameraToDestroy.get().release(); + Log.w(TAG, "released old camera instance"); + } catch (Exception e) { + Log.w(TAG, e); + } + } + return null; + } + + @Override protected void onPostMain(Void avoid) { + onOrientationChange.disable(); + displayOrientation = -1; + outputOrientation = -1; + removeView(surface); + addView(surface); + Log.i(TAG, "onPause() completed"); + } + }); + + for (CameraViewListener listener : listeners) { + listener.onCameraStop(); + } + } + + public boolean isStarted() { + return state != State.PAUSED; + } + + @SuppressWarnings("SuspiciousNameCombination") + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int width = r - l; + final int height = b - t; + final int previewWidth; + final int previewHeight; + + if (camera.isPresent() && previewSize != null) { + if (displayOrientation == 90 || displayOrientation == 270) { + previewWidth = previewSize.height; + previewHeight = previewSize.width; + } else { + previewWidth = previewSize.width; + previewHeight = previewSize.height; + } + } else { + previewWidth = width; + previewHeight = height; + } + + if (previewHeight == 0 || previewWidth == 0) { + Log.w(TAG, "skipping layout due to zero-width/height preview size"); + return; + } + + if (width * previewHeight > height * previewWidth) { + final int scaledChildHeight = previewHeight * width / previewWidth; + surface.layout(0, (height - scaledChildHeight) / 2, width, (height + scaledChildHeight) / 2); + } else { + final int scaledChildWidth = previewWidth * height / previewHeight; + surface.layout((width - scaledChildWidth) / 2, 0, (width + scaledChildWidth) / 2, height); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + Log.i(TAG, "onSizeChanged(" + oldw + "x" + oldh + " -> " + w + "x" + h + ")"); + super.onSizeChanged(w, h, oldw, oldh); + camera.ifPresent(value -> startPreview(value.getParameters())); + } + + public void addListener(@NonNull CameraViewListener listener) { + listeners.add(listener); + } + + public void setPreviewCallback(final @NonNull PreviewCallback previewCallback) { + enqueueTask(new PostInitializationTask() { + @Override + protected void onPostMain(Void avoid) { + camera.ifPresent(value -> value.setPreviewCallback((data, camera) -> { + if (!QrCameraView.this.camera.isPresent()) { + return; + } + + final int rotation = getCameraPictureOrientation(); + final Size previewSize = camera.getParameters().getPreviewSize(); + if (data != null) { + previewCallback.onPreviewFrame(new PreviewFrame(data, previewSize.width, previewSize.height, rotation)); + } + })); + } + }); + } + + private void onCameraReady(final @NonNull Camera camera) { + final Parameters parameters = camera.getParameters(); + + parameters.setRecordingHint(true); + final List focusModes = parameters.getSupportedFocusModes(); + if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); + } else if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) { + parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO); + } + + displayOrientation = CameraUtils.getCameraDisplayOrientation(getActivity(), getCameraInfo()); + camera.setDisplayOrientation(displayOrientation); + camera.setParameters(parameters); + enqueueTask(new PostInitializationTask() { + @Override + protected Void onRunBackground() { + try { + camera.setPreviewDisplay(surface.getHolder()); + startPreview(parameters); + } catch (Exception e) { + Log.w(TAG, "couldn't set preview display", e); + } + return null; + } + }); + } + + private void startPreview(final @NonNull Parameters parameters) { + camera.ifPresent(camera -> { + try { + final Size preferredPreviewSize = getPreferredPreviewSize(parameters); + + if (preferredPreviewSize != null && !parameters.getPreviewSize().equals(preferredPreviewSize)) { + Log.i(TAG, "starting preview with size " + preferredPreviewSize.width + "x" + preferredPreviewSize.height); + if (state == State.ACTIVE) stopPreview(); + previewSize = preferredPreviewSize; + parameters.setPreviewSize(preferredPreviewSize.width, preferredPreviewSize.height); + camera.setParameters(parameters); + } else { + previewSize = parameters.getPreviewSize(); + } + long previewStartMillis = System.currentTimeMillis(); + camera.startPreview(); + Log.i(TAG, "camera.startPreview() -> " + (System.currentTimeMillis() - previewStartMillis) + "ms"); + state = State.ACTIVE; + ThreadUtil.runOnMain(() -> { + requestLayout(); + for (CameraViewListener listener : listeners) { + listener.onCameraStart(); + } + }); + } catch (Exception e) { + Log.w(TAG, e); + } + }); + } + + private void stopPreview() { + camera.ifPresent(camera -> { + try { + camera.stopPreview(); + state = State.RESUMED; + } catch (Exception e) { + Log.w(TAG, e); + } + }); + } + + private Size getPreferredPreviewSize(@NonNull Parameters parameters) { + return CameraUtils.getPreferredPreviewSize(displayOrientation, + getMeasuredWidth(), + getMeasuredHeight(), + parameters); + } + + private int getCameraPictureOrientation() { + if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { + outputOrientation = getCameraPictureRotation(getActivity().getWindowManager() + .getDefaultDisplay() + .getOrientation()); + } else if (getCameraInfo().facing == CameraInfo.CAMERA_FACING_FRONT) { + outputOrientation = (360 - displayOrientation) % 360; + } else { + outputOrientation = displayOrientation; + } + + return outputOrientation; + } + + private @NonNull CameraInfo getCameraInfo() { + final CameraInfo info = new CameraInfo(); + Camera.getCameraInfo(cameraId, info); + return info; + } + + // XXX this sucks + private Activity getActivity() { + return (Activity) getContext(); + } + + public int getCameraPictureRotation(int orientation) { + final CameraInfo info = getCameraInfo(); + final int rotation; + + orientation = (orientation + 45) / 90 * 90; + + if (info.facing == CameraInfo.CAMERA_FACING_FRONT) { + rotation = (info.orientation - orientation + 360) % 360; + } else { + rotation = (info.orientation + orientation) % 360; + } + + return rotation; + } + + private class OnOrientationChange extends OrientationEventListener { + public OnOrientationChange(Context context) { + super(context); + disable(); + } + + @Override + public void onOrientationChanged(int orientation) { + camera.ifPresent(camera -> { + if (orientation != ORIENTATION_UNKNOWN) { + int newOutputOrientation = getCameraPictureRotation(orientation); + + if (newOutputOrientation != outputOrientation) { + outputOrientation = newOutputOrientation; + + Parameters params = camera.getParameters(); + + params.setRotation(outputOrientation); + + try { + camera.setParameters(params); + } catch (Exception e) { + Log.e(TAG, "Exception updating camera parameters in orientation change", e); + } + } + } + }); + } + } + + private void enqueueTask(SerialAsyncTask job) { + AsyncTask.SERIAL_EXECUTOR.execute(job); + } + + public static abstract class SerialAsyncTask implements Runnable { + + @Override + public final void run() { + if (!onWait()) { + Log.w(TAG, "skipping task, preconditions not met in onWait()"); + return; + } + + ThreadUtil.runOnMainSync(this::onPreMain); + final Result result = onRunBackground(); + ThreadUtil.runOnMainSync(() -> onPostMain(result)); + } + + protected boolean onWait() {return true;} + + protected void onPreMain() {} + + protected Result onRunBackground() {return null;} + + protected void onPostMain(Result result) {} + } + + private abstract class PostInitializationTask extends SerialAsyncTask { + @Override protected boolean onWait() { + synchronized (QrCameraView.this) { + if (!camera.isPresent()) { + return false; + } + while (getMeasuredHeight() <= 0 || getMeasuredWidth() <= 0 || !surface.isReady()) { + Log.i(TAG, String.format("waiting. surface ready? %s", surface.isReady())); + waitFor(); + } + return true; + } + } + } + + private void waitFor() { + try { + wait(0); + } catch (InterruptedException ie) { + throw new AssertionError(ie); + } + } + + public interface CameraViewListener { + void onImageCapture(@NonNull final byte[] imageBytes); + + void onCameraFail(); + + void onCameraStart(); + + void onCameraStop(); + } + + public interface PreviewCallback { + void onPreviewFrame(@NonNull PreviewFrame frame); + } + + public static class PreviewFrame { + private final @NonNull byte[] data; + private final int width; + private final int height; + private final int orientation; + + public PreviewFrame(@NonNull byte[] data, int width, int height, int orientation) { + this.data = data; + this.width = width; + this.height = height; + this.orientation = orientation; + } + + public @NonNull byte[] getData() { + return data; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public int getOrientation() { + return orientation; + } + } + + private enum State { + PAUSED, RESUMED, ACTIVE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanListener.java b/qr/lib/src/main/java/org/signal/qr/kitkat/ScanListener.java similarity index 74% rename from app/src/main/java/org/thoughtcrime/securesms/qr/ScanListener.java rename to qr/lib/src/main/java/org/signal/qr/kitkat/ScanListener.java index 93224ae18..e902827cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qr/ScanListener.java +++ b/qr/lib/src/main/java/org/signal/qr/kitkat/ScanListener.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.qr; +package org.signal.qr.kitkat; import androidx.annotation.NonNull; diff --git a/qr/lib/src/main/java/org/signal/qr/kitkat/ScanningThread.java b/qr/lib/src/main/java/org/signal/qr/kitkat/ScanningThread.java new file mode 100644 index 000000000..ae440a744 --- /dev/null +++ b/qr/lib/src/main/java/org/signal/qr/kitkat/ScanningThread.java @@ -0,0 +1,88 @@ +package org.signal.qr.kitkat; + +import androidx.annotation.NonNull; + +import com.google.zxing.DecodeHintType; + +import org.signal.core.util.logging.Log; +import org.signal.qr.QrProcessor; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.signal.qr.kitkat.QrCameraView.PreviewCallback; +import static org.signal.qr.kitkat.QrCameraView.PreviewFrame; + +public class ScanningThread extends Thread implements PreviewCallback { + + private static final String TAG = Log.tag(ScanningThread.class); + + private final QrProcessor processor = new QrProcessor(); + private final AtomicReference scanListener = new AtomicReference<>(); + private final Map hints = new HashMap<>(); + + private boolean scanning = true; + private PreviewFrame previewFrame; + + public void setCharacterSet(String characterSet) { + hints.put(DecodeHintType.CHARACTER_SET, characterSet); + } + + public void setScanListener(ScanListener scanListener) { + this.scanListener.set(scanListener); + } + + @Override + public void onPreviewFrame(@NonNull PreviewFrame previewFrame) { + try { + synchronized (this) { + this.previewFrame = previewFrame; + this.notify(); + } + } catch (RuntimeException e) { + Log.w(TAG, e); + } + } + + @Override + public void run() { + while (true) { + PreviewFrame ourFrame; + + synchronized (this) { + while (scanning && previewFrame == null) { + waitFor(); + } + + if (!scanning) return; + else ourFrame = previewFrame; + + previewFrame = null; + } + + String data = processor.getScannedData(ourFrame.getData(), ourFrame.getWidth(), ourFrame.getHeight()); + ScanListener scanListener = this.scanListener.get(); + + if (data != null && scanListener != null) { + scanListener.onQrDataFound(data); + return; + } + } + } + + public void stopScanning() { + synchronized (this) { + scanning = false; + notify(); + } + } + + private void waitFor() { + try { + wait(0); + } catch (InterruptedException ie) { + throw new AssertionError(ie); + } + } +} diff --git a/settings.gradle b/settings.gradle index e5cf35768..78f4e11b5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -18,6 +18,8 @@ include ':spinner' include ':spinner-app' include ':contacts' include ':contacts-app' +include ':qr' +include ':qr-app' project(':app').name = 'Signal-Android' project(':paging').projectDir = file('paging/lib') @@ -40,6 +42,9 @@ project(':spinner-app').projectDir = file('spinner/app') project(':contacts').projectDir = file('contacts/lib') project(':contacts-app').projectDir = file('contacts/app') +project(':qr').projectDir = file('qr/lib') +project(':qr-app').projectDir = file('qr/app') + rootProject.name='Signal' apply from: 'dependencies.gradle' diff --git a/signalModule.gradle b/signalModule.gradle new file mode 100644 index 000000000..e49aca2c3 --- /dev/null +++ b/signalModule.gradle @@ -0,0 +1,48 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'org.jlleitschuh.gradle.ktlint' + +android { + buildToolsVersion BUILD_TOOL_VERSION + compileSdkVersion COMPILE_SDK + + defaultConfig { + minSdkVersion MINIMUM_SDK + targetSdkVersion TARGET_SDK + multiDexEnabled true + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JAVA_VERSION + targetCompatibility JAVA_VERSION + } + + kotlinOptions { + jvmTarget = '1.8' + } + + lintOptions { + disable 'InvalidVectorPath' + } +} + +ktlint { + // Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507 + version = "0.43.2" +} + +dependencies { + lintChecks project(':lintchecks') + + implementation project(':core-util') + + coreLibraryDesugaring libs.android.tools.desugar + + implementation libs.androidx.core.ktx + implementation libs.androidx.fragment.ktx + implementation libs.androidx.annotation + implementation libs.androidx.appcompat + + implementation libs.kotlin.stdlib.jdk8 +} diff --git a/signalModuleApp.gradle b/signalModuleApp.gradle new file mode 100644 index 000000000..9a11e709f --- /dev/null +++ b/signalModuleApp.gradle @@ -0,0 +1,48 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'org.jlleitschuh.gradle.ktlint' + +android { + buildToolsVersion BUILD_TOOL_VERSION + compileSdkVersion COMPILE_SDK + + defaultConfig { + versionCode 1 + versionName "1.0" + + minSdkVersion 19 + targetSdkVersion TARGET_SDK + multiDexEnabled true + } + + kotlinOptions { + jvmTarget = '1.8' + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JAVA_VERSION + targetCompatibility JAVA_VERSION + } +} + +ktlint { + // Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507 + version = "0.43.2" +} + +dependencies { + coreLibraryDesugaring libs.android.tools.desugar + + implementation "androidx.activity:activity-ktx:1.2.2" + + implementation libs.androidx.appcompat + implementation libs.material.material + implementation libs.androidx.constraintlayout + + implementation libs.kotlin.stdlib.jdk8 + + testImplementation testLibs.junit.junit + + implementation project(':core-util') +} \ No newline at end of file