Source added
This commit is contained in:
parent
b2864b500e
commit
ba28ca859e
8352 changed files with 1487182 additions and 1 deletions
22
qr/lib/build.gradle.kts
Normal file
22
qr/lib/build.gradle.kts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
plugins {
|
||||
id("signal-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.qr"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core-util"))
|
||||
|
||||
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.androidx.lifecycle.livedata.core)
|
||||
|
||||
implementation(libs.google.guava.android)
|
||||
implementation(libs.google.zxing.android.integration)
|
||||
implementation(libs.google.zxing.core)
|
||||
}
|
||||
13
qr/lib/src/main/AndroidManifest.xml
Normal file
13
qr/lib/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package org.signal.qr
|
||||
|
||||
import android.graphics.ImageFormat
|
||||
import androidx.camera.core.ImageProxy
|
||||
import com.google.zxing.LuminanceSource
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Luminance source that gets data via an [ImageProxy]. The main reason for this is because
|
||||
* the Y-Plane provided by the camera framework can have a row stride (number of bytes that make up a row)
|
||||
* that is different than the image width.
|
||||
*
|
||||
* An image width can be reported as 1080 but the row stride may be 1088. Thus when representing a row-major
|
||||
* 2D array as a 1D array, the math can go sideways if width is used instead of row stride.
|
||||
*/
|
||||
class ImageProxyLuminanceSource(image: ImageProxy) : LuminanceSource(image.width, image.height) {
|
||||
|
||||
val yData: ByteArray
|
||||
|
||||
init {
|
||||
require(image.format == ImageFormat.YUV_420_888) { "Invalid image format" }
|
||||
|
||||
yData = ByteArray(image.width * image.height)
|
||||
|
||||
val yBuffer: ByteBuffer = image.planes[0].buffer
|
||||
yBuffer.position(0)
|
||||
|
||||
val yRowStride: Int = image.planes[0].rowStride
|
||||
|
||||
for (y in 0 until image.height) {
|
||||
val yIndex: Int = y * yRowStride
|
||||
yBuffer.position(yIndex)
|
||||
yBuffer.get(yData, y * image.width, image.width)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRow(y: Int, row: ByteArray?): ByteArray {
|
||||
require(y in 0 until height) { "Requested row is outside the image: $y" }
|
||||
|
||||
val toReturn: ByteArray = if (row == null || row.size < width) {
|
||||
ByteArray(width)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
|
||||
val yIndex: Int = y * width
|
||||
|
||||
yData.copyInto(toReturn, 0, yIndex, yIndex + width)
|
||||
|
||||
return toReturn
|
||||
}
|
||||
|
||||
override fun getMatrix(): ByteArray {
|
||||
return yData
|
||||
}
|
||||
|
||||
fun render(): IntArray {
|
||||
val argbArray = IntArray(width * height)
|
||||
|
||||
var yValue: Int
|
||||
yData.forEachIndexed { i, byte ->
|
||||
yValue = (byte.toInt() and 0xff).coerceIn(0..255)
|
||||
argbArray[i] = 255 shl 24 or (yValue and 255 shl 16) or (yValue and 255 shl 8) or (yValue and 255)
|
||||
}
|
||||
|
||||
return argbArray
|
||||
}
|
||||
}
|
||||
85
qr/lib/src/main/java/org/signal/qr/QrProcessor.kt
Normal file
85
qr/lib/src/main/java/org/signal/qr/QrProcessor.kt
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package org.signal.qr
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.camera.core.ImageProxy
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.ChecksumException
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.FormatException
|
||||
import com.google.zxing.LuminanceSource
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.RGBLuminanceSource
|
||||
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 java.nio.IntBuffer
|
||||
|
||||
/**
|
||||
* Wraps [QRCodeReader] for use from API19 or API21+.
|
||||
*/
|
||||
class QrProcessor {
|
||||
|
||||
private val reader = QRCodeReader()
|
||||
|
||||
private var previousHeight = 0
|
||||
private var previousWidth = 0
|
||||
|
||||
fun getScannedData(proxy: ImageProxy): String? {
|
||||
return getScannedData(ImageProxyLuminanceSource(proxy))
|
||||
}
|
||||
|
||||
fun getScannedData(
|
||||
data: ByteArray,
|
||||
width: Int,
|
||||
height: Int
|
||||
): String? {
|
||||
return getScannedData(PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false))
|
||||
}
|
||||
|
||||
fun getScannedData(bitmap: Bitmap?): String? {
|
||||
if (bitmap == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val buffer = IntBuffer.allocate((bitmap.byteCount / 4) + 1)
|
||||
bitmap.copyPixelsToBuffer(buffer)
|
||||
return getScannedData(RGBLuminanceSource(bitmap.width, bitmap.height, buffer.array()))
|
||||
}
|
||||
|
||||
private fun getScannedData(source: LuminanceSource): String? {
|
||||
try {
|
||||
if (source.width != previousWidth || source.height != previousHeight) {
|
||||
Log.i(TAG, "Processing ${source.width} x ${source.height} image")
|
||||
previousWidth = source.width
|
||||
previousHeight = source.height
|
||||
}
|
||||
|
||||
listener?.invoke(source)
|
||||
|
||||
val bitmap = BinaryBitmap(HybridBinarizer(source))
|
||||
val result: Result? = reader.decode(bitmap, mapOf(DecodeHintType.TRY_HARDER to true, DecodeHintType.CHARACTER_SET to "ISO-8859-1"))
|
||||
|
||||
if (result != null) {
|
||||
return result.text
|
||||
}
|
||||
} catch (e: NullPointerException) {
|
||||
Log.w(TAG, "Random null", e)
|
||||
} catch (e: ChecksumException) {
|
||||
Log.w(TAG, "QR code read and decoded, but checksum failed", e)
|
||||
} catch (e: FormatException) {
|
||||
Log.w(TAG, "Thrown when a barcode was successfully detected, but some aspect of the content did not conform to the barcodes format rules.", e)
|
||||
} catch (e: NotFoundException) {
|
||||
// Thanks ZXing...
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(QrProcessor::class.java)
|
||||
|
||||
/** For debugging only */
|
||||
var listener: ((LuminanceSource) -> Unit)? = null
|
||||
}
|
||||
}
|
||||
67
qr/lib/src/main/java/org/signal/qr/QrScannerView.kt
Normal file
67
qr/lib/src/main/java/org/signal/qr/QrScannerView.kt
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package org.signal.qr
|
||||
|
||||
import android.content.Context
|
||||
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
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
private var scannerView: ScannerView? = null
|
||||
private val qrDataPublish: PublishSubject<String> = PublishSubject.create()
|
||||
|
||||
val qrData: Observable<String> = qrDataPublish
|
||||
|
||||
private fun initScannerView(forceLegacy: Boolean) {
|
||||
val scannerView: FrameLayout = if (!forceLegacy) {
|
||||
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)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun start(lifecycleOwner: LifecycleOwner, forceLegacy: Boolean = false) {
|
||||
if (scannerView != null) {
|
||||
Log.w(TAG, "Attempt to start scanning that has already started")
|
||||
return
|
||||
}
|
||||
|
||||
initScannerView(forceLegacy)
|
||||
|
||||
scannerView?.start(lifecycleOwner)
|
||||
lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
qrDataPublish.onComplete()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun toggleCamera() {
|
||||
Log.d(TAG, "Toggling camera")
|
||||
scannerView?.toggleCamera()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(QrScannerView::class.java)
|
||||
}
|
||||
}
|
||||
11
qr/lib/src/main/java/org/signal/qr/ScannerView.kt
Normal file
11
qr/lib/src/main/java/org/signal/qr/ScannerView.kt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package org.signal.qr
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
/**
|
||||
* Common interface for interacting with QR scanning views.
|
||||
*/
|
||||
interface ScannerView {
|
||||
fun start(lifecycleOwner: LifecycleOwner)
|
||||
fun toggleCamera()
|
||||
}
|
||||
62
qr/lib/src/main/java/org/signal/qr/ScannerView19.kt
Normal file
62
qr/lib/src/main/java/org/signal/qr/ScannerView19.kt
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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 lifecycleOwner: LifecycleOwner? = null
|
||||
private var scanningThread: ScanningThread? = null
|
||||
private lateinit var cameraView: QrCameraView
|
||||
|
||||
private val lifecycleObserver = 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
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
cameraView = QrCameraView(context)
|
||||
cameraView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(cameraView)
|
||||
}
|
||||
|
||||
override fun start(lifecycleOwner: LifecycleOwner) {
|
||||
this.lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver)
|
||||
this.lifecycleOwner = lifecycleOwner
|
||||
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
|
||||
}
|
||||
|
||||
override fun toggleCamera() {
|
||||
cameraView.toggleCamera()
|
||||
lifecycleOwner?.let {
|
||||
lifecycleObserver.onPause(it)
|
||||
lifecycleObserver.onResume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
qr/lib/src/main/java/org/signal/qr/ScannerView21.kt
Normal file
123
qr/lib/src/main/java/org/signal/qr/ScannerView21.kt
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package org.signal.qr
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Size
|
||||
import android.widget.FrameLayout
|
||||
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")
|
||||
internal class ScannerView21 constructor(
|
||||
context: Context,
|
||||
private val listener: ScanListener
|
||||
) : FrameLayout(context), ScannerView {
|
||||
|
||||
private var lifecyleOwner: LifecycleOwner? = null
|
||||
private val analyzerExecutor = Executors.newSingleThreadExecutor()
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
private var camera: Camera? = null
|
||||
private var previewView: PreviewView
|
||||
private val qrProcessor = QrProcessor()
|
||||
|
||||
private val lifecycleObserver: DefaultLifecycleObserver = object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
cameraProvider = null
|
||||
camera = null
|
||||
analyzerExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
previewView = PreviewView(context)
|
||||
previewView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(previewView)
|
||||
}
|
||||
|
||||
override fun toggleCamera() {
|
||||
cameraSelector = if (cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) {
|
||||
CameraSelector.DEFAULT_FRONT_CAMERA
|
||||
} else {
|
||||
CameraSelector.DEFAULT_BACK_CAMERA
|
||||
}
|
||||
|
||||
lifecyleOwner?.let { start(it) }
|
||||
}
|
||||
|
||||
override fun start(lifecycleOwner: LifecycleOwner) {
|
||||
this.lifecyleOwner?.lifecycle?.removeObserver(lifecycleObserver)
|
||||
this.lifecyleOwner = 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(lifecycleObserver)
|
||||
}
|
||||
|
||||
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 resolution = Size(480, 640)
|
||||
|
||||
val preview = Preview.Builder()
|
||||
.setTargetResolution(resolution)
|
||||
.build()
|
||||
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(resolution)
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
|
||||
imageAnalysis.setAnalyzer(analyzerExecutor) { proxy ->
|
||||
proxy.use {
|
||||
val data: String? = qrProcessor.getScannedData(it)
|
||||
if (data != null) {
|
||||
listener.onQrDataFound(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cameraProvider.unbindAll()
|
||||
camera = cameraProvider.bindToLifecycle(lifecycle, cameraSelector, preview, imageAnalysis)
|
||||
|
||||
preview.setSurfaceProvider(previewView.surfaceProvider)
|
||||
|
||||
Log.d(TAG, "Preview: ${preview.resolutionInfo}")
|
||||
Log.d(TAG, "Analysis: ${imageAnalysis.resolutionInfo}")
|
||||
|
||||
this.cameraProvider = cameraProvider
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ScannerView21::class.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;
|
||||
}
|
||||
}
|
||||
109
qr/lib/src/main/java/org/signal/qr/kitkat/CameraUtils.java
Normal file
109
qr/lib/src/main/java/org/signal/qr/kitkat/CameraUtils.java
Normal file
|
|
@ -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<Size> sizes = parameters.getSupportedPreviewSizes();
|
||||
List<Size> ideals = new LinkedList<>();
|
||||
List<Size> 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<Size> {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
480
qr/lib/src/main/java/org/signal/qr/kitkat/QrCameraView.java
Normal file
480
qr/lib/src/main/java/org/signal/qr/kitkat/QrCameraView.java
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
/*
|
||||
Copyright (c) 2013-2014 CommonsWare, LLC
|
||||
Portions Copyright (C) 2007 The Android Open Source Project
|
||||
<p>
|
||||
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> camera = Optional.empty();
|
||||
private volatile int cameraId = CameraInfo.CAMERA_FACING_BACK;
|
||||
private volatile int displayOrientation = -1;
|
||||
|
||||
private @NonNull State state = State.PAUSED;
|
||||
private @Nullable Size previewSize;
|
||||
private final List<CameraViewListener> 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<Void>() {
|
||||
@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<Void>() {
|
||||
private Optional<Camera> 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;
|
||||
}
|
||||
|
||||
public void toggleCamera() {
|
||||
if (cameraId == CameraInfo.CAMERA_FACING_BACK) {
|
||||
cameraId = CameraInfo.CAMERA_FACING_FRONT;
|
||||
} else {
|
||||
cameraId = CameraInfo.CAMERA_FACING_BACK;
|
||||
}
|
||||
}
|
||||
|
||||
@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<Void>() {
|
||||
@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<String> 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<Void>() {
|
||||
@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<Result> 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<Result> extends SerialAsyncTask<Result> {
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package org.signal.qr.kitkat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public interface ScanListener {
|
||||
void onQrDataFound(@NonNull String data);
|
||||
}
|
||||
|
|
@ -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> scanListener = new AtomicReference<>();
|
||||
private final Map<DecodeHintType, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue