diff options
7 files changed, 274 insertions, 2 deletions
diff --git a/app/build.gradle b/app/build.gradle index 2dffdd54..dbc3d926 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -422,6 +422,10 @@ dependencies { implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' implementation 'androidx.lifecycle:lifecycle-process:2.7.0' implementation 'de.hdodenhof:circleimageview:3.1.0' + implementation 'com.google.mlkit:barcode-scanning:17.3.0' + implementation 'androidx.camera:camera-camera2:1.3.4' + implementation 'androidx.camera:camera-lifecycle:1.3.4' + implementation 'androidx.camera:camera-view:1.3.4' //implementation 'info.guardianproject:tor-android:0.4.5.7' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08433778..3a762180 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,7 +20,10 @@ android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <!-- Used to show all apps in the allowed Apps selection --> <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> - + <uses-feature + android:name="android.hardware.camera" + android:required="false" /> + <uses-permission android:name="android.permission.CAMERA"/> <application android:name=".base.BitmaskApp" android:allowBackup="false" @@ -111,6 +114,10 @@ <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" /> </intent-filter> </activity> + <activity android:name=".providersetup.activities.scanner.ScannerActivity" + android:launchMode="singleInstance" + android:screenOrientation="portrait" + android:exported="false" /> <activity android:name=".base.MainActivity" android:label="@string/app_name" diff --git a/app/src/main/java/se/leap/bitmaskclient/providersetup/activities/scanner/ScannerActivity.java b/app/src/main/java/se/leap/bitmaskclient/providersetup/activities/scanner/ScannerActivity.java new file mode 100644 index 00000000..e3c081c2 --- /dev/null +++ b/app/src/main/java/se/leap/bitmaskclient/providersetup/activities/scanner/ScannerActivity.java @@ -0,0 +1,150 @@ +package se.leap.bitmaskclient.providersetup.activities.scanner; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.appcompat.app.AppCompatActivity; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ImageAnalysis; +import androidx.camera.core.ImageProxy; +import androidx.camera.core.Preview; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.lifecycle.ViewModelProvider; + +import com.google.mlkit.vision.barcode.BarcodeScanner; +import com.google.mlkit.vision.barcode.BarcodeScanning; +import com.google.mlkit.vision.barcode.common.Barcode; +import com.google.mlkit.vision.common.InputImage; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import se.leap.bitmaskclient.base.models.Introducer; +import se.leap.bitmaskclient.databinding.AScannerBinding; + +@OptIn(markerClass = androidx.camera.core.ExperimentalGetImage.class) +public class ScannerActivity extends AppCompatActivity { + public static final String INVITE_CODE = "invite_code"; + private static final String TAG = ScannerActivity.class.getSimpleName(); + private AScannerBinding binding; + private ProcessCameraProvider cameraProvider; + private Preview previewUseCase; + private CameraSelector cameraSelector; + private ImageAnalysis analysisUseCase; + + public static Intent newIntent(Context context) { + return new Intent(context, ScannerActivity.class); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = AScannerBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + } + + @Override + protected void onResume() { + super.onResume(); + initCamera(); + } + + private void initCamera() { + cameraSelector = new CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build(); + new ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(getApplication())).get(ScannerViewModel.class).getProcessCameraProvider().observe(this, cameraProvider -> { + this.cameraProvider = cameraProvider; + bindCameraUseCases(); + }); + } + + private void bindCameraUseCases() { + bindPreviewUseCase(); + bindAnalyseUseCase(); + } + + + private void bindPreviewUseCase() { + if (cameraProvider == null) { + return; + } + if (previewUseCase != null) { + cameraProvider.unbind(previewUseCase); + } + + previewUseCase = new Preview.Builder().setTargetRotation(binding.previewView.getDisplay().getRotation()).build(); + previewUseCase.setSurfaceProvider(binding.previewView.getSurfaceProvider()); + + try { + cameraProvider.bindToLifecycle(this, cameraSelector, previewUseCase); + } catch (Exception exception) { + Log.e(TAG, exception.getMessage()); + } + } + + + private void bindAnalyseUseCase() { + // Note that if you know which format of barcode your app is dealing with, detection will be + // faster to specify the supported barcode formats one by one, e.g. + // BarcodeScannerOptions.Builder() + // .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + // .build(); + BarcodeScanner barcodeScanner = BarcodeScanning.getClient(); + + if (cameraProvider == null) { + return; + } + if (analysisUseCase != null) { + cameraProvider.unbind(analysisUseCase); + } + + analysisUseCase = new ImageAnalysis.Builder().setTargetRotation(binding.previewView.getDisplay().getRotation()).build(); + + // Initialize our background executor + ExecutorService cameraExecutor = Executors.newSingleThreadExecutor(); + + analysisUseCase.setAnalyzer(cameraExecutor, imageProxy -> { + // Insert barcode scanning code here + processImageProxy(barcodeScanner, imageProxy); + }); + + try { + cameraProvider.bindToLifecycle(this, cameraSelector, analysisUseCase); + } catch (Exception exception) { + Log.e(TAG, exception.getMessage()); + } + } + + private void processImageProxy(BarcodeScanner barcodeScanner, ImageProxy imageProxy) { + InputImage inputImage = InputImage.fromMediaImage(imageProxy.getImage(), imageProxy.getImageInfo().getRotationDegrees()); + + barcodeScanner.process(inputImage).addOnSuccessListener(barcodes -> { + for (Barcode barcode : barcodes) { + try { + Introducer introducer = Introducer.fromUrl(barcode.getRawValue()); + if (introducer != null && introducer.validate()) { + imageProxy.close(); + setResult(RESULT_OK, new Intent().putExtra(INVITE_CODE, introducer)); + finish(); + } else { + Toast.makeText(this, "Invalid introducer", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, e.getMessage()); + Toast.makeText(this, "Invalid introducer", Toast.LENGTH_SHORT).show(); + } + } + }).addOnFailureListener(e -> { + Log.e(TAG, e.getMessage()); + }).addOnCompleteListener(task -> { + // When the image is from CameraX analysis use case, must call image.close() on received + // images when finished using them. Otherwise, new images may not be received or the camera + // may stall. + imageProxy.close(); + }); + } +} diff --git a/app/src/main/java/se/leap/bitmaskclient/providersetup/activities/scanner/ScannerViewModel.java b/app/src/main/java/se/leap/bitmaskclient/providersetup/activities/scanner/ScannerViewModel.java new file mode 100644 index 00000000..d749e512 --- /dev/null +++ b/app/src/main/java/se/leap/bitmaskclient/providersetup/activities/scanner/ScannerViewModel.java @@ -0,0 +1,42 @@ +package se.leap.bitmaskclient.providersetup.activities.scanner; + +import android.app.Application; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.concurrent.ExecutionException; + +public class ScannerViewModel extends AndroidViewModel { + private static final String TAG = ScannerViewModel.class.getSimpleName(); + private MutableLiveData<ProcessCameraProvider> cameraProviderLiveData; + + public ScannerViewModel(@NonNull Application application) { + super(application); + } + + public LiveData<ProcessCameraProvider> getProcessCameraProvider() { + if (cameraProviderLiveData == null) { + cameraProviderLiveData = new MutableLiveData<>(); + ListenableFuture<ProcessCameraProvider> cameraProviderFuture = ProcessCameraProvider.getInstance(getApplication()); + cameraProviderFuture.addListener(() -> { + try { + cameraProviderLiveData.setValue(cameraProviderFuture.get()); + } catch (ExecutionException e) { + // Handle any errors (including cancellation) here. + Log.e(TAG, "Unhandled exception", e); + } catch (InterruptedException e) { + Log.e(TAG, "Unhandled exception", e); + } + }, ContextCompat.getMainExecutor(getApplication())); + } + return cameraProviderLiveData; + } +} diff --git a/app/src/main/java/se/leap/bitmaskclient/providersetup/fragments/ProviderSelectionFragment.java b/app/src/main/java/se/leap/bitmaskclient/providersetup/fragments/ProviderSelectionFragment.java index e5c19e28..8ccf7993 100644 --- a/app/src/main/java/se/leap/bitmaskclient/providersetup/fragments/ProviderSelectionFragment.java +++ b/app/src/main/java/se/leap/bitmaskclient/providersetup/fragments/ProviderSelectionFragment.java @@ -3,6 +3,10 @@ package se.leap.bitmaskclient.providersetup.fragments; import static se.leap.bitmaskclient.providersetup.fragments.viewmodel.ProviderSelectionViewModel.ADD_PROVIDER; import static se.leap.bitmaskclient.providersetup.fragments.viewmodel.ProviderSelectionViewModel.INVITE_CODE_PROVIDER; +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; import android.graphics.Typeface; import android.os.Bundle; import android.text.Editable; @@ -12,14 +16,18 @@ import android.view.View; import android.view.ViewGroup; import android.widget.RadioButton; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; import androidx.lifecycle.ViewModelProvider; -import java.net.URISyntaxException; import java.util.ArrayList; import se.leap.bitmaskclient.R; +import se.leap.bitmaskclient.providersetup.activities.scanner.ScannerActivity; import se.leap.bitmaskclient.base.models.Introducer; import se.leap.bitmaskclient.base.models.Provider; import se.leap.bitmaskclient.base.utils.ViewHelper; @@ -30,6 +38,9 @@ import se.leap.bitmaskclient.providersetup.fragments.viewmodel.ProviderSelection public class ProviderSelectionFragment extends BaseSetupFragment implements CancelCallback { + private static final int PERMISSION_GRANTED_REQUEST_CODE = 1; + private ActivityResultLauncher<Intent> scannerActivityResultLauncher; + private ProviderSelectionViewModel viewModel; private ArrayList<RadioButton> radioButtons; @@ -49,6 +60,15 @@ public class ProviderSelectionFragment extends BaseSetupFragment implements Canc new ProviderSelectionViewModelFactory( getContext().getApplicationContext().getAssets())). get(ProviderSelectionViewModel.class); + scannerActivityResultLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null) { + Introducer introducer = data.getParcelableExtra(ScannerActivity.INVITE_CODE); + binding.editCustomProvider.setText(introducer.toUrl()); + } + } + }); } @Override @@ -80,6 +100,7 @@ public class ProviderSelectionFragment extends BaseSetupFragment implements Canc binding.editCustomProvider.setVisibility(viewModel.getEditProviderVisibility()); binding.syntaxCheck.setVisibility(viewModel.getEditProviderVisibility()); + binding.qrScanner.setVisibility(viewModel.getQrScannerVisibility()); return binding.getRoot(); } @@ -87,6 +108,28 @@ public class ProviderSelectionFragment extends BaseSetupFragment implements Canc public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); setupActivityCallback.registerCancelCallback(this); + initQrScanner(); + } + + private void initQrScanner() { + binding.qrScanner.setOnClickListener(v -> { + if (isCameraPermissionGranted()) { + scannerActivityResultLauncher.launch(ScannerActivity.newIntent(getContext())); + } else { + ActivityCompat.requestPermissions( + getActivity(), + new String[]{Manifest.permission.CAMERA}, + PERMISSION_GRANTED_REQUEST_CODE + ); + } + }); + } + + private boolean isCameraPermissionGranted() { + return ContextCompat.checkSelfPermission( + getContext(), + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED; } @Override @@ -102,6 +145,7 @@ public class ProviderSelectionFragment extends BaseSetupFragment implements Canc binding.providerDescription.setText(viewModel.getProviderDescription(getContext())); binding.editCustomProvider.setVisibility(viewModel.getEditProviderVisibility()); binding.syntaxCheck.setVisibility(viewModel.getEditProviderVisibility()); + binding.qrScanner.setVisibility(viewModel.getQrScannerVisibility()); if (viewModel.getCustomUrl() == null || viewModel.getCustomUrl().isEmpty()) { binding.syntaxCheckResult.setText(""); binding.syntaxCheckResult.setTextColor(getResources().getColor(R.color.color_font_btn)); diff --git a/app/src/main/java/se/leap/bitmaskclient/providersetup/fragments/viewmodel/ProviderSelectionViewModel.java b/app/src/main/java/se/leap/bitmaskclient/providersetup/fragments/viewmodel/ProviderSelectionViewModel.java index 58b43fbd..00117336 100644 --- a/app/src/main/java/se/leap/bitmaskclient/providersetup/fragments/viewmodel/ProviderSelectionViewModel.java +++ b/app/src/main/java/se/leap/bitmaskclient/providersetup/fragments/viewmodel/ProviderSelectionViewModel.java @@ -86,6 +86,13 @@ public class ProviderSelectionViewModel extends ViewModel { return provider.getDescription(); } + public int getQrScannerVisibility() { + if (selected == INVITE_CODE_PROVIDER) { + return View.VISIBLE; + } + return View.GONE; + } + public int getEditProviderVisibility() { if (selected == ADD_PROVIDER) { return View.VISIBLE; diff --git a/app/src/main/res/layout/a_scanner.xml b/app/src/main/res/layout/a_scanner.xml new file mode 100644 index 00000000..5fb2db53 --- /dev/null +++ b/app/src/main/res/layout/a_scanner.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".providersetup.activities.scanner.ScannerActivity"> + + <androidx.camera.view.PreviewView + android:id="@+id/previewView" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file |