diff --git a/.gitignore b/.gitignore index 625ff5896c3b..ccb0eeb34605 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .pub/ .dart_tool/ pubspec.lock +flutter_export_environment.sh examples/all_plugins/pubspec.yaml diff --git a/examples/all_plugins/android/app/build.gradle b/examples/all_plugins/android/app/build.gradle index 389706e34fb4..f4100d3139bd 100644 --- a/examples/all_plugins/android/app/build.gradle +++ b/examples/all_plugins/android/app/build.gradle @@ -1,3 +1,5 @@ +gradle.startParameter.showStacktrace = org.gradle.api.logging.configuration.ShowStacktrace.ALWAYS + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { diff --git a/examples/all_plugins/android/build.gradle b/examples/all_plugins/android/build.gradle index bb8a303898ca..95bbaca8b8a5 100644 --- a/examples/all_plugins/android/build.gradle +++ b/examples/all_plugins/android/build.gradle @@ -1,3 +1,5 @@ +gradle.startParameter.showStacktrace = org.gradle.api.logging.configuration.ShowStacktrace.ALWAYS + buildscript { repositories { google() diff --git a/packages/camera/android/build.gradle b/packages/camera/android/build.gradle index fbce7b86f8f7..dd544c084ba7 100644 --- a/packages/camera/android/build.gradle +++ b/packages/camera/android/build.gradle @@ -44,7 +44,12 @@ android { lintOptions { disable 'InvalidPackage' } + compileOptions { + sourceCompatibility = '1.8' + targetCompatibility = '1.8' + } dependencies { implementation 'androidx.annotation:annotation:1.0.0' + implementation 'androidx.core:core:1.0.0' } } diff --git a/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java new file mode 100644 index 000000000000..bf99f8d561d5 --- /dev/null +++ b/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -0,0 +1,513 @@ +package io.flutter.plugins.camera; + +import static android.view.OrientationEventListener.ORIENTATION_UNKNOWN; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.graphics.ImageFormat; +import android.graphics.SurfaceTexture; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraDevice; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CameraMetadata; +import android.hardware.camera2.CaptureFailure; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.Image; +import android.media.ImageReader; +import android.media.MediaRecorder; +import android.util.Size; +import android.view.OrientationEventListener; +import android.view.Surface; +import androidx.annotation.NonNull; +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.view.FlutterView; +import io.flutter.view.TextureRegistry.SurfaceTextureEntry; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Camera { + private final SurfaceTextureEntry flutterTexture; + private final CameraManager cameraManager; + private final OrientationEventListener orientationEventListener; + private final boolean isFrontFacing; + private final int sensorOrientation; + private final String cameraName; + private final Size captureSize; + private final Size previewSize; + private final Size videoSize; + private final boolean enableAudio; + + private CameraDevice cameraDevice; + private CameraCaptureSession cameraCaptureSession; + private ImageReader pictureImageReader; + private ImageReader imageStreamReader; + private EventChannel.EventSink eventSink; + private CaptureRequest.Builder captureRequestBuilder; + private MediaRecorder mediaRecorder; + private boolean recordingVideo; + private int currentOrientation = ORIENTATION_UNKNOWN; + + public Camera( + final Activity activity, + final FlutterView flutterView, + final String cameraName, + final String resolutionPreset, + final boolean enableAudio) + throws CameraAccessException { + if (activity == null) { + throw new IllegalStateException("No activity available!"); + } + + this.cameraName = cameraName; + this.enableAudio = enableAudio; + this.flutterTexture = flutterView.createSurfaceTexture(); + this.cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); + orientationEventListener = + new OrientationEventListener(activity.getApplicationContext()) { + @Override + public void onOrientationChanged(int i) { + if (i == ORIENTATION_UNKNOWN) { + return; + } + // Convert the raw deg angle to the nearest multiple of 90. + currentOrientation = (int) Math.round(i / 90.0) * 90; + } + }; + orientationEventListener.enable(); + + int minHeight; + switch (resolutionPreset) { + case "high": + minHeight = 720; + break; + case "medium": + minHeight = 480; + break; + case "low": + minHeight = 240; + break; + default: + throw new IllegalArgumentException("Unknown preset: " + resolutionPreset); + } + + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); + StreamConfigurationMap streamConfigurationMap = + characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + //noinspection ConstantConditions + sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); + //noinspection ConstantConditions + isFrontFacing = + characteristics.get(CameraCharacteristics.LENS_FACING) == CameraMetadata.LENS_FACING_FRONT; + captureSize = CameraUtils.computeBestCaptureSize(streamConfigurationMap); + Size[] sizes = + CameraUtils.computeBestPreviewAndRecordingSize( + activity, streamConfigurationMap, minHeight, getMediaOrientation(), captureSize); + videoSize = sizes[0]; + previewSize = sizes[1]; + } + + public void setupCameraEventChannel(EventChannel cameraEventChannel) { + cameraEventChannel.setStreamHandler( + new EventChannel.StreamHandler() { + @Override + public void onListen(Object arguments, EventChannel.EventSink sink) { + eventSink = sink; + } + + @Override + public void onCancel(Object arguments) { + eventSink = null; + } + }); + } + + private void prepareMediaRecorder(String outputFilePath) throws IOException { + if (mediaRecorder != null) { + mediaRecorder.release(); + } + mediaRecorder = new MediaRecorder(); + + // There's a specific order that mediaRecorder expects. Do not change the order + // of these function calls. + if (enableAudio) mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); + mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); + mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + if (enableAudio) mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264); + mediaRecorder.setVideoEncodingBitRate(1024 * 1000); + if (enableAudio) mediaRecorder.setAudioSamplingRate(16000); + mediaRecorder.setVideoFrameRate(27); + mediaRecorder.setVideoSize(videoSize.getWidth(), videoSize.getHeight()); + mediaRecorder.setOutputFile(outputFilePath); + mediaRecorder.setOrientationHint(getMediaOrientation()); + + mediaRecorder.prepare(); + } + + @SuppressLint("MissingPermission") + public void open(@NonNull final Result result) throws CameraAccessException { + pictureImageReader = + ImageReader.newInstance( + captureSize.getWidth(), captureSize.getHeight(), ImageFormat.JPEG, 2); + + // Used to steam image byte data to dart side. + imageStreamReader = + ImageReader.newInstance( + previewSize.getWidth(), previewSize.getHeight(), ImageFormat.YUV_420_888, 2); + + cameraManager.openCamera( + cameraName, + new CameraDevice.StateCallback() { + @Override + public void onOpened(@NonNull CameraDevice device) { + cameraDevice = device; + try { + startPreview(); + } catch (CameraAccessException e) { + result.error("CameraAccess", e.getMessage(), null); + close(); + return; + } + Map reply = new HashMap<>(); + reply.put("textureId", flutterTexture.id()); + reply.put("previewWidth", previewSize.getWidth()); + reply.put("previewHeight", previewSize.getHeight()); + result.success(reply); + } + + @Override + public void onClosed(@NonNull CameraDevice camera) { + sendEvent(EventType.CAMERA_CLOSING); + super.onClosed(camera); + } + + @Override + public void onDisconnected(@NonNull CameraDevice cameraDevice) { + close(); + sendEvent(EventType.ERROR, "The camera was disconnected."); + } + + @Override + public void onError(@NonNull CameraDevice cameraDevice, int errorCode) { + close(); + String errorDescription; + switch (errorCode) { + case ERROR_CAMERA_IN_USE: + errorDescription = "The camera device is in use already."; + break; + case ERROR_MAX_CAMERAS_IN_USE: + errorDescription = "Max cameras in use"; + break; + case ERROR_CAMERA_DISABLED: + errorDescription = "The camera device could not be opened due to a device policy."; + break; + case ERROR_CAMERA_DEVICE: + errorDescription = "The camera device has encountered a fatal error"; + break; + case ERROR_CAMERA_SERVICE: + errorDescription = "The camera service has encountered a fatal error."; + break; + default: + errorDescription = "Unknown camera error"; + } + sendEvent(EventType.ERROR, errorDescription); + } + }, + null); + } + + private void writeToFile(ByteBuffer buffer, File file) throws IOException { + try (FileOutputStream outputStream = new FileOutputStream(file)) { + while (0 < buffer.remaining()) { + outputStream.getChannel().write(buffer); + } + } + } + + SurfaceTextureEntry getFlutterTexture() { + return flutterTexture; + } + + public void takePicture(String filePath, @NonNull final Result result) { + final File file = new File(filePath); + + if (file.exists()) { + result.error( + "fileExists", "File at path '" + filePath + "' already exists. Cannot overwrite.", null); + return; + } + + pictureImageReader.setOnImageAvailableListener( + reader -> { + try (Image image = reader.acquireLatestImage()) { + ByteBuffer buffer = image.getPlanes()[0].getBuffer(); + writeToFile(buffer, file); + result.success(null); + } catch (IOException e) { + result.error("IOError", "Failed saving image", null); + } + }, + null); + + try { + final CaptureRequest.Builder captureBuilder = + cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); + captureBuilder.addTarget(pictureImageReader.getSurface()); + captureBuilder.set(CaptureRequest.JPEG_ORIENTATION, getMediaOrientation()); + + cameraCaptureSession.capture( + captureBuilder.build(), + new CameraCaptureSession.CaptureCallback() { + @Override + public void onCaptureFailed( + @NonNull CameraCaptureSession session, + @NonNull CaptureRequest request, + @NonNull CaptureFailure failure) { + String reason; + switch (failure.getReason()) { + case CaptureFailure.REASON_ERROR: + reason = "An error happened in the framework"; + break; + case CaptureFailure.REASON_FLUSHED: + reason = "The capture has failed due to an abortCaptures() call"; + break; + default: + reason = "Unknown reason"; + } + result.error("captureFailure", reason, null); + } + }, + null); + } catch (CameraAccessException e) { + result.error("cameraAccess", e.getMessage(), null); + } + } + + private void createCaptureSession(int templateType, Surface... surfaces) + throws CameraAccessException { + createCaptureSession(templateType, null, surfaces); + } + + private void createCaptureSession( + int templateType, Runnable onSuccessCallback, Surface... surfaces) + throws CameraAccessException { + // Close any existing capture session. + closeCaptureSession(); + + // Create a new capture builder. + captureRequestBuilder = cameraDevice.createCaptureRequest(templateType); + + // Build Flutter surface to render to + SurfaceTexture surfaceTexture = flutterTexture.surfaceTexture(); + surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); + Surface flutterSurface = new Surface(surfaceTexture); + captureRequestBuilder.addTarget(flutterSurface); + + List remainingSurfaces = Arrays.asList(surfaces); + if (templateType != CameraDevice.TEMPLATE_PREVIEW) { + // If it is not preview mode, add all surfaces as targets. + for (Surface surface : remainingSurfaces) { + captureRequestBuilder.addTarget(surface); + } + } + + // Prepare the callback + CameraCaptureSession.StateCallback callback = + new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(@NonNull CameraCaptureSession session) { + try { + if (cameraDevice == null) { + sendEvent(EventType.ERROR, "The camera was closed during configuration."); + return; + } + cameraCaptureSession = session; + captureRequestBuilder.set( + CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO); + cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); + if (onSuccessCallback != null) { + onSuccessCallback.run(); + } + } catch (CameraAccessException | IllegalStateException | IllegalArgumentException e) { + sendEvent(EventType.ERROR, e.getMessage()); + } + } + + @Override + public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { + sendEvent(EventType.ERROR, "Failed to configure camera session."); + } + }; + + // Collect all surfaces we want to render to. + List surfaceList = new ArrayList<>(); + surfaceList.add(flutterSurface); + surfaceList.addAll(remainingSurfaces); + // Start the session + cameraDevice.createCaptureSession(surfaceList, callback, null); + } + + public void startVideoRecording(String filePath, Result result) { + if (new File(filePath).exists()) { + result.error("fileExists", "File at path '" + filePath + "' already exists.", null); + return; + } + try { + prepareMediaRecorder(filePath); + recordingVideo = true; + createCaptureSession( + CameraDevice.TEMPLATE_RECORD, () -> mediaRecorder.start(), mediaRecorder.getSurface()); + result.success(null); + } catch (CameraAccessException | IOException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } + } + + public void stopVideoRecording(@NonNull final Result result) { + if (!recordingVideo) { + result.success(null); + return; + } + + try { + recordingVideo = false; + mediaRecorder.stop(); + mediaRecorder.reset(); + startPreview(); + result.success(null); + } catch (CameraAccessException | IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } + } + + public void startPreview() throws CameraAccessException { + createCaptureSession(CameraDevice.TEMPLATE_PREVIEW, pictureImageReader.getSurface()); + } + + public void startPreviewWithImageStream(EventChannel imageStreamChannel) + throws CameraAccessException { + createCaptureSession(CameraDevice.TEMPLATE_STILL_CAPTURE, imageStreamReader.getSurface()); + + imageStreamChannel.setStreamHandler( + new EventChannel.StreamHandler() { + @Override + public void onListen(Object o, EventChannel.EventSink imageStreamSink) { + setImageStreamImageAvailableListener(imageStreamSink); + } + + @Override + public void onCancel(Object o) { + imageStreamReader.setOnImageAvailableListener(null, null); + } + }); + } + + private void setImageStreamImageAvailableListener(final EventChannel.EventSink imageStreamSink) { + imageStreamReader.setOnImageAvailableListener( + reader -> { + Image img = reader.acquireLatestImage(); + if (img == null) return; + + List> planes = new ArrayList<>(); + for (Image.Plane plane : img.getPlanes()) { + ByteBuffer buffer = plane.getBuffer(); + + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes, 0, bytes.length); + + Map planeBuffer = new HashMap<>(); + planeBuffer.put("bytesPerRow", plane.getRowStride()); + planeBuffer.put("bytesPerPixel", plane.getPixelStride()); + planeBuffer.put("bytes", bytes); + + planes.add(planeBuffer); + } + + Map imageBuffer = new HashMap<>(); + imageBuffer.put("width", img.getWidth()); + imageBuffer.put("height", img.getHeight()); + imageBuffer.put("format", img.getFormat()); + imageBuffer.put("planes", planes); + + imageStreamSink.success(imageBuffer); + img.close(); + }, + null); + } + + private void sendEvent(EventType eventType) { + sendEvent(eventType, null); + } + + private void sendEvent(EventType eventType, String description) { + if (eventSink != null) { + Map event = new HashMap<>(); + event.put("eventType", eventType.toString().toLowerCase()); + // Only errors have description + if (eventType != EventType.ERROR) { + event.put("errorDescription", description); + } + eventSink.success(event); + } + } + + private void closeCaptureSession() { + if (cameraCaptureSession != null) { + cameraCaptureSession.close(); + cameraCaptureSession = null; + } + } + + public void close() { + closeCaptureSession(); + + if (cameraDevice != null) { + cameraDevice.close(); + cameraDevice = null; + } + if (pictureImageReader != null) { + pictureImageReader.close(); + pictureImageReader = null; + } + if (imageStreamReader != null) { + imageStreamReader.close(); + imageStreamReader = null; + } + if (mediaRecorder != null) { + mediaRecorder.reset(); + mediaRecorder.release(); + mediaRecorder = null; + } + } + + public void dispose() { + close(); + flutterTexture.release(); + orientationEventListener.disable(); + } + + private int getMediaOrientation() { + final int sensorOrientationOffset = + (currentOrientation == ORIENTATION_UNKNOWN) + ? 0 + : (isFrontFacing) ? -currentOrientation : currentOrientation; + return (sensorOrientationOffset + sensorOrientation + 360) % 360; + } + + private enum EventType { + ERROR, + CAMERA_CLOSING, + } +} diff --git a/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPermissions.java b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPermissions.java new file mode 100644 index 000000000000..d703af819181 --- /dev/null +++ b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPermissions.java @@ -0,0 +1,80 @@ +package io.flutter.plugins.camera; + +import android.Manifest; +import android.Manifest.permission; +import android.app.Activity; +import android.content.pm.PackageManager; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import io.flutter.plugin.common.PluginRegistry; +import io.flutter.plugin.common.PluginRegistry.Registrar; + +public class CameraPermissions { + private static final int CAMERA_REQUEST_ID = 513469796; + private boolean ongoing = false; + + public void requestPermissions( + Registrar registrar, boolean enableAudio, ResultCallback callback) { + if (ongoing) { + callback.onResult("cameraPermission", "Camera permission request ongoing"); + } + Activity activity = registrar.activity(); + if (!hasCameraPermission(activity) || (enableAudio && !hasAudioPermission(activity))) { + registrar.addRequestPermissionsResultListener( + new CameraRequestPermissionsListener( + (String errorCode, String errorDescription) -> { + ongoing = false; + callback.onResult(errorCode, errorDescription); + })); + ongoing = true; + ActivityCompat.requestPermissions( + activity, + enableAudio + ? new String[] {Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO} + : new String[] {Manifest.permission.CAMERA}, + CAMERA_REQUEST_ID); + } else { + // Permissions already exist. Call the callback with success. + callback.onResult(null, null); + } + } + + private boolean hasCameraPermission(Activity activity) { + return ContextCompat.checkSelfPermission(activity, permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + private boolean hasAudioPermission(Activity activity) { + return ContextCompat.checkSelfPermission(activity, permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + } + + private static class CameraRequestPermissionsListener + implements PluginRegistry.RequestPermissionsResultListener { + final ResultCallback callback; + + private CameraRequestPermissionsListener(ResultCallback callback) { + this.callback = callback; + } + + @Override + public boolean onRequestPermissionsResult(int id, String[] permissions, int[] grantResults) { + if (id == CAMERA_REQUEST_ID) { + if (grantResults[0] != PackageManager.PERMISSION_GRANTED) { + callback.onResult("cameraPermission", "MediaRecorderCamera permission not granted"); + } else if (grantResults.length > 1 + && grantResults[1] != PackageManager.PERMISSION_GRANTED) { + callback.onResult("cameraPermission", "MediaRecorderAudio permission not granted"); + } else { + callback.onResult(null, null); + } + return true; + } + return false; + } + } + + interface ResultCallback { + void onResult(String errorCode, String errorDescription); + } +} diff --git a/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java index 1a6b4c0de984..2d16e0be80ef 100644 --- a/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java @@ -1,88 +1,33 @@ package io.flutter.plugins.camera; -import static android.view.OrientationEventListener.ORIENTATION_UNKNOWN; - -import android.Manifest; -import android.app.Activity; -import android.content.Context; -import android.content.pm.PackageManager; -import android.graphics.ImageFormat; -import android.graphics.Point; -import android.graphics.SurfaceTexture; import android.hardware.camera2.CameraAccessException; -import android.hardware.camera2.CameraCaptureSession; -import android.hardware.camera2.CameraCharacteristics; -import android.hardware.camera2.CameraDevice; -import android.hardware.camera2.CameraManager; -import android.hardware.camera2.CameraMetadata; -import android.hardware.camera2.CaptureFailure; -import android.hardware.camera2.CaptureRequest; -import android.hardware.camera2.params.StreamConfigurationMap; -import android.media.Image; -import android.media.ImageReader; -import android.media.MediaRecorder; import android.os.Build; -import android.util.Size; -import android.view.Display; -import android.view.OrientationEventListener; -import android.view.Surface; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.plugin.common.PluginRegistry; import io.flutter.plugin.common.PluginRegistry.Registrar; import io.flutter.view.FlutterView; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; public class CameraPlugin implements MethodCallHandler { - private static final int CAMERA_REQUEST_ID = 513469796; - private static final String TAG = "CameraPlugin"; - - private static CameraManager cameraManager; + private final CameraPermissions cameraPermissions = new CameraPermissions(); private final FlutterView view; + private final Registrar registrar; + private final EventChannel imageStreamChannel; private Camera camera; - private Registrar registrar; - // The code to run after requesting camera permissions. - private Runnable cameraPermissionContinuation; - private final OrientationEventListener orientationEventListener; - private int currentOrientation = ORIENTATION_UNKNOWN; - private CameraPlugin(Registrar registrar, FlutterView view) { + private CameraPlugin(Registrar registrar) { this.registrar = registrar; - this.view = view; - - orientationEventListener = - new OrientationEventListener(registrar.activity().getApplicationContext()) { - @Override - public void onOrientationChanged(int i) { - if (i == ORIENTATION_UNKNOWN) { - return; - } - // Convert the raw deg angle to the nearest multiple of 90. - currentOrientation = (int) Math.round(i / 90.0) * 90; - } - }; - - registrar.addRequestPermissionsResultListener(new CameraRequestPermissionsListener()); + this.view = registrar.view(); + this.imageStreamChannel = + new EventChannel(registrar.messenger(), "plugins.flutter.io/camera/imageStream"); } public static void registerWith(Registrar registrar) { - if (registrar.activity() == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { // When a background flutter view tries to register the plugin, the registrar has no activity. // We stop the registration process as this plugin is foreground only. Also, if the sdk is // less than 21 (min sdk for Camera2) we don't register the plugin. @@ -92,61 +37,59 @@ public static void registerWith(Registrar registrar) { final MethodChannel channel = new MethodChannel(registrar.messenger(), "plugins.flutter.io/camera"); - cameraManager = (CameraManager) registrar.activity().getSystemService(Context.CAMERA_SERVICE); + channel.setMethodCallHandler(new CameraPlugin(registrar)); + } + + private void instantiateCamera(MethodCall call, Result result) throws CameraAccessException { + String cameraName = call.argument("cameraName"); + String resolutionPreset = call.argument("resolutionPreset"); + boolean enableAudio = call.argument("enableAudio"); + camera = new Camera(registrar.activity(), view, cameraName, resolutionPreset, enableAudio); + + EventChannel cameraEventChannel = + new EventChannel( + registrar.messenger(), + "flutter.io/cameraPlugin/cameraEvents" + camera.getFlutterTexture().id()); + camera.setupCameraEventChannel(cameraEventChannel); - channel.setMethodCallHandler(new CameraPlugin(registrar, registrar.view())); + camera.open(result); } @Override - public void onMethodCall(MethodCall call, final Result result) { + public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) { switch (call.method) { case "availableCameras": try { - String[] cameraNames = cameraManager.getCameraIdList(); - List> cameras = new ArrayList<>(); - for (String cameraName : cameraNames) { - HashMap details = new HashMap<>(); - CameraCharacteristics characteristics = - cameraManager.getCameraCharacteristics(cameraName); - details.put("name", cameraName); - @SuppressWarnings("ConstantConditions") - int sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); - details.put("sensorOrientation", sensorOrientation); - - int lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING); - switch (lensFacing) { - case CameraMetadata.LENS_FACING_FRONT: - details.put("lensFacing", "front"); - break; - case CameraMetadata.LENS_FACING_BACK: - details.put("lensFacing", "back"); - break; - case CameraMetadata.LENS_FACING_EXTERNAL: - details.put("lensFacing", "external"); - break; - } - cameras.add(details); - } - result.success(cameras); + result.success(CameraUtils.getAvailableCameras(registrar.activity())); } catch (Exception e) { handleException(e, result); } break; case "initialize": { - String cameraName = call.argument("cameraName"); - String resolutionPreset = call.argument("resolutionPreset"); - boolean enableAudio = call.argument("enableAudio"); if (camera != null) { camera.close(); } - camera = new Camera(cameraName, resolutionPreset, result, enableAudio); - orientationEventListener.enable(); + cameraPermissions.requestPermissions( + registrar, + call.argument("enableAudio"), + (String errCode, String errDesc) -> { + if (errCode == null) { + try { + instantiateCamera(call, result); + } catch (Exception e) { + handleException(e, result); + } + } else { + result.error(errCode, errDesc, null); + } + }); + break; } case "takePicture": { - camera.takePicture((String) call.argument("path"), result); + camera.takePicture(call.argument("path"), result); break; } case "prepareForVideoRecording": @@ -157,8 +100,7 @@ public void onMethodCall(MethodCall call, final Result result) { } case "startVideoRecording": { - final String filePath = call.argument("filePath"); - camera.startVideoRecording(filePath, result); + camera.startVideoRecording(call.argument("filePath"), result); break; } case "stopVideoRecording": @@ -169,7 +111,7 @@ public void onMethodCall(MethodCall call, final Result result) { case "startImageStream": { try { - camera.startPreviewWithImageStream(); + camera.startPreviewWithImageStream(imageStreamChannel); result.success(null); } catch (Exception e) { handleException(e, result); @@ -191,7 +133,6 @@ public void onMethodCall(MethodCall call, final Result result) { if (camera != null) { camera.dispose(); } - orientationEventListener.disable(); result.success(null); break; } @@ -212,689 +153,4 @@ private void handleException(Exception exception, Result result) { throw (RuntimeException) exception; } - - private static class CompareSizesByArea implements Comparator { - @Override - public int compare(Size lhs, Size rhs) { - // We cast here to ensure the multiplications won't overflow. - return Long.signum( - (long) lhs.getWidth() * lhs.getHeight() - (long) rhs.getWidth() * rhs.getHeight()); - } - } - - private class CameraRequestPermissionsListener - implements PluginRegistry.RequestPermissionsResultListener { - @Override - public boolean onRequestPermissionsResult(int id, String[] permissions, int[] grantResults) { - if (id == CAMERA_REQUEST_ID) { - cameraPermissionContinuation.run(); - return true; - } - return false; - } - } - - private class Camera { - private final FlutterView.SurfaceTextureEntry textureEntry; - private CameraDevice cameraDevice; - private CameraCaptureSession cameraCaptureSession; - private EventChannel.EventSink eventSink; - private ImageReader pictureImageReader; - private ImageReader imageStreamReader; - private int sensorOrientation; - private boolean isFrontFacing; - private String cameraName; - private Size captureSize; - private Size previewSize; - private CaptureRequest.Builder captureRequestBuilder; - private Size videoSize; - private MediaRecorder mediaRecorder; - private boolean recordingVideo; - private boolean enableAudio; - - Camera( - final String cameraName, - final String resolutionPreset, - @NonNull final Result result, - final boolean enableAudio) { - - this.cameraName = cameraName; - this.enableAudio = enableAudio; - textureEntry = view.createSurfaceTexture(); - - registerEventChannel(); - - try { - int minHeight; - switch (resolutionPreset) { - case "high": - minHeight = 720; - break; - case "medium": - minHeight = 480; - break; - case "low": - minHeight = 240; - break; - default: - throw new IllegalArgumentException("Unknown preset: " + resolutionPreset); - } - - CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); - StreamConfigurationMap streamConfigurationMap = - characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); - //noinspection ConstantConditions - sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); - //noinspection ConstantConditions - isFrontFacing = - characteristics.get(CameraCharacteristics.LENS_FACING) - == CameraMetadata.LENS_FACING_FRONT; - computeBestCaptureSize(streamConfigurationMap); - computeBestPreviewAndRecordingSize(streamConfigurationMap, minHeight, captureSize); - - if (cameraPermissionContinuation != null) { - result.error("cameraPermission", "Camera permission request ongoing", null); - } - cameraPermissionContinuation = - new Runnable() { - @Override - public void run() { - cameraPermissionContinuation = null; - if (!hasCameraPermission()) { - result.error( - "cameraPermission", "MediaRecorderCamera permission not granted", null); - return; - } - if (enableAudio && !hasAudioPermission()) { - result.error( - "cameraPermission", "MediaRecorderAudio permission not granted", null); - return; - } - open(result); - } - }; - if (hasCameraPermission() && (!enableAudio || hasAudioPermission())) { - cameraPermissionContinuation.run(); - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - final Activity activity = registrar.activity(); - if (activity == null) { - throw new IllegalStateException("No activity available!"); - } - - activity.requestPermissions( - enableAudio - ? new String[] {Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO} - : new String[] {Manifest.permission.CAMERA}, - CAMERA_REQUEST_ID); - } - } - } catch (CameraAccessException e) { - result.error("CameraAccess", e.getMessage(), null); - } catch (IllegalArgumentException e) { - result.error("IllegalArgumentException", e.getMessage(), null); - } - } - - private void registerEventChannel() { - new EventChannel( - registrar.messenger(), "flutter.io/cameraPlugin/cameraEvents" + textureEntry.id()) - .setStreamHandler( - new EventChannel.StreamHandler() { - @Override - public void onListen(Object arguments, EventChannel.EventSink eventSink) { - Camera.this.eventSink = eventSink; - } - - @Override - public void onCancel(Object arguments) { - Camera.this.eventSink = null; - } - }); - } - - private boolean hasCameraPermission() { - final Activity activity = registrar.activity(); - if (activity == null) { - throw new IllegalStateException("No activity available!"); - } - - return Build.VERSION.SDK_INT < Build.VERSION_CODES.M - || activity.checkSelfPermission(Manifest.permission.CAMERA) - == PackageManager.PERMISSION_GRANTED; - } - - private boolean hasAudioPermission() { - final Activity activity = registrar.activity(); - if (activity == null) { - throw new IllegalStateException("No activity available!"); - } - - return Build.VERSION.SDK_INT < Build.VERSION_CODES.M - || activity.checkSelfPermission(Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED; - } - - private void computeBestPreviewAndRecordingSize( - StreamConfigurationMap streamConfigurationMap, int minHeight, Size captureSize) { - Size[] sizes = streamConfigurationMap.getOutputSizes(SurfaceTexture.class); - - // Preview size and video size should not be greater than screen resolution or 1080. - Point screenResolution = new Point(); - - final Activity activity = registrar.activity(); - if (activity == null) { - throw new IllegalStateException("No activity available!"); - } - - Display display = activity.getWindowManager().getDefaultDisplay(); - display.getRealSize(screenResolution); - - final boolean swapWH = getMediaOrientation() % 180 == 90; - int screenWidth = swapWH ? screenResolution.y : screenResolution.x; - int screenHeight = swapWH ? screenResolution.x : screenResolution.y; - - List goodEnough = new ArrayList<>(); - for (Size s : sizes) { - if (minHeight <= s.getHeight() - && s.getWidth() <= screenWidth - && s.getHeight() <= screenHeight - && s.getHeight() <= 1080) { - goodEnough.add(s); - } - } - - Collections.sort(goodEnough, new CompareSizesByArea()); - - if (goodEnough.isEmpty()) { - previewSize = sizes[0]; - videoSize = sizes[0]; - } else { - float captureSizeRatio = (float) captureSize.getWidth() / captureSize.getHeight(); - - previewSize = goodEnough.get(0); - for (Size s : goodEnough) { - if ((float) s.getWidth() / s.getHeight() == captureSizeRatio) { - previewSize = s; - break; - } - } - - Collections.reverse(goodEnough); - videoSize = goodEnough.get(0); - for (Size s : goodEnough) { - if ((float) s.getWidth() / s.getHeight() == captureSizeRatio) { - videoSize = s; - break; - } - } - } - } - - private void computeBestCaptureSize(StreamConfigurationMap streamConfigurationMap) { - // For still image captures, we use the largest available size. - captureSize = - Collections.max( - Arrays.asList(streamConfigurationMap.getOutputSizes(ImageFormat.JPEG)), - new CompareSizesByArea()); - } - - private void prepareMediaRecorder(String outputFilePath) throws IOException { - if (mediaRecorder != null) { - mediaRecorder.release(); - } - mediaRecorder = new MediaRecorder(); - - if (enableAudio) mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); - mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); - mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); - if (enableAudio) mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); - mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264); - mediaRecorder.setVideoEncodingBitRate(1024 * 1000); - if (enableAudio) mediaRecorder.setAudioSamplingRate(16000); - mediaRecorder.setVideoFrameRate(27); - mediaRecorder.setVideoSize(videoSize.getWidth(), videoSize.getHeight()); - mediaRecorder.setOutputFile(outputFilePath); - mediaRecorder.setOrientationHint(getMediaOrientation()); - - mediaRecorder.prepare(); - } - - private void open(@Nullable final Result result) { - if (!hasCameraPermission()) { - if (result != null) result.error("cameraPermission", "Camera permission not granted", null); - } else { - try { - pictureImageReader = - ImageReader.newInstance( - captureSize.getWidth(), captureSize.getHeight(), ImageFormat.JPEG, 2); - - // Used to steam image byte data to dart side. - imageStreamReader = - ImageReader.newInstance( - previewSize.getWidth(), previewSize.getHeight(), ImageFormat.YUV_420_888, 2); - - cameraManager.openCamera( - cameraName, - new CameraDevice.StateCallback() { - @Override - public void onOpened(@NonNull CameraDevice cameraDevice) { - Camera.this.cameraDevice = cameraDevice; - try { - startPreview(); - } catch (CameraAccessException e) { - if (result != null) result.error("CameraAccess", e.getMessage(), null); - cameraDevice.close(); - Camera.this.cameraDevice = null; - return; - } - - if (result != null) { - Map reply = new HashMap<>(); - reply.put("textureId", textureEntry.id()); - reply.put("previewWidth", previewSize.getWidth()); - reply.put("previewHeight", previewSize.getHeight()); - result.success(reply); - } - } - - @Override - public void onClosed(@NonNull CameraDevice camera) { - if (eventSink != null) { - Map event = new HashMap<>(); - event.put("eventType", "cameraClosing"); - eventSink.success(event); - } - super.onClosed(camera); - } - - @Override - public void onDisconnected(@NonNull CameraDevice cameraDevice) { - cameraDevice.close(); - Camera.this.cameraDevice = null; - sendErrorEvent("The camera was disconnected."); - } - - @Override - public void onError(@NonNull CameraDevice cameraDevice, int errorCode) { - cameraDevice.close(); - Camera.this.cameraDevice = null; - String errorDescription; - switch (errorCode) { - case ERROR_CAMERA_IN_USE: - errorDescription = "The camera device is in use already."; - break; - case ERROR_MAX_CAMERAS_IN_USE: - errorDescription = "Max cameras in use"; - break; - case ERROR_CAMERA_DISABLED: - errorDescription = - "The camera device could not be opened due to a device policy."; - break; - case ERROR_CAMERA_DEVICE: - errorDescription = "The camera device has encountered a fatal error"; - break; - case ERROR_CAMERA_SERVICE: - errorDescription = "The camera service has encountered a fatal error."; - break; - default: - errorDescription = "Unknown camera error"; - } - sendErrorEvent(errorDescription); - } - }, - null); - } catch (CameraAccessException e) { - if (result != null) result.error("cameraAccess", e.getMessage(), null); - } - } - } - - private void writeToFile(ByteBuffer buffer, File file) throws IOException { - try (FileOutputStream outputStream = new FileOutputStream(file)) { - while (0 < buffer.remaining()) { - outputStream.getChannel().write(buffer); - } - } - } - - private void takePicture(String filePath, @NonNull final Result result) { - final File file = new File(filePath); - - if (file.exists()) { - result.error( - "fileExists", - "File at path '" + filePath + "' already exists. Cannot overwrite.", - null); - return; - } - - pictureImageReader.setOnImageAvailableListener( - new ImageReader.OnImageAvailableListener() { - @Override - public void onImageAvailable(ImageReader reader) { - try (Image image = reader.acquireLatestImage()) { - ByteBuffer buffer = image.getPlanes()[0].getBuffer(); - writeToFile(buffer, file); - result.success(null); - } catch (IOException e) { - result.error("IOError", "Failed saving image", null); - } - } - }, - null); - - try { - final CaptureRequest.Builder captureBuilder = - cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); - captureBuilder.addTarget(pictureImageReader.getSurface()); - captureBuilder.set(CaptureRequest.JPEG_ORIENTATION, getMediaOrientation()); - - cameraCaptureSession.capture( - captureBuilder.build(), - new CameraCaptureSession.CaptureCallback() { - @Override - public void onCaptureFailed( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull CaptureFailure failure) { - String reason; - switch (failure.getReason()) { - case CaptureFailure.REASON_ERROR: - reason = "An error happened in the framework"; - break; - case CaptureFailure.REASON_FLUSHED: - reason = "The capture has failed due to an abortCaptures() call"; - break; - default: - reason = "Unknown reason"; - } - result.error("captureFailure", reason, null); - } - }, - null); - } catch (CameraAccessException e) { - result.error("cameraAccess", e.getMessage(), null); - } - } - - private void startVideoRecording(String filePath, @NonNull final Result result) { - if (cameraDevice == null) { - result.error("configureFailed", "Camera was closed during configuration.", null); - return; - } - if (new File(filePath).exists()) { - result.error( - "fileExists", - "File at path '" + filePath + "' already exists. Cannot overwrite.", - null); - return; - } - try { - closeCaptureSession(); - prepareMediaRecorder(filePath); - - recordingVideo = true; - - SurfaceTexture surfaceTexture = textureEntry.surfaceTexture(); - surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); - captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); - - List surfaces = new ArrayList<>(); - - Surface previewSurface = new Surface(surfaceTexture); - surfaces.add(previewSurface); - captureRequestBuilder.addTarget(previewSurface); - - Surface recorderSurface = mediaRecorder.getSurface(); - surfaces.add(recorderSurface); - captureRequestBuilder.addTarget(recorderSurface); - - cameraDevice.createCaptureSession( - surfaces, - new CameraCaptureSession.StateCallback() { - @Override - public void onConfigured(@NonNull CameraCaptureSession cameraCaptureSession) { - try { - if (cameraDevice == null) { - result.error("configureFailed", "Camera was closed during configuration", null); - return; - } - Camera.this.cameraCaptureSession = cameraCaptureSession; - captureRequestBuilder.set( - CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO); - cameraCaptureSession.setRepeatingRequest( - captureRequestBuilder.build(), null, null); - mediaRecorder.start(); - result.success(null); - } catch (CameraAccessException - | IllegalStateException - | IllegalArgumentException e) { - result.error("cameraException", e.getMessage(), null); - } - } - - @Override - public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { - result.error("configureFailed", "Failed to configure camera session", null); - } - }, - null); - } catch (CameraAccessException | IOException e) { - result.error("videoRecordingFailed", e.getMessage(), null); - } - } - - private void stopVideoRecording(@NonNull final Result result) { - if (!recordingVideo) { - result.success(null); - return; - } - - try { - recordingVideo = false; - mediaRecorder.stop(); - mediaRecorder.reset(); - startPreview(); - result.success(null); - } catch (CameraAccessException | IllegalStateException e) { - result.error("videoRecordingFailed", e.getMessage(), null); - } - } - - private void startPreview() throws CameraAccessException { - closeCaptureSession(); - - SurfaceTexture surfaceTexture = textureEntry.surfaceTexture(); - surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); - captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); - - List surfaces = new ArrayList<>(); - - Surface previewSurface = new Surface(surfaceTexture); - surfaces.add(previewSurface); - captureRequestBuilder.addTarget(previewSurface); - - surfaces.add(pictureImageReader.getSurface()); - - cameraDevice.createCaptureSession( - surfaces, - new CameraCaptureSession.StateCallback() { - - @Override - public void onConfigured(@NonNull CameraCaptureSession session) { - if (cameraDevice == null) { - sendErrorEvent("The camera was closed during configuration."); - return; - } - try { - cameraCaptureSession = session; - captureRequestBuilder.set( - CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO); - cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); - } catch (CameraAccessException | IllegalStateException | IllegalArgumentException e) { - sendErrorEvent(e.getMessage()); - } - } - - @Override - public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { - sendErrorEvent("Failed to configure the camera for preview."); - } - }, - null); - } - - private void startPreviewWithImageStream() throws CameraAccessException { - closeCaptureSession(); - - SurfaceTexture surfaceTexture = textureEntry.surfaceTexture(); - surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); - - captureRequestBuilder = - cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); - - List surfaces = new ArrayList<>(); - - Surface previewSurface = new Surface(surfaceTexture); - surfaces.add(previewSurface); - captureRequestBuilder.addTarget(previewSurface); - - surfaces.add(imageStreamReader.getSurface()); - captureRequestBuilder.addTarget(imageStreamReader.getSurface()); - - cameraDevice.createCaptureSession( - surfaces, - new CameraCaptureSession.StateCallback() { - @Override - public void onConfigured(@NonNull CameraCaptureSession session) { - if (cameraDevice == null) { - sendErrorEvent("The camera was closed during configuration."); - return; - } - try { - cameraCaptureSession = session; - captureRequestBuilder.set( - CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO); - cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); - } catch (CameraAccessException | IllegalStateException | IllegalArgumentException e) { - sendErrorEvent(e.getMessage()); - } - } - - @Override - public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { - sendErrorEvent("Failed to configure the camera for streaming images."); - } - }, - null); - - registerImageStreamEventChannel(); - } - - private void registerImageStreamEventChannel() { - final EventChannel imageStreamChannel = - new EventChannel(registrar.messenger(), "plugins.flutter.io/camera/imageStream"); - - imageStreamChannel.setStreamHandler( - new EventChannel.StreamHandler() { - @Override - public void onListen(Object o, EventChannel.EventSink eventSink) { - setImageStreamImageAvailableListener(eventSink); - } - - @Override - public void onCancel(Object o) { - imageStreamReader.setOnImageAvailableListener(null, null); - } - }); - } - - private void setImageStreamImageAvailableListener(final EventChannel.EventSink eventSink) { - imageStreamReader.setOnImageAvailableListener( - new ImageReader.OnImageAvailableListener() { - @Override - public void onImageAvailable(final ImageReader reader) { - Image img = reader.acquireLatestImage(); - if (img == null) return; - - List> planes = new ArrayList<>(); - for (Image.Plane plane : img.getPlanes()) { - ByteBuffer buffer = plane.getBuffer(); - - byte[] bytes = new byte[buffer.remaining()]; - buffer.get(bytes, 0, bytes.length); - - Map planeBuffer = new HashMap<>(); - planeBuffer.put("bytesPerRow", plane.getRowStride()); - planeBuffer.put("bytesPerPixel", plane.getPixelStride()); - planeBuffer.put("bytes", bytes); - - planes.add(planeBuffer); - } - - Map imageBuffer = new HashMap<>(); - imageBuffer.put("width", img.getWidth()); - imageBuffer.put("height", img.getHeight()); - imageBuffer.put("format", img.getFormat()); - imageBuffer.put("planes", planes); - - eventSink.success(imageBuffer); - img.close(); - } - }, - null); - } - - private void sendErrorEvent(String errorDescription) { - if (eventSink != null) { - Map event = new HashMap<>(); - event.put("eventType", "error"); - event.put("errorDescription", errorDescription); - eventSink.success(event); - } - } - - private void closeCaptureSession() { - if (cameraCaptureSession != null) { - cameraCaptureSession.close(); - cameraCaptureSession = null; - } - } - - private void close() { - closeCaptureSession(); - - if (cameraDevice != null) { - cameraDevice.close(); - cameraDevice = null; - } - if (pictureImageReader != null) { - pictureImageReader.close(); - pictureImageReader = null; - } - if (imageStreamReader != null) { - imageStreamReader.close(); - imageStreamReader = null; - } - if (mediaRecorder != null) { - mediaRecorder.reset(); - mediaRecorder.release(); - mediaRecorder = null; - } - } - - private void dispose() { - close(); - textureEntry.release(); - } - - private int getMediaOrientation() { - final int sensorOrientationOffset = - (currentOrientation == ORIENTATION_UNKNOWN) - ? 0 - : (isFrontFacing) ? -currentOrientation : currentOrientation; - return (sensorOrientationOffset + sensorOrientation + 360) % 360; - } - } } diff --git a/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java new file mode 100644 index 000000000000..517db1537041 --- /dev/null +++ b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java @@ -0,0 +1,129 @@ +package io.flutter.plugins.camera; + +import android.app.Activity; +import android.content.Context; +import android.graphics.ImageFormat; +import android.graphics.Point; +import android.graphics.SurfaceTexture; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CameraMetadata; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.util.Size; +import android.view.Display; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Provides various utilities for camera. */ +public final class CameraUtils { + + private CameraUtils() {} + + static Size[] computeBestPreviewAndRecordingSize( + Activity activity, + StreamConfigurationMap streamConfigurationMap, + int minHeight, + int orientation, + Size captureSize) { + Size previewSize, videoSize; + Size[] sizes = streamConfigurationMap.getOutputSizes(SurfaceTexture.class); + + // Preview size and video size should not be greater than screen resolution or 1080. + Point screenResolution = new Point(); + + Display display = activity.getWindowManager().getDefaultDisplay(); + display.getRealSize(screenResolution); + + final boolean swapWH = orientation % 180 == 90; + int screenWidth = swapWH ? screenResolution.y : screenResolution.x; + int screenHeight = swapWH ? screenResolution.x : screenResolution.y; + + List goodEnough = new ArrayList<>(); + for (Size s : sizes) { + if (minHeight <= s.getHeight() + && s.getWidth() <= screenWidth + && s.getHeight() <= screenHeight + && s.getHeight() <= 1080) { + goodEnough.add(s); + } + } + + Collections.sort(goodEnough, new CompareSizesByArea()); + + if (goodEnough.isEmpty()) { + previewSize = sizes[0]; + videoSize = sizes[0]; + } else { + float captureSizeRatio = (float) captureSize.getWidth() / captureSize.getHeight(); + + previewSize = goodEnough.get(0); + for (Size s : goodEnough) { + if ((float) s.getWidth() / s.getHeight() == captureSizeRatio) { + previewSize = s; + break; + } + } + + Collections.reverse(goodEnough); + videoSize = goodEnough.get(0); + for (Size s : goodEnough) { + if ((float) s.getWidth() / s.getHeight() == captureSizeRatio) { + videoSize = s; + break; + } + } + } + return new Size[] {videoSize, previewSize}; + } + + static Size computeBestCaptureSize(StreamConfigurationMap streamConfigurationMap) { + // For still image captures, we use the largest available size. + return Collections.max( + Arrays.asList(streamConfigurationMap.getOutputSizes(ImageFormat.JPEG)), + new CompareSizesByArea()); + } + + public static List> getAvailableCameras(Activity activity) + throws CameraAccessException { + CameraManager cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); + String[] cameraNames = cameraManager.getCameraIdList(); + List> cameras = new ArrayList<>(); + for (String cameraName : cameraNames) { + HashMap details = new HashMap<>(); + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); + details.put("name", cameraName); + int sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); + details.put("sensorOrientation", sensorOrientation); + + int lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING); + switch (lensFacing) { + case CameraMetadata.LENS_FACING_FRONT: + details.put("lensFacing", "front"); + break; + case CameraMetadata.LENS_FACING_BACK: + details.put("lensFacing", "back"); + break; + case CameraMetadata.LENS_FACING_EXTERNAL: + details.put("lensFacing", "external"); + break; + } + cameras.add(details); + } + return cameras; + } + + private static class CompareSizesByArea implements Comparator { + @Override + public int compare(Size lhs, Size rhs) { + // We cast here to ensure the multiplications won't overflow. + return Long.signum( + (long) lhs.getWidth() * lhs.getHeight() - (long) rhs.getWidth() * rhs.getHeight()); + } + } +} diff --git a/packages/camera/lib/new/camera.dart b/packages/camera/lib/new/camera.dart index ab135079d2dd..08b085f8e2c8 100644 --- a/packages/camera/lib/new/camera.dart +++ b/packages/camera/lib/new/camera.dart @@ -6,3 +6,5 @@ export 'src/camera_controller.dart'; export 'src/camera_testing.dart'; export 'src/common/camera_interface.dart'; export 'src/common/native_texture.dart'; +export 'src/support_android/camera.dart'; +export 'src/support_android/camera_info.dart'; diff --git a/packages/camera/lib/new/src/support_android/camera.dart b/packages/camera/lib/new/src/support_android/camera.dart new file mode 100644 index 000000000000..d78753d24355 --- /dev/null +++ b/packages/camera/lib/new/src/support_android/camera.dart @@ -0,0 +1,120 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import '../common/camera_channel.dart'; +import '../common/camera_mixins.dart'; +import '../common/native_texture.dart'; +import 'camera_info.dart'; + +/// The Camera class used to set image capture settings, start/stop preview, snap pictures, and retrieve frames for encoding for video. +/// +/// This class is a client for the Camera service, which manages the actual +/// camera hardware. +/// +/// This exposes the deprecated Android +/// [Camera](https://developer.android.com/reference/android/hardware/Camera) +/// API. This should only be used with Android sdk versions less than 21. +class Camera with NativeMethodCallHandler { + Camera._(); + + bool _isClosed = false; + + /// Retrieves the number of physical cameras available on this device. + static Future getNumberOfCameras() { + return CameraChannel.channel.invokeMethod( + 'Camera#getNumberOfCameras', + ); + } + + /// Creates a new [Camera] object to access a particular hardware camera. + /// + /// If the same camera is opened by other applications, this will throw a + /// [PlatformException]. + /// + /// You must call [release] when you are done using the camera, otherwise it + /// will remain locked and be unavailable to other applications. + /// + /// Your application should only have one [Camera] object active at a time for + /// a particular hardware camera. + static Camera open(int cameraId) { + final Camera camera = Camera._(); + + CameraChannel.channel.invokeMethod( + 'Camera#open', + {'cameraId': cameraId, 'cameraHandle': camera.handle}, + ); + + return camera; + } + + /// Retrieves information about a particular camera. + /// + /// If [getNumberOfCameras] returns N, the valid id is 0 to N-1. + static Future getCameraInfo(int cameraId) async { + final Map infoMap = + await CameraChannel.channel.invokeMapMethod( + 'Camera#getCameraInfo', + {'cameraId': cameraId}, + ); + + return CameraInfo.fromMap(infoMap); + } + + /// Sets the [NativeTexture] to be used for live preview. + /// + /// This method must be called before [startPreview]. + /// + /// The one exception is that if the preview native texture is not set (or + /// set to null) before [startPreview] is called, then this method may be + /// called once with a non-null parameter to set the preview texture. + /// (This allows camera setup and surface creation to happen in parallel, + /// saving time.) The preview native texture may not otherwise change while + /// preview is running. + set previewTexture(NativeTexture texture) { + assert(!_isClosed); + + CameraChannel.channel.invokeMethod( + 'Camera#previewTexture', + {'handle': handle, 'nativeTexture': texture?.asMap()}, + ); + } + + /// Starts capturing and drawing preview frames to the screen. + /// + /// Preview will not actually start until a surface is supplied with + /// [previewTexture]. + Future startPreview() { + assert(!_isClosed); + + return CameraChannel.channel.invokeMethod( + 'Camera#startPreview', + {'handle': handle}, + ); + } + + /// Stops capturing and drawing preview frames to the [previewTexture], and resets the camera for a future call to [startPreview]. + Future stopPreview() { + assert(!_isClosed); + + return CameraChannel.channel.invokeMethod( + 'Camera#stopPreview', + {'handle': handle}, + ); + } + + /// Disconnects and releases the Camera object resources. + /// + /// You must call this as soon as you're done with the Camera object. + Future release() { + if (_isClosed) return Future.value(); + + _isClosed = true; + return CameraChannel.channel.invokeMethod( + 'Camera#release', + {'handle': handle}, + ); + } +} diff --git a/packages/camera/lib/new/src/support_android/camera_info.dart b/packages/camera/lib/new/src/support_android/camera_info.dart new file mode 100644 index 000000000000..033fecfea6d9 --- /dev/null +++ b/packages/camera/lib/new/src/support_android/camera_info.dart @@ -0,0 +1,68 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import '../common/camera_interface.dart'; + +/// The direction that the camera faces. +enum Facing { back, front } + +/// Information about a camera. +/// +/// Retrieved from [Camera.getCameraInfo]. +class CameraInfo implements CameraDescription { + const CameraInfo({ + @required this.id, + @required this.facing, + @required this.orientation, + }) : assert(id != null), + assert(facing != null), + assert(orientation != null); + + factory CameraInfo.fromMap(Map map) { + return CameraInfo( + id: map['id'], + orientation: map['orientation'], + facing: Facing.values.firstWhere( + (Facing facing) => facing.toString() == map['facing'], + ), + ); + } + + /// Identifier for a particular camera. + final int id; + + /// The direction that the camera faces. + final Facing facing; + + /// The orientation of the camera image. + /// + /// The value is the angle that the camera image needs to be rotated clockwise + /// so it shows correctly on the display in its natural orientation. + /// It should be 0, 90, 180, or 270. + /// + /// For example, suppose a device has a naturally tall screen. The back-facing + /// camera sensor is mounted in landscape. You are looking at the screen. If + /// the top side of the camera sensor is aligned with the right edge of the + /// screen in natural orientation, the value should be 90. If the top side of + /// a front-facing camera sensor is aligned with the right of the screen, the + /// value should be 270. + final int orientation; + + @override + String get name => id.toString(); + + @override + LensDirection get direction { + switch (facing) { + case Facing.front: + return LensDirection.front; + case Facing.back: + return LensDirection.back; + } + + return null; + } +} diff --git a/packages/camera/test/support_android/support_android_test.dart b/packages/camera/test/support_android/support_android_test.dart new file mode 100644 index 000000000000..114f56ab3348 --- /dev/null +++ b/packages/camera/test/support_android/support_android_test.dart @@ -0,0 +1,141 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:camera/new/src/support_android/camera_info.dart'; +import 'package:camera/new/src/support_android/camera.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:camera/new/src/camera_testing.dart'; + +void main() { + group('Support Android Camera', () { + group('$Camera', () { + final List log = []; + setUpAll(() { + CameraTesting.channel + .setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + switch (methodCall.method) { + case 'Camera#getNumberOfCameras': + return 3; + case 'Camera#open': + return null; + case 'Camera#getCameraInfo': + return { + 'id': 3, + 'orientation': 90, + 'facing': Facing.front.toString(), + }; + case 'Camera#startPreview': + return null; + case 'Camera#stopPreview': + return null; + case 'Camera#release': + return null; + } + + throw ArgumentError.value( + methodCall.method, + 'methodCall.method', + 'No method found for', + ); + }); + }); + + setUp(() { + log.clear(); + CameraTesting.nextHandle = 0; + }); + + test('getNumberOfCameras', () async { + final int result = await Camera.getNumberOfCameras(); + + expect(result, 3); + expect(log, [ + isMethodCall( + '$Camera#getNumberOfCameras', + arguments: null, + ) + ]); + }); + + test('open', () { + Camera.open(14); + + expect(log, [ + isMethodCall( + '$Camera#open', + arguments: { + 'cameraId': 14, + 'cameraHandle': 0, + }, + ) + ]); + }); + + test('getCameraInfo', () async { + final CameraInfo info = await Camera.getCameraInfo(14); + + expect(info.id, 3); + expect(info.orientation, 90); + expect(info.facing, Facing.front); + + expect(log, [ + isMethodCall( + '$Camera#getCameraInfo', + arguments: {'cameraId': 14}, + ) + ]); + }); + + test('startPreview', () { + final Camera camera = Camera.open(0); + + log.clear(); + camera.startPreview(); + + expect(log, [ + isMethodCall( + '$Camera#startPreview', + arguments: { + 'handle': 0, + }, + ) + ]); + }); + + test('stopPreview', () { + final Camera camera = Camera.open(0); + + log.clear(); + camera.stopPreview(); + + expect(log, [ + isMethodCall( + '$Camera#stopPreview', + arguments: { + 'handle': 0, + }, + ) + ]); + }); + + test('release', () { + final Camera camera = Camera.open(0); + + log.clear(); + camera.release(); + + expect(log, [ + isMethodCall( + '$Camera#release', + arguments: { + 'handle': 0, + }, + ) + ]); + }); + }); + }); +} diff --git a/packages/cloud_firestore/CHANGELOG.md b/packages/cloud_firestore/CHANGELOG.md index f3a81406954c..f5d647e0b9db 100644 --- a/packages/cloud_firestore/CHANGELOG.md +++ b/packages/cloud_firestore/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.12.9 + +* New optional `includeMetadataChanges` parameter added to `DocumentReference.snapshots()` + and `Query.snapshots()` +* Fix example app crash when the `message` field was not a string +* Internal renaming of method names. + +## 0.12.8+1 + +* Add `metadata` to `QuerySnapshot`. + +## 0.12.8 + +* Updated how document ids are generated to more closely match native implementations. + ## 0.12.7+1 * Update google-services Android gradle plugin to 4.3.0 in documentation and examples. diff --git a/packages/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/cloudfirestore/CloudFirestorePlugin.java b/packages/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/cloudfirestore/CloudFirestorePlugin.java index 5254b8b1493d..e13697ab4c7d 100644 --- a/packages/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/cloudfirestore/CloudFirestorePlugin.java +++ b/packages/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/cloudfirestore/CloudFirestorePlugin.java @@ -31,6 +31,7 @@ import com.google.firebase.firestore.FirebaseFirestoreSettings; import com.google.firebase.firestore.GeoPoint; import com.google.firebase.firestore.ListenerRegistration; +import com.google.firebase.firestore.MetadataChanges; import com.google.firebase.firestore.Query; import com.google.firebase.firestore.QuerySnapshot; import com.google.firebase.firestore.SetOptions; @@ -192,6 +193,11 @@ private Map parseQuerySnapshot(QuerySnapshot querySnapshot) { } data.put("documentChanges", documentChanges); + Map metadata = new HashMap<>(); + metadata.put("hasPendingWrites", querySnapshot.getMetadata().hasPendingWrites()); + metadata.put("isFromCache", querySnapshot.getMetadata().isFromCache()); + data.put("metadata", metadata); + return data; } @@ -619,22 +625,32 @@ public void run() { int handle = nextListenerHandle++; EventObserver observer = new EventObserver(handle); observers.put(handle, observer); - listenerRegistrations.put(handle, getQuery(arguments).addSnapshotListener(observer)); + MetadataChanges metadataChanges = + (Boolean) arguments.get("includeMetadataChanges") + ? MetadataChanges.INCLUDE + : MetadataChanges.EXCLUDE; + listenerRegistrations.put( + handle, getQuery(arguments).addSnapshotListener(metadataChanges, observer)); result.success(handle); break; } - case "Query#addDocumentListener": + case "DocumentReference#addSnapshotListener": { Map arguments = call.arguments(); int handle = nextListenerHandle++; DocumentObserver observer = new DocumentObserver(handle); documentObservers.put(handle, observer); + MetadataChanges metadataChanges = + (Boolean) arguments.get("includeMetadataChanges") + ? MetadataChanges.INCLUDE + : MetadataChanges.EXCLUDE; listenerRegistrations.put( - handle, getDocumentReference(arguments).addSnapshotListener(observer)); + handle, + getDocumentReference(arguments).addSnapshotListener(metadataChanges, observer)); result.success(handle); break; } - case "Query#removeListener": + case "removeListener": { Map arguments = call.arguments(); int handle = (Integer) arguments.get("handle"); diff --git a/packages/cloud_firestore/example/lib/main.dart b/packages/cloud_firestore/example/lib/main.dart index 27138f2fbd9e..30c1e2ce3036 100755 --- a/packages/cloud_firestore/example/lib/main.dart +++ b/packages/cloud_firestore/example/lib/main.dart @@ -41,8 +41,11 @@ class MessageList extends StatelessWidget { itemCount: messageCount, itemBuilder: (_, int index) { final DocumentSnapshot document = snapshot.data.documents[index]; + final dynamic message = document['message']; return ListTile( - title: Text(document['message'] ?? ''), + title: Text( + message != null ? message.toString() : '', + ), subtitle: Text('Message ${index + 1} of $messageCount'), ); }, @@ -54,7 +57,9 @@ class MessageList extends StatelessWidget { class MyHomePage extends StatelessWidget { MyHomePage({this.firestore}); + final Firestore firestore; + CollectionReference get messages => firestore.collection('messages'); Future _addMessage() async { diff --git a/packages/cloud_firestore/example/test_driver/cloud_firestore.dart b/packages/cloud_firestore/example/test_driver/cloud_firestore.dart index d7d85db5b000..2c984f7d6985 100644 --- a/packages/cloud_firestore/example/test_driver/cloud_firestore.dart +++ b/packages/cloud_firestore/example/test_driver/cloud_firestore.dart @@ -1,8 +1,9 @@ import 'dart:async'; -import 'package:flutter_driver/driver_extension.dart'; -import 'package:flutter_test/flutter_test.dart'; + import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { final Completer completer = Completer(); @@ -51,6 +52,7 @@ void main() { .where('message', isEqualTo: 'Hello world!') .limit(1); final QuerySnapshot querySnapshot = await query.getDocuments(); + expect(querySnapshot.metadata, isNotNull); expect(querySnapshot.documents.first['message'], 'Hello world!'); final DocumentReference firstDoc = querySnapshot.documents.first.reference; @@ -70,6 +72,7 @@ void main() { .limit(1); final QuerySnapshot querySnapshot = await query.getDocuments(); expect(querySnapshot.documents.first['stars'], 5); + expect(querySnapshot.metadata, isNotNull); }); test('increment', () async { @@ -103,6 +106,36 @@ void main() { await ref.delete(); }); + test('includeMetadataChanges', () async { + final DocumentReference ref = firestore.collection('messages').document(); + final Stream snapshotWithoutMetadataChanges = + ref.snapshots(includeMetadataChanges: false).take(1); + final Stream snapshotsWithMetadataChanges = + ref.snapshots(includeMetadataChanges: true).take(3); + + ref.setData({'hello': 'world'}); + + final DocumentSnapshot snapshot = + await snapshotWithoutMetadataChanges.first; + expect(snapshot.metadata.hasPendingWrites, true); + expect(snapshot.metadata.isFromCache, true); + expect(snapshot.data['hello'], 'world'); + + final List snapshots = + await snapshotsWithMetadataChanges.toList(); + expect(snapshots[0].metadata.hasPendingWrites, true); + expect(snapshots[0].metadata.isFromCache, true); + expect(snapshots[0].data['hello'], 'world'); + expect(snapshots[1].metadata.hasPendingWrites, true); + expect(snapshots[1].metadata.isFromCache, false); + expect(snapshots[1].data['hello'], 'world'); + expect(snapshots[2].metadata.hasPendingWrites, false); + expect(snapshots[2].metadata.isFromCache, false); + expect(snapshots[2].data['hello'], 'world'); + + await ref.delete(); + }); + test('runTransaction', () async { final DocumentReference ref = firestore.collection('messages').document(); await ref.setData({ diff --git a/packages/cloud_firestore/ios/Classes/CloudFirestorePlugin.m b/packages/cloud_firestore/ios/Classes/CloudFirestorePlugin.m index a18c809d4098..71c4232af682 100644 --- a/packages/cloud_firestore/ios/Classes/CloudFirestorePlugin.m +++ b/packages/cloud_firestore/ios/Classes/CloudFirestorePlugin.m @@ -203,6 +203,10 @@ static FIRFirestoreSource getSource(NSDictionary *arguments) { @"documentChanges" : documentChanges, @"documents" : documents, @"metadatas" : metadatas, + @"metadata" : @{ + @"hasPendingWrites" : @(snapshot.metadata.hasPendingWrites), + @"isFromCache" : @(snapshot.metadata.isFromCache), + } }; } @@ -522,39 +526,52 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result message:[exception name] details:[exception reason]]); } + NSNumber *includeMetadataChanges = call.arguments[@"includeMetadataChanges"]; id listener = [query - addSnapshotListener:^(FIRQuerySnapshot *_Nullable snapshot, NSError *_Nullable error) { - if (snapshot == nil) { - result(getFlutterError(error)); - return; - } - NSMutableDictionary *arguments = [parseQuerySnapshot(snapshot) mutableCopy]; - [arguments setObject:handle forKey:@"handle"]; - [weakSelf.channel invokeMethod:@"QuerySnapshot" arguments:arguments]; - }]; + addSnapshotListenerWithIncludeMetadataChanges:includeMetadataChanges.boolValue + listener:^(FIRQuerySnapshot *_Nullable snapshot, + NSError *_Nullable error) { + if (snapshot == nil) { + result(getFlutterError(error)); + return; + } + NSMutableDictionary *arguments = + [parseQuerySnapshot(snapshot) mutableCopy]; + [arguments setObject:handle forKey:@"handle"]; + [weakSelf.channel invokeMethod:@"QuerySnapshot" + arguments:arguments]; + }]; _listeners[handle] = listener; result(handle); - } else if ([@"Query#addDocumentListener" isEqualToString:call.method]) { + } else if ([@"DocumentReference#addSnapshotListener" isEqualToString:call.method]) { __block NSNumber *handle = [NSNumber numberWithInt:_nextListenerHandle++]; FIRDocumentReference *document = getDocumentReference(call.arguments); - id listener = - [document addSnapshotListener:^(FIRDocumentSnapshot *snapshot, NSError *_Nullable error) { - if (snapshot == nil) { - result(getFlutterError(error)); - return; - } - [weakSelf.channel invokeMethod:@"DocumentSnapshot" - arguments:@{ - @"handle" : handle, - @"path" : snapshot ? snapshot.reference.path : [NSNull null], - @"data" : snapshot.exists ? snapshot.data : [NSNull null], - @"metadata" : snapshot ? @{ - @"hasPendingWrites" : @(snapshot.metadata.hasPendingWrites), - @"isFromCache" : @(snapshot.metadata.isFromCache), - } - : [NSNull null], - }]; - }]; + NSNumber *includeMetadataChanges = call.arguments[@"includeMetadataChanges"]; + id listener = [document + addSnapshotListenerWithIncludeMetadataChanges:includeMetadataChanges.boolValue + listener:^(FIRDocumentSnapshot *snapshot, + NSError *_Nullable error) { + if (snapshot == nil) { + result(getFlutterError(error)); + return; + } + [weakSelf.channel + invokeMethod:@"DocumentSnapshot" + arguments:@{ + @"handle" : handle, + @"path" : snapshot ? snapshot.reference.path + : [NSNull null], + @"data" : snapshot.exists ? snapshot.data + : [NSNull null], + @"metadata" : snapshot ? @{ + @"hasPendingWrites" : + @(snapshot.metadata.hasPendingWrites), + @"isFromCache" : + @(snapshot.metadata.isFromCache), + } + : [NSNull null], + }]; + }]; _listeners[handle] = listener; result(handle); } else if ([@"Query#getDocuments" isEqualToString:call.method]) { @@ -577,7 +594,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } result(parseQuerySnapshot(snapshot)); }]; - } else if ([@"Query#removeListener" isEqualToString:call.method]) { + } else if ([@"removeListener" isEqualToString:call.method]) { NSNumber *handle = call.arguments[@"handle"]; [[_listeners objectForKey:handle] remove]; [_listeners removeObjectForKey:handle]; diff --git a/packages/cloud_firestore/lib/cloud_firestore.dart b/packages/cloud_firestore/lib/cloud_firestore.dart index ec4a21ac2137..fba72b049cb6 100755 --- a/packages/cloud_firestore/lib/cloud_firestore.dart +++ b/packages/cloud_firestore/lib/cloud_firestore.dart @@ -15,7 +15,7 @@ import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; -import 'src/utils/push_id_generator.dart'; +import 'src/utils/auto_id_generator.dart'; part 'src/blob.dart'; part 'src/collection_reference.dart'; diff --git a/packages/cloud_firestore/lib/src/collection_reference.dart b/packages/cloud_firestore/lib/src/collection_reference.dart index ffa5e016acfd..f1de3a8ba4b9 100644 --- a/packages/cloud_firestore/lib/src/collection_reference.dart +++ b/packages/cloud_firestore/lib/src/collection_reference.dart @@ -40,7 +40,7 @@ class CollectionReference extends Query { DocumentReference document([String path]) { List childPath; if (path == null) { - final String key = PushIdGenerator.generatePushChildName(); + final String key = AutoIdGenerator.autoId(); childPath = List.from(_pathComponents)..add(key); } else { childPath = List.from(_pathComponents)..addAll(path.split(('/'))); diff --git a/packages/cloud_firestore/lib/src/document_reference.dart b/packages/cloud_firestore/lib/src/document_reference.dart index 2edf8ac548ad..413cf41be0be 100644 --- a/packages/cloud_firestore/lib/src/document_reference.dart +++ b/packages/cloud_firestore/lib/src/document_reference.dart @@ -116,7 +116,8 @@ class DocumentReference { /// Notifies of documents at this location // TODO(jackson): Reduce code duplication with [Query] - Stream snapshots() { + Stream snapshots({bool includeMetadataChanges = false}) { + assert(includeMetadataChanges != null); Future _handle; // It's fine to let the StreamController be garbage collected once all the // subscribers have cancelled; this analyzer warning is safe to ignore. @@ -124,10 +125,11 @@ class DocumentReference { controller = StreamController.broadcast( onListen: () { _handle = Firestore.channel.invokeMethod( - 'Query#addDocumentListener', + 'DocumentReference#addSnapshotListener', { 'app': firestore.app.name, 'path': path, + 'includeMetadataChanges': includeMetadataChanges, }, ).then((dynamic result) => result); _handle.then((int handle) { @@ -137,7 +139,7 @@ class DocumentReference { onCancel: () { _handle.then((int handle) async { await Firestore.channel.invokeMethod( - 'Query#removeListener', + 'removeListener', {'handle': handle}, ); Firestore._documentObservers.remove(handle); diff --git a/packages/cloud_firestore/lib/src/query.dart b/packages/cloud_firestore/lib/src/query.dart index e1f6e2aa5a96..114b48a6df57 100644 --- a/packages/cloud_firestore/lib/src/query.dart +++ b/packages/cloud_firestore/lib/src/query.dart @@ -50,7 +50,8 @@ class Query { /// Notifies of query results at this location // TODO(jackson): Reduce code duplication with [DocumentReference] - Stream snapshots() { + Stream snapshots({bool includeMetadataChanges = false}) { + assert(includeMetadataChanges != null); Future _handle; // It's fine to let the StreamController be garbage collected once all the // subscribers have cancelled; this analyzer warning is safe to ignore. @@ -64,6 +65,7 @@ class Query { 'path': _path, 'isCollectionGroup': _isCollectionGroup, 'parameters': _parameters, + 'includeMetadataChanges': includeMetadataChanges, }, ).then((dynamic result) => result); _handle.then((int handle) { @@ -73,7 +75,7 @@ class Query { onCancel: () { _handle.then((int handle) async { await Firestore.channel.invokeMethod( - 'Query#removeListener', + 'removeListener', {'handle': handle}, ); Firestore._queryObservers.remove(handle); diff --git a/packages/cloud_firestore/lib/src/query_snapshot.dart b/packages/cloud_firestore/lib/src/query_snapshot.dart index 4cc85403e19f..1ecb0bd54fe1 100644 --- a/packages/cloud_firestore/lib/src/query_snapshot.dart +++ b/packages/cloud_firestore/lib/src/query_snapshot.dart @@ -25,7 +25,11 @@ class QuerySnapshot { data['documentChanges'][index], _firestore, ); - }); + }), + metadata = SnapshotMetadata._( + data['metadata']['hasPendingWrites'], + data['metadata']['isFromCache'], + ); /// Gets a list of all the documents included in this snapshot final List documents; @@ -34,5 +38,7 @@ class QuerySnapshot { /// is the first snapshot, all documents will be in the list as Added changes. final List documentChanges; + final SnapshotMetadata metadata; + final Firestore _firestore; } diff --git a/packages/cloud_firestore/lib/src/utils/auto_id_generator.dart b/packages/cloud_firestore/lib/src/utils/auto_id_generator.dart new file mode 100644 index 000000000000..e2e1cc57138a --- /dev/null +++ b/packages/cloud_firestore/lib/src/utils/auto_id_generator.dart @@ -0,0 +1,34 @@ +// Copyright 2017, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math'; + +/// Utility class for generating Firebase child node keys. +/// +/// Since the Flutter plugin API is asynchronous, there's no way for us +/// to use the native SDK to generate the node key synchronously and we +/// have to do it ourselves if we want to be able to reference the +/// newly-created node synchronously. +/// +/// This code is based largely on the Android implementation and ported to Dart. + +class AutoIdGenerator { + static const int _AUTO_ID_LENGTH = 20; + + static const String _AUTO_ID_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + static final Random _random = Random(); + + static String autoId() { + final StringBuffer stringBuffer = StringBuffer(); + final int maxRandom = _AUTO_ID_ALPHABET.length; + + for (int i = 0; i < _AUTO_ID_LENGTH; ++i) { + stringBuffer.write(_AUTO_ID_ALPHABET[_random.nextInt(maxRandom)]); + } + + return stringBuffer.toString(); + } +} diff --git a/packages/cloud_firestore/lib/src/utils/push_id_generator.dart b/packages/cloud_firestore/lib/src/utils/push_id_generator.dart deleted file mode 100644 index f822f6ce66a5..000000000000 --- a/packages/cloud_firestore/lib/src/utils/push_id_generator.dart +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2017, the Chromium project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -import 'dart:math'; - -/// Utility class for generating Firebase child node keys. -/// -/// Since the Flutter plugin API is asynchronous, there's no way for us -/// to use the native SDK to generate the node key synchronously and we -/// have to do it ourselves if we want to be able to reference the -/// newly-created node synchronously. -/// -/// This code is based on a Firebase blog post and ported to Dart. -/// https://firebase.googleblog.com/2015/02/the-2120-ways-to-ensure-unique_68.html -class PushIdGenerator { - static const String PUSH_CHARS = - '-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz'; - - static final Random _random = Random(); - - static int _lastPushTime; - - static final List _lastRandChars = List(12); - - static String generatePushChildName() { - int now = DateTime.now().millisecondsSinceEpoch; - final bool duplicateTime = (now == _lastPushTime); - _lastPushTime = now; - - final List timeStampChars = List(8); - for (int i = 7; i >= 0; i--) { - timeStampChars[i] = PUSH_CHARS[now % 64]; - now = (now / 64).floor(); - } - assert(now == 0); - - final StringBuffer result = StringBuffer(timeStampChars.join()); - - if (!duplicateTime) { - for (int i = 0; i < 12; i++) { - _lastRandChars[i] = _random.nextInt(64); - } - } else { - _incrementArray(); - } - for (int i = 0; i < 12; i++) { - result.write(PUSH_CHARS[_lastRandChars[i]]); - } - assert(result.length == 20); - return result.toString(); - } - - static void _incrementArray() { - for (int i = 11; i >= 0; i--) { - if (_lastRandChars[i] != 63) { - _lastRandChars[i] = _lastRandChars[i] + 1; - return; - } - _lastRandChars[i] = 0; - } - } -} diff --git a/packages/cloud_firestore/pubspec.yaml b/packages/cloud_firestore/pubspec.yaml index 08348e413ea7..475130fbb5f5 100755 --- a/packages/cloud_firestore/pubspec.yaml +++ b/packages/cloud_firestore/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Cloud Firestore, a cloud-hosted, noSQL database live synchronization and offline support on Android and iOS. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/cloud_firestore -version: 0.12.7+1 +version: 0.12.9 flutter: plugin: diff --git a/packages/cloud_firestore/test/cloud_firestore_test.dart b/packages/cloud_firestore/test/cloud_firestore_test.dart index 2b679fea6d0b..c68f04405009 100755 --- a/packages/cloud_firestore/test/cloud_firestore_test.dart +++ b/packages/cloud_firestore/test/cloud_firestore_test.dart @@ -63,6 +63,7 @@ void main() { 'paths': ["${methodCall.arguments['path']}/0"], 'documents': [kMockDocumentSnapshotData], 'metadatas': >[kMockSnapshotMetadata], + 'metadata': kMockSnapshotMetadata, 'documentChanges': [ { 'oldIndex': -1, @@ -78,7 +79,7 @@ void main() { ); }); return handle; - case 'Query#addDocumentListener': + case 'DocumentReference#addSnapshotListener': final int handle = mockHandleId++; // Wait before sending a message back. // Otherwise the first request didn't have the time to finish. @@ -105,6 +106,7 @@ void main() { 'paths': ["${methodCall.arguments['path']}/0"], 'documents': [kMockDocumentSnapshotData], 'metadatas': >[kMockSnapshotMetadata], + 'metadata': kMockSnapshotMetadata, 'documentChanges': [ { 'oldIndex': -1, @@ -328,8 +330,9 @@ void main() { expect(collectionReference.path, equals('foo')); }); test('listen', () async { - final QuerySnapshot snapshot = - await collectionReference.snapshots().first; + final QuerySnapshot snapshot = await collectionReference + .snapshots(includeMetadataChanges: true) + .first; final DocumentSnapshot document = snapshot.documents[0]; expect(document.documentID, equals('0')); expect(document.reference.path, equals('foo/0')); @@ -346,11 +349,12 @@ void main() { 'parameters': { 'where': >[], 'orderBy': >[], - } + }, + 'includeMetadataChanges': true, }, ), isMethodCall( - 'Query#removeListener', + 'removeListener', arguments: {'handle': 0}, ), ]); @@ -377,11 +381,12 @@ void main() { ['createdAt', '<', 100], ], 'orderBy': >[], - } + }, + 'includeMetadataChanges': false, }, ), isMethodCall( - 'Query#removeListener', + 'removeListener', arguments: {'handle': 0}, ), ]), @@ -409,11 +414,12 @@ void main() { ['profile', '==', null], ], 'orderBy': >[], - } + }, + 'includeMetadataChanges': false, }, ), isMethodCall( - 'Query#removeListener', + 'removeListener', arguments: {'handle': 0}, ), ]), @@ -441,11 +447,12 @@ void main() { 'orderBy': >[ ['createdAt', false] ], - } + }, + 'includeMetadataChanges': false, }, ), isMethodCall( - 'Query#removeListener', + 'removeListener', arguments: {'handle': 0}, ), ]), @@ -455,8 +462,10 @@ void main() { group('DocumentReference', () { test('listen', () async { - final DocumentSnapshot snapshot = - await firestore.document('path/to/foo').snapshots().first; + final DocumentSnapshot snapshot = await firestore + .document('path/to/foo') + .snapshots(includeMetadataChanges: true) + .first; expect(snapshot.documentID, equals('foo')); expect(snapshot.reference.path, equals('path/to/foo')); expect(snapshot.data, equals(kMockDocumentSnapshotData)); @@ -466,14 +475,15 @@ void main() { log, [ isMethodCall( - 'Query#addDocumentListener', + 'DocumentReference#addSnapshotListener', arguments: { 'app': app.name, 'path': 'path/to/foo', + 'includeMetadataChanges': true, }, ), isMethodCall( - 'Query#removeListener', + 'removeListener', arguments: {'handle': 0}, ), ], @@ -614,6 +624,10 @@ void main() { test('getDocumentsFromCollection', () async { QuerySnapshot snapshot = await collectionReference.getDocuments(source: Source.server); + expect(snapshot.metadata.hasPendingWrites, + equals(kMockSnapshotMetadata['hasPendingWrites'])); + expect(snapshot.metadata.isFromCache, + equals(kMockSnapshotMetadata['isFromCache'])); DocumentSnapshot document = snapshot.documents.first; expect(document.documentID, equals('0')); expect(document.reference.path, equals('foo/0')); @@ -781,6 +795,10 @@ void main() { }); test('getDocumentsFromCollectionGroup', () async { QuerySnapshot snapshot = await collectionGroupQuery.getDocuments(); + expect(snapshot.metadata.hasPendingWrites, + equals(kMockSnapshotMetadata['hasPendingWrites'])); + expect(snapshot.metadata.isFromCache, + equals(kMockSnapshotMetadata['isFromCache'])); DocumentSnapshot document = snapshot.documents.first; expect(document.documentID, equals('0')); expect(document.reference.path, equals('bar/0')); diff --git a/packages/cloud_functions/CHANGELOG.md b/packages/cloud_functions/CHANGELOG.md index e4661c25bd6a..57062fbbfa2c 100644 --- a/packages/cloud_functions/CHANGELOG.md +++ b/packages/cloud_functions/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.4.1 + +* Support for cloud functions emulators. + ## 0.4.0+3 * Update google-services Android gradle plugin to 4.3.0 in documentation and examples. @@ -93,7 +97,7 @@ [Callable functions](https://firebase.google.com/docs/functions/callable) are similar to other HTTP functions, with these additional features: - - With callables, Firebase Authentication and FCM tokens are - automatically included in requests. - - The functions.https.onCall trigger automatically deserializes - the request body and validates auth tokens. + - With callables, Firebase Authentication and FCM tokens are + automatically included in requests. + - The functions.https.onCall trigger automatically deserializes + the request body and validates auth tokens. diff --git a/packages/cloud_functions/android/src/main/java/io/flutter/plugins/firebase/cloudfunctions/CloudFunctionsPlugin.java b/packages/cloud_functions/android/src/main/java/io/flutter/plugins/firebase/cloudfunctions/CloudFunctionsPlugin.java index 81dfc98e6a95..b99ddefec324 100644 --- a/packages/cloud_functions/android/src/main/java/io/flutter/plugins/firebase/cloudfunctions/CloudFunctionsPlugin.java +++ b/packages/cloud_functions/android/src/main/java/io/flutter/plugins/firebase/cloudfunctions/CloudFunctionsPlugin.java @@ -38,12 +38,16 @@ public void onMethodCall(MethodCall call, final Result result) { String appName = call.argument("app"); FirebaseApp app = FirebaseApp.getInstance(appName); String region = call.argument("region"); + String origin = call.argument("origin"); FirebaseFunctions functions; if (region != null) { functions = FirebaseFunctions.getInstance(app, region); } else { functions = FirebaseFunctions.getInstance(app); } + if (origin != null) { + functions.useFunctionsEmulator(origin); + } HttpsCallableReference httpsCallableReference = functions.getHttpsCallable(functionName); Number timeoutMilliseconds = call.argument("timeoutMilliseconds"); if (timeoutMilliseconds != null) { diff --git a/packages/cloud_functions/ios/Classes/CloudFunctionsPlugin.m b/packages/cloud_functions/ios/Classes/CloudFunctionsPlugin.m index cdeaf53debfb..e48c0fbc73cb 100644 --- a/packages/cloud_functions/ios/Classes/CloudFunctionsPlugin.m +++ b/packages/cloud_functions/ios/Classes/CloudFunctionsPlugin.m @@ -44,6 +44,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result NSObject *parameters = call.arguments[@"parameters"]; NSString *appName = call.arguments[@"app"]; NSString *region = call.arguments[@"region"]; + NSString *origin = call.arguments[@"origin"]; NSNumber *timeoutMicroseconds = call.arguments[@"timeoutMicroseconds"]; FIRApp *app = [FIRApp appNamed:appName]; FIRFunctions *functions; @@ -52,6 +53,9 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } else { functions = [FIRFunctions functionsForApp:app]; } + if (origin != nil && origin != (id)[NSNull null]) { + [functions useFunctionsEmulatorOrigin:origin]; + } FIRHTTPSCallable *function = [functions HTTPSCallableWithName:functionName]; if (timeoutMicroseconds != nil && timeoutMicroseconds != [NSNull null]) { [function setTimeoutInterval:(NSTimeInterval)timeoutMicroseconds.doubleValue / 1000000]; diff --git a/packages/cloud_functions/lib/src/cloud_functions.dart b/packages/cloud_functions/lib/src/cloud_functions.dart index 03c1705efa53..d15db7b49a1f 100644 --- a/packages/cloud_functions/lib/src/cloud_functions.dart +++ b/packages/cloud_functions/lib/src/cloud_functions.dart @@ -31,6 +31,8 @@ class CloudFunctions { final String _region; + String _origin; + /// Gets an instance of a Callable HTTPS trigger in Cloud Functions. /// /// Can then be executed by calling `call()` on it. @@ -39,4 +41,12 @@ class CloudFunctions { HttpsCallable getHttpsCallable({@required String functionName}) { return HttpsCallable._(this, functionName); } + + /// Changes this instance to point to a Cloud Functions emulator running locally. + /// + /// @param origin The origin of the local emulator, such as "//10.0.2.2:5005". + CloudFunctions useFunctionsEmulator({@required String origin}) { + _origin = origin; + return this; + } } diff --git a/packages/cloud_functions/lib/src/https_callable.dart b/packages/cloud_functions/lib/src/https_callable.dart index 09a8f578596f..6ae34dbf73f5 100644 --- a/packages/cloud_functions/lib/src/https_callable.dart +++ b/packages/cloud_functions/lib/src/https_callable.dart @@ -34,6 +34,7 @@ class HttpsCallable { .invokeMethod('CloudFunctions#call', { 'app': _cloudFunctions._app.name, 'region': _cloudFunctions._region, + 'origin': _cloudFunctions._origin, 'timeoutMicroseconds': timeout?.inMicroseconds, 'functionName': _functionName, 'parameters': parameters, diff --git a/packages/cloud_functions/pubspec.yaml b/packages/cloud_functions/pubspec.yaml index 20034a7acce9..d1e9bf14f4cd 100644 --- a/packages/cloud_functions/pubspec.yaml +++ b/packages/cloud_functions/pubspec.yaml @@ -1,6 +1,6 @@ name: cloud_functions description: Flutter plugin for Cloud Functions. -version: 0.4.0+3 +version: 0.4.1 author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/cloud_functions @@ -23,5 +23,5 @@ dev_dependencies: test: any environment: - sdk: ">=2.0.0-dev.28.0 <3.0.0" - flutter: ">=0.2.4 <2.0.0" + sdk: '>=2.0.0-dev.28.0 <3.0.0' + flutter: '>=0.2.4 <2.0.0' diff --git a/packages/cloud_functions/test/cloud_functions_test.dart b/packages/cloud_functions/test/cloud_functions_test.dart index 10cb74ae02a6..6e58c422423f 100644 --- a/packages/cloud_functions/test/cloud_functions_test.dart +++ b/packages/cloud_functions/test/cloud_functions_test.dart @@ -38,6 +38,10 @@ void main() { await callable.call({ 'quux': 'quuz', }); + await CloudFunctions.instance + .useFunctionsEmulator(origin: 'http://localhost:5001') + .getHttpsCallable(functionName: 'bez') + .call(); expect( log, [ @@ -46,6 +50,7 @@ void main() { arguments: { 'app': '[DEFAULT]', 'region': null, + 'origin': null, 'functionName': 'baz', 'timeoutMicroseconds': null, 'parameters': null, @@ -56,11 +61,23 @@ void main() { arguments: { 'app': '1337', 'region': 'space', + 'origin': null, 'functionName': 'qux', 'timeoutMicroseconds': (const Duration(days: 300)).inMicroseconds, 'parameters': {'quux': 'quuz'}, }, ), + isMethodCall( + 'CloudFunctions#call', + arguments: { + 'app': '[DEFAULT]', + 'region': null, + 'origin': 'http://localhost:5001', + 'functionName': 'bez', + 'timeoutMicroseconds': null, + 'parameters': null, + }, + ), ], ); }); diff --git a/packages/firebase_analytics/CHANGELOG.md b/packages/firebase_analytics/CHANGELOG.md index 320289c2d858..16051241ebb9 100644 --- a/packages/firebase_analytics/CHANGELOG.md +++ b/packages/firebase_analytics/CHANGELOG.md @@ -1,3 +1,11 @@ +## 5.0.0 + +* **Breaking change**. Remove deprecated method `setMinimumSessionDuration`. +* **Breaking change**. Removed `FirebaseAnalyticsAndroid.setAnalyticsCollectionEnabled`. Use + `FirebaseAnalytics.setAnalyticsCollectionEnabled` instead. +* Update Android gradle plugin and gradle version. +* Remove redundant casts on Android. + ## 4.0.2 * Update google-services Android gradle plugin to 4.3.0 in documentation and examples. diff --git a/packages/firebase_analytics/android/build.gradle b/packages/firebase_analytics/android/build.gradle index ad0cb30fdb16..96633d9194bc 100755 --- a/packages/firebase_analytics/android/build.gradle +++ b/packages/firebase_analytics/android/build.gradle @@ -21,7 +21,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:3.4.2' } } diff --git a/packages/firebase_analytics/android/src/main/java/io/flutter/plugins/firebaseanalytics/FirebaseAnalyticsPlugin.java b/packages/firebase_analytics/android/src/main/java/io/flutter/plugins/firebaseanalytics/FirebaseAnalyticsPlugin.java index 7215afb1e73b..0ec15c7cc22b 100755 --- a/packages/firebase_analytics/android/src/main/java/io/flutter/plugins/firebaseanalytics/FirebaseAnalyticsPlugin.java +++ b/packages/firebase_analytics/android/src/main/java/io/flutter/plugins/firebaseanalytics/FirebaseAnalyticsPlugin.java @@ -47,9 +47,6 @@ public void onMethodCall(MethodCall call, Result result) { case "setAnalyticsCollectionEnabled": handleSetAnalyticsCollectionEnabled(call, result); break; - case "setMinimumSessionDuration": - handleSetMinimumSessionDuration(call, result); - break; case "setSessionTimeoutDuration": handleSetSessionTimeoutDuration(call, result); break; @@ -57,7 +54,7 @@ public void onMethodCall(MethodCall call, Result result) { handleSetUserProperty(call, result); break; case "resetAnalyticsData": - handleResetAnalyticsData(call, result); + handleResetAnalyticsData(result); break; default: result.notImplemented(); @@ -66,13 +63,10 @@ public void onMethodCall(MethodCall call, Result result) { } private void handleLogEvent(MethodCall call, Result result) { - @SuppressWarnings("unchecked") - Map arguments = (Map) call.arguments; - final String eventName = (String) arguments.get("name"); - @SuppressWarnings("unchecked") - final Bundle parameterBundle = - createBundleFromMap((Map) arguments.get("parameters")); + final String eventName = call.argument("name"); + final Map map = call.argument("parameters"); + final Bundle parameterBundle = createBundleFromMap(map); firebaseAnalytics.logEvent(eventName, parameterBundle); result.success(null); } @@ -84,49 +78,40 @@ private void handleSetUserId(MethodCall call, Result result) { } private void handleSetCurrentScreen(MethodCall call, Result result) { - @SuppressWarnings("unchecked") Activity activity = registrar.activity(); if (activity == null) { result.error("no_activity", "handleSetCurrentScreen requires a foreground activity", null); return; } - Map arguments = (Map) call.arguments; - final String screenName = (String) arguments.get("screenName"); - final String screenClassOverride = (String) arguments.get("screenClassOverride"); + + final String screenName = call.argument("screenName"); + final String screenClassOverride = call.argument("screenClassOverride"); firebaseAnalytics.setCurrentScreen(activity, screenName, screenClassOverride); result.success(null); } private void handleSetAnalyticsCollectionEnabled(MethodCall call, Result result) { - final Boolean enabled = (Boolean) call.arguments; + final Boolean enabled = call.arguments(); firebaseAnalytics.setAnalyticsCollectionEnabled(enabled); result.success(null); } - private void handleSetMinimumSessionDuration(MethodCall call, Result result) { - final Integer milliseconds = (Integer) call.arguments; - firebaseAnalytics.setMinimumSessionDuration(milliseconds); - result.success(null); - } - private void handleSetSessionTimeoutDuration(MethodCall call, Result result) { - final Integer milliseconds = (Integer) call.arguments; + final Integer milliseconds = call.arguments(); firebaseAnalytics.setSessionTimeoutDuration(milliseconds); result.success(null); } private void handleSetUserProperty(MethodCall call, Result result) { - @SuppressWarnings("unchecked") - Map arguments = (Map) call.arguments; - final String name = (String) arguments.get("name"); - final String value = (String) arguments.get("value"); + final String name = call.argument("name"); + final String value = call.argument("value"); firebaseAnalytics.setUserProperty(name, value); result.success(null); } - private void handleResetAnalyticsData(MethodCall call, Result result) { + private void handleResetAnalyticsData(Result result) { firebaseAnalytics.resetAnalyticsData(); result.success(null); } diff --git a/packages/firebase_analytics/example/android/build.gradle b/packages/firebase_analytics/example/android/build.gradle index 695de848ec30..3f271ea72055 100755 --- a/packages/firebase_analytics/example/android/build.gradle +++ b/packages/firebase_analytics/example/android/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:3.4.2' classpath 'com.google.gms:google-services:4.3.0' } } diff --git a/packages/firebase_analytics/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/firebase_analytics/example/android/gradle/wrapper/gradle-wrapper.properties index 019065d1d650..0c59c158ca01 100644 --- a/packages/firebase_analytics/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/firebase_analytics/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Wed Jul 31 23:52:55 BRT 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip diff --git a/packages/firebase_analytics/example/lib/main.dart b/packages/firebase_analytics/example/lib/main.dart index 2c1d1b789984..2c3affd29c1c 100755 --- a/packages/firebase_analytics/example/lib/main.dart +++ b/packages/firebase_analytics/example/lib/main.dart @@ -94,11 +94,6 @@ class _MyHomePageState extends State { setMessage('setAnalyticsCollectionEnabled succeeded'); } - Future _testSetMinimumSessionDuration() async { - await analytics.android?.setMinimumSessionDuration(20000); - setMessage('setMinimumSessionDuration succeeded'); - } - Future _testSetSessionTimeoutDuration() async { await analytics.android?.setSessionTimeoutDuration(2000000); setMessage('setSessionTimeoutDuration succeeded'); @@ -298,10 +293,6 @@ class _MyHomePageState extends State { child: const Text('Test setAnalyticsCollectionEnabled'), onPressed: _testSetAnalyticsCollectionEnabled, ), - MaterialButton( - child: const Text('Test setMinimumSessionDuration'), - onPressed: _testSetMinimumSessionDuration, - ), MaterialButton( child: const Text('Test setSessionTimeoutDuration'), onPressed: _testSetSessionTimeoutDuration, diff --git a/packages/firebase_analytics/example/test_driver/firebase_analytics.dart b/packages/firebase_analytics/example/test_driver/firebase_analytics.dart index 40f994203284..ee09c9415527 100644 --- a/packages/firebase_analytics/example/test_driver/firebase_analytics.dart +++ b/packages/firebase_analytics/example/test_driver/firebase_analytics.dart @@ -25,7 +25,7 @@ void main() { expect(analytics.android, isNull); } if (Platform.isAndroid) { - await analytics.android.setMinimumSessionDuration(9000); + await analytics.android.setSessionTimeoutDuration(1000); } }); diff --git a/packages/firebase_analytics/lib/firebase_analytics.dart b/packages/firebase_analytics/lib/firebase_analytics.dart index 2dd17e09786f..c04ac1c3e468 100755 --- a/packages/firebase_analytics/lib/firebase_analytics.dart +++ b/packages/firebase_analytics/lib/firebase_analytics.dart @@ -24,7 +24,7 @@ class FirebaseAnalytics { /// Example: /// /// FirebaseAnalytics analytics = FirebaseAnalytics(); - /// analytics.android?.setMinimumSessionDuration(200000); + /// analytics.android?.setSessionTimeoutDuration(true); final FirebaseAnalyticsAndroid android = defaultTargetPlatform == TargetPlatform.android ? FirebaseAnalyticsAndroid() @@ -871,29 +871,6 @@ class FirebaseAnalytics { class FirebaseAnalyticsAndroid { final MethodChannel _channel = firebaseChannel; - /// Sets whether analytics collection is enabled for this app on this device. - /// - /// This setting is persisted across app sessions. By default it is enabled. - /// Deprecated: Use [FirebaseAnalytics.setAnalyticsCollectionEnabled] instead. - @deprecated - Future setAnalyticsCollectionEnabled(bool enabled) async { - if (enabled == null) { - throw ArgumentError.notNull('enabled'); - } - await _channel.invokeMethod('setAnalyticsCollectionEnabled', enabled); - } - - /// Sets the minimum engagement time required before starting a session. - /// - /// The default value is 10000 (10 seconds). - Future setMinimumSessionDuration(int milliseconds) async { - if (milliseconds == null) { - throw ArgumentError.notNull('milliseconds'); - } - await _channel.invokeMethod( - 'setMinimumSessionDuration', milliseconds); - } - /// Sets the duration of inactivity that terminates the current session. /// /// The default value is 1800000 (30 minutes). diff --git a/packages/firebase_analytics/pubspec.yaml b/packages/firebase_analytics/pubspec.yaml index 324fac8ac189..071ed2321382 100755 --- a/packages/firebase_analytics/pubspec.yaml +++ b/packages/firebase_analytics/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Analytics for Firebase, an app measuremen solution that provides insight on app usage and user engagement on Android and iOS. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/firebase_analytics -version: 4.0.2 +version: 5.0.0 flutter: plugin: diff --git a/packages/firebase_analytics/test/firebase_analytics_test.dart b/packages/firebase_analytics/test/firebase_analytics_test.dart index 31fa713e1596..c8af79710f0d 100755 --- a/packages/firebase_analytics/test/firebase_analytics_test.dart +++ b/packages/firebase_analytics/test/firebase_analytics_test.dart @@ -113,17 +113,6 @@ void main() { ); }); - test('setMinimumSessionDuration', () async { - await analytics.android.setMinimumSessionDuration(123); - expect( - methodCall, - isMethodCall( - 'setMinimumSessionDuration', - arguments: 123, - ), - ); - }); - test('setSessionTimeoutDuration', () async { await analytics.android.setSessionTimeoutDuration(234); expect( diff --git a/packages/firebase_auth/CHANGELOG.md b/packages/firebase_auth/CHANGELOG.md index 1728a08e358f..1565e4fada58 100644 --- a/packages/firebase_auth/CHANGELOG.md +++ b/packages/firebase_auth/CHANGELOG.md @@ -1,3 +1,44 @@ +## 0.14.0+1 + +* Add dependency on `androidx.annotation:annotation:1.0.0`. + +## 0.14.0 + +* Added new `IdTokenResult` class. +* **Breaking Change**. `getIdToken()` method now returns `IdTokenResult` instead of a token `String`. + Use the `token` property of `IdTokenResult` to retrieve the token `String`. +* Added integration testing for `getIdToken()`. + +## 0.13.1+1 + +* Update authentication example in README. + +## 0.13.1 + +* Fixed a crash on iOS when sign-in fails. +* Additional integration testing. +* Updated documentation for `FirebaseUser.delete()` to include error codes. +* Updated Firebase project to match other Flutterfire apps. + +## 0.13.0 + +* **Breaking change**: Replace `FirebaseUserMetadata.creationTimestamp` and + `FirebaseUserMetadata.lastSignInTimestamp` with `creationTime` and `lastSignInTime`. + Previously on iOS `creationTimestamp` and `lastSignInTimestamp` returned in + seconds and on Android in milliseconds. Now, both platforms provide values as a + `DateTime`. + +## 0.12.0+1 + +* Fixes iOS sign-in exceptions when `additionalUserInfo` is `nil` or has `nil` fields. +* Additional integration testing. + +## 0.12.0 + +* Added new `AuthResult` and `AdditionalUserInfo` classes. +* **Breaking Change**. Sign-in methods now return `AuthResult` instead of `FirebaseUser`. + Retrieve the `FirebaseUser` using the `user` property of `AuthResult`. + ## 0.11.1+12 * Update google-services Android gradle plugin to 4.3.0 in documentation and examples. diff --git a/packages/firebase_auth/README.md b/packages/firebase_auth/README.md index c7d2e62914af..5c1e80f8181f 100755 --- a/packages/firebase_auth/README.md +++ b/packages/firebase_auth/README.md @@ -71,7 +71,7 @@ Future _handleSignIn() async { idToken: googleAuth.idToken, ); - final FirebaseUser user = await _auth.signInWithCredential(credential); + final FirebaseUser user = (await _auth.signInWithCredential(credential)).user; print("signed in " + user.displayName); return user; } diff --git a/packages/firebase_auth/android/build.gradle b/packages/firebase_auth/android/build.gradle index b6c763e108a7..3e1822be799e 100755 --- a/packages/firebase_auth/android/build.gradle +++ b/packages/firebase_auth/android/build.gradle @@ -46,8 +46,9 @@ android { disable 'InvalidPackage' } dependencies { - api 'com.google.firebase:firebase-auth:17.0.0' + implementation 'androidx.annotation:annotation:1.0.0' implementation 'com.google.firebase:firebase-common:16.1.0' + api 'com.google.firebase:firebase-auth:17.0.0' api 'com.google.code.gson:gson:2.8.5' } } diff --git a/packages/firebase_auth/android/src/main/java/io/flutter/plugins/firebaseauth/FirebaseAuthPlugin.java b/packages/firebase_auth/android/src/main/java/io/flutter/plugins/firebaseauth/FirebaseAuthPlugin.java index 8b0ac80f3a53..c135259f22d1 100755 --- a/packages/firebase_auth/android/src/main/java/io/flutter/plugins/firebaseauth/FirebaseAuthPlugin.java +++ b/packages/firebase_auth/android/src/main/java/io/flutter/plugins/firebaseauth/FirebaseAuthPlugin.java @@ -16,6 +16,7 @@ import com.google.firebase.FirebaseNetworkException; import com.google.firebase.FirebaseTooManyRequestsException; import com.google.firebase.auth.ActionCodeSettings; +import com.google.firebase.auth.AdditionalUserInfo; import com.google.firebase.auth.AuthCredential; import com.google.firebase.auth.AuthResult; import com.google.firebase.auth.EmailAuthProvider; @@ -531,7 +532,7 @@ private void handleGetToken(MethodCall call, final Result result, FirebaseAuth f } Map arguments = call.arguments(); - boolean refresh = arguments.get("refresh"); + final boolean refresh = arguments.get("refresh"); currentUser .getIdToken(refresh) @@ -539,8 +540,18 @@ private void handleGetToken(MethodCall call, final Result result, FirebaseAuth f new OnCompleteListener() { public void onComplete(@NonNull Task task) { if (task.isSuccessful() && task.getResult() != null) { - String idToken = task.getResult().getToken(); - result.success(idToken); + final Map map = new HashMap<>(); + map.put("token", task.getResult().getToken()); + map.put("expirationTimestamp", task.getResult().getExpirationTimestamp()); + map.put("authTimestamp", task.getResult().getAuthTimestamp()); + map.put("issuedAtTimestamp", task.getResult().getIssuedAtTimestamp()); + map.put("claims", task.getResult().getClaims()); + + if (task.getResult().getSignInProvider() != null) { + map.put("signInProvider", task.getResult().getSignInProvider()); + } + + result.success(Collections.unmodifiableMap(map)); } else { reportException(result, task.getException()); } @@ -669,9 +680,15 @@ public void onComplete(@NonNull Task task) { if (!task.isSuccessful() || task.getResult() == null) { reportException(result, task.getException()); } else { - FirebaseUser user = task.getResult().getUser(); - Map userMap = Collections.unmodifiableMap(mapFromUser(user)); - result.success(userMap); + AuthResult authResult = task.getResult(); + FirebaseUser user = authResult.getUser(); + AdditionalUserInfo additionalUserInfo = authResult.getAdditionalUserInfo(); + Map userMap = (mapFromUser(user)); + Map additionalUserInfoMap = mapFromAdditionalUserInfo(additionalUserInfo); + Map map = new HashMap<>(); + map.put("user", userMap); + map.put("additionalUserInfo", additionalUserInfoMap); + result.success(Collections.unmodifiableMap(map)); } } } @@ -752,6 +769,19 @@ private Map mapFromUser(FirebaseUser user) { } } + private Map mapFromAdditionalUserInfo(AdditionalUserInfo info) { + if (info != null) { + Map additionalUserInfoMap = new HashMap<>(); + additionalUserInfoMap.put("profile", info.getProfile()); + additionalUserInfoMap.put("providerId", info.getProviderId()); + additionalUserInfoMap.put("username", info.getUsername()); + additionalUserInfoMap.put("isNewUser", info.isNewUser()); + return Collections.unmodifiableMap(additionalUserInfoMap); + } else { + return null; + } + } + private void markUserRequired(Result result) { result.error("USER_REQUIRED", "Please authenticate with Firebase first", null); } diff --git a/packages/firebase_auth/example/android/app/build.gradle b/packages/firebase_auth/example/android/app/build.gradle index d31dce7a99f4..295870863a51 100755 --- a/packages/firebase_auth/example/android/app/build.gradle +++ b/packages/firebase_auth/example/android/app/build.gradle @@ -32,7 +32,7 @@ android { } defaultConfig { - applicationId "io.flutter.plugins.firebaseauthexample" + applicationId "dev.flutter.plugins.firebaseauthexample" minSdkVersion 16 targetSdkVersion 28 versionCode flutterVersionCode.toInteger() diff --git a/packages/firebase_auth/example/android/app/google-services.json b/packages/firebase_auth/example/android/app/google-services.json old mode 100755 new mode 100644 index eccaa4d9da46..238110fbb63e --- a/packages/firebase_auth/example/android/app/google-services.json +++ b/packages/firebase_auth/example/android/app/google-services.json @@ -1,52 +1,300 @@ { "project_info": { - "project_number": "466218294969", - "firebase_url": "https://flutter-plugins-auth.firebaseio.com", - "project_id": "flutter-plugins-auth", - "storage_bucket": "flutter-plugins-auth.appspot.com" + "project_number": "159623150305", + "firebase_url": "https://flutter-firebase-plugins.firebaseio.com", + "project_id": "flutter-firebase-plugins", + "storage_bucket": "flutter-firebase-plugins.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:466218294969:android:11e8f037a4a3ec3b", + "mobilesdk_app_id": "1:159623150305:android:2e167f427b396288", + "android_client_info": { + "package_name": "dev.flutter.plugins.firebaseauthexample" + } + }, + "oauth_client": [ + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyChk3KEG7QYrs4kQPLP1tjJNxBTbfCAdgg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "159623150305-h67j0o93bcctblfhde74r7fssmcobamr.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "io.flutter.plugins.firebaseAuthExample" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:159623150305:android:236f9daea101f77e", + "android_client_info": { + "package_name": "io.flutter.plugins.firebase.firestoreexample" + } + }, + "oauth_client": [ + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyChk3KEG7QYrs4kQPLP1tjJNxBTbfCAdgg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "159623150305-h67j0o93bcctblfhde74r7fssmcobamr.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "io.flutter.plugins.firebaseAuthExample" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:159623150305:android:11e8f037a4a3ec3b", "android_client_info": { "package_name": "io.flutter.plugins.firebaseauthexample" } }, "oauth_client": [ { - "client_id": "466218294969-ikp8nslafq77v10dkp83f1atvfkacide.apps.googleusercontent.com", + "client_id": "159623150305-6cf91dro1kro30hdlfp3rkod8mldc81c.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "io.flutter.plugins.firebaseauthexample", - "certificate_hash": "f8323ac5fe6e7adc1ddc5612e16b5d04d7f1358b" + "certificate_hash": "4ef1514a34a8edf0f5b3ff7108d3497fa3449f69" } }, { - "client_id": "466218294969-t7bmju7plgllvluqpgb1gklfuv56pfr8.apps.googleusercontent.com", + "client_id": "159623150305-pjkkv9ff9tgkmjs7kjalsqv2pp6ltj04.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "io.flutter.plugins.firebaseauthexample", + "certificate_hash": "9ee6a0388f780cf93a852248905d2d3d83d8e50d" + } + }, + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyA4il7ZGUO9gi7ErxFspGcaxi0qr9Huifc" + "current_key": "AIzaSyChk3KEG7QYrs4kQPLP1tjJNxBTbfCAdgg" } ], "services": { - "analytics_service": { - "status": 1 + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "159623150305-h67j0o93bcctblfhde74r7fssmcobamr.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "io.flutter.plugins.firebaseAuthExample" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:159623150305:android:c68d3ad04a4046db", + "android_client_info": { + "package_name": "io.flutter.plugins.firebasedatabaseexample" + } + }, + "oauth_client": [ + { + "client_id": "159623150305-j5cqghi5snpqptesd2mdjum7o35hiltb.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "io.flutter.plugins.firebasedatabaseexample", + "certificate_hash": "9ee6a0388f780cf93a852248905d2d3d83d8e50d" + } }, + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyChk3KEG7QYrs4kQPLP1tjJNxBTbfCAdgg" + } + ], + "services": { "appinvite_service": { - "status": 2, "other_platform_oauth_client": [ { - "client_id": "466218294969-t7bmju7plgllvluqpgb1gklfuv56pfr8.apps.googleusercontent.com", + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", "client_type": 3 + }, + { + "client_id": "159623150305-h67j0o93bcctblfhde74r7fssmcobamr.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "io.flutter.plugins.firebaseAuthExample" + } } ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:159623150305:android:620f0e4ca16cbddd", + "android_client_info": { + "package_name": "io.flutter.plugins.firebasemessagingexample" + } + }, + "oauth_client": [ + { + "client_id": "159623150305-qubsrfqbnrp4vurajv72eujgk164nin0.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "io.flutter.plugins.firebasemessagingexample", + "certificate_hash": "f8323ac5fe6e7adc1ddc5612e16b5d04d7f1358b" + } }, - "ads_service": { - "status": 2 + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyChk3KEG7QYrs4kQPLP1tjJNxBTbfCAdgg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "159623150305-h67j0o93bcctblfhde74r7fssmcobamr.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "io.flutter.plugins.firebaseAuthExample" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:159623150305:android:ef48439a0cc0263d", + "android_client_info": { + "package_name": "io.flutter.plugins.firebasestorageexample" + } + }, + "oauth_client": [ + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyChk3KEG7QYrs4kQPLP1tjJNxBTbfCAdgg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "159623150305-h67j0o93bcctblfhde74r7fssmcobamr.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "io.flutter.plugins.firebaseAuthExample" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:159623150305:android:5e9f1f89e134dc86", + "android_client_info": { + "package_name": "io.flutter.plugins.googlesigninexample" + } + }, + "oauth_client": [ + { + "client_id": "159623150305-3suu3qq4vs3ki7uaigbtrh2aal1p9ram.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "io.flutter.plugins.googlesigninexample", + "certificate_hash": "f8323ac5fe6e7adc1ddc5612e16b5d04d7f1358b" + } + }, + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyChk3KEG7QYrs4kQPLP1tjJNxBTbfCAdgg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "159623150305-q05bbbtsutr02abhips3suj7hujfk4bg.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "159623150305-h67j0o93bcctblfhde74r7fssmcobamr.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "io.flutter.plugins.firebaseAuthExample" + } + } + ] } } } diff --git a/packages/firebase_auth/example/ios/Runner/GoogleService-Info.plist b/packages/firebase_auth/example/ios/Runner/GoogleService-Info.plist index 991d668efeea..2f48146fd821 100644 --- a/packages/firebase_auth/example/ios/Runner/GoogleService-Info.plist +++ b/packages/firebase_auth/example/ios/Runner/GoogleService-Info.plist @@ -2,39 +2,37 @@ - AD_UNIT_ID_FOR_BANNER_TEST - ca-app-pub-3940256099942544/2934735716 - AD_UNIT_ID_FOR_INTERSTITIAL_TEST - ca-app-pub-3940256099942544/4411468910 CLIENT_ID - 466218294969-5mdmh6opkqd87a5m81nm83u4eqd3bvgk.apps.googleusercontent.com + 159623150305-h67j0o93bcctblfhde74r7fssmcobamr.apps.googleusercontent.com REVERSED_CLIENT_ID - com.googleusercontent.apps.466218294969-5mdmh6opkqd87a5m81nm83u4eqd3bvgk + com.googleusercontent.apps.159623150305-h67j0o93bcctblfhde74r7fssmcobamr + ANDROID_CLIENT_ID + 159623150305-j5cqghi5snpqptesd2mdjum7o35hiltb.apps.googleusercontent.com API_KEY - AIzaSyCb0BM8Q9o_gCubuHvsxdxHSgDZaQoZrv0 + AIzaSyDyzecVw1zXTpBKwfFHxpl7QyYBhimNhUk GCM_SENDER_ID - 466218294969 + 159623150305 PLIST_VERSION 1 BUNDLE_ID io.flutter.plugins.firebaseAuthExample PROJECT_ID - flutter-plugins-auth + flutter-firebase-plugins STORAGE_BUCKET - flutter-plugins-auth.appspot.com + flutter-firebase-plugins.appspot.com IS_ADS_ENABLED - + IS_ANALYTICS_ENABLED IS_APPINVITE_ENABLED - + IS_GCM_ENABLED IS_SIGNIN_ENABLED GOOGLE_APP_ID - 1:466218294969:ios:8d89e1b69d780386 + 1:159623150305:ios:8d89e1b69d780386 DATABASE_URL - https://flutter-plugins-auth.firebaseio.com + https://flutter-firebase-plugins.firebaseio.com \ No newline at end of file diff --git a/packages/firebase_auth/example/lib/register_page.dart b/packages/firebase_auth/example/lib/register_page.dart index fd4c74430ff5..acc055aa17b5 100644 --- a/packages/firebase_auth/example/lib/register_page.dart +++ b/packages/firebase_auth/example/lib/register_page.dart @@ -82,10 +82,11 @@ class RegisterPageState extends State { // Example code for registration. void _register() async { - final FirebaseUser user = await _auth.createUserWithEmailAndPassword( + final FirebaseUser user = (await _auth.createUserWithEmailAndPassword( email: _emailController.text, password: _passwordController.text, - ); + )) + .user; if (user != null) { setState(() { _success = true; diff --git a/packages/firebase_auth/example/lib/signin_page.dart b/packages/firebase_auth/example/lib/signin_page.dart index 52faf6f50d02..ac1638f5f2d8 100644 --- a/packages/firebase_auth/example/lib/signin_page.dart +++ b/packages/firebase_auth/example/lib/signin_page.dart @@ -145,10 +145,11 @@ class _EmailPasswordFormState extends State<_EmailPasswordForm> { // Example code of how to sign in with email and password. void _signInWithEmailAndPassword() async { - final FirebaseUser user = await _auth.signInWithEmailAndPassword( + final FirebaseUser user = (await _auth.signInWithEmailAndPassword( email: _emailController.text, password: _passwordController.text, - ); + )) + .user; if (user != null) { setState(() { _success = true; @@ -193,10 +194,11 @@ class _EmailLinkSignInSectionState extends State<_EmailLinkSignInSection> final Uri link = await _retrieveDynamicLink(); if (link != null) { - final FirebaseUser user = await _auth.signInWithEmailAndLink( + final FirebaseUser user = (await _auth.signInWithEmailAndLink( email: _userEmail, link: link.toString(), - ); + )) + .user; if (user != null) { _userID = user.uid; @@ -330,7 +332,7 @@ class _AnonymouslySignInSectionState extends State<_AnonymouslySignInSection> { // Example code of how to sign in anonymously. void _signInAnonymously() async { - final FirebaseUser user = await _auth.signInAnonymously(); + final FirebaseUser user = (await _auth.signInAnonymously()).user; assert(user != null); assert(user.isAnonymous); assert(!user.isEmailVerified); @@ -413,7 +415,8 @@ class _GoogleSignInSectionState extends State<_GoogleSignInSection> { accessToken: googleAuth.accessToken, idToken: googleAuth.idToken, ); - final FirebaseUser user = await _auth.signInWithCredential(credential); + final FirebaseUser user = + (await _auth.signInWithCredential(credential)).user; assert(user.email != null); assert(user.displayName != null); assert(!user.isAnonymous); @@ -554,7 +557,8 @@ class _PhoneSignInSectionState extends State<_PhoneSignInSection> { verificationId: _verificationId, smsCode: _smsController.text, ); - final FirebaseUser user = await _auth.signInWithCredential(credential); + final FirebaseUser user = + (await _auth.signInWithCredential(credential)).user; final FirebaseUser currentUser = await _auth.currentUser(); assert(user.uid == currentUser.uid); setState(() { @@ -698,7 +702,8 @@ class _OtherProvidersSignInSectionState final AuthCredential credential = GithubAuthProvider.getCredential( token: _tokenController.text, ); - final FirebaseUser user = await _auth.signInWithCredential(credential); + final FirebaseUser user = + (await _auth.signInWithCredential(credential)).user; assert(user.email != null); assert(user.displayName != null); assert(!user.isAnonymous); @@ -720,7 +725,8 @@ class _OtherProvidersSignInSectionState final AuthCredential credential = FacebookAuthProvider.getCredential( accessToken: _tokenController.text, ); - final FirebaseUser user = await _auth.signInWithCredential(credential); + final FirebaseUser user = + (await _auth.signInWithCredential(credential)).user; assert(user.email != null); assert(user.displayName != null); assert(!user.isAnonymous); @@ -742,7 +748,8 @@ class _OtherProvidersSignInSectionState final AuthCredential credential = TwitterAuthProvider.getCredential( authToken: _tokenController.text, authTokenSecret: _tokenSecretController.text); - final FirebaseUser user = await _auth.signInWithCredential(credential); + final FirebaseUser user = + (await _auth.signInWithCredential(credential)).user; assert(user.email != null); assert(user.displayName != null); assert(!user.isAnonymous); diff --git a/packages/firebase_auth/example/pubspec.yaml b/packages/firebase_auth/example/pubspec.yaml index 5356bd5c52a2..9478c9b1f34f 100755 --- a/packages/firebase_auth/example/pubspec.yaml +++ b/packages/firebase_auth/example/pubspec.yaml @@ -8,8 +8,9 @@ dependencies: firebase_auth: path: ../ google_sign_in: ^4.0.0 - firebase_core: ^0.4.0 + firebase_core: ^0.4.0+8 firebase_dynamic_links: ^0.3.0 + uuid: ^2.0.2 dev_dependencies: flutter_driver: diff --git a/packages/firebase_auth/example/test/firebase_auth.dart b/packages/firebase_auth/example/test/firebase_auth.dart index 2c4f4c8ede32..ab969daaf2e4 100644 --- a/packages/firebase_auth/example/test/firebase_auth.dart +++ b/packages/firebase_auth/example/test/firebase_auth.dart @@ -3,9 +3,12 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/services.dart'; +import 'package:uuid/uuid.dart'; import 'package:flutter_driver/driver_extension.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:firebase_auth/firebase_auth.dart'; void main() { final Completer completer = Completer(); @@ -15,10 +18,72 @@ void main() { group('$FirebaseAuth', () { final FirebaseAuth auth = FirebaseAuth.instance; + setUp(() async { + await auth.signOut(); + }); + test('signInAnonymously', () async { - final FirebaseUser user = await auth.signInAnonymously(); + final AuthResult result = await auth.signInAnonymously(); + final FirebaseUser user = result.user; + final AdditionalUserInfo additionalUserInfo = result.additionalUserInfo; + expect(additionalUserInfo.username, isNull); + expect(additionalUserInfo.isNewUser, isNotNull); + expect(additionalUserInfo.profile, isNull); + // TODO(jackson): Fix behavior to be consistent across platforms + // https://github.com/firebase/firebase-ios-sdk/issues/3450 + expect( + additionalUserInfo.providerId == null || + additionalUserInfo.providerId == 'password', + isTrue); expect(user.uid, isNotNull); expect(user.isAnonymous, isTrue); + expect(user.metadata.creationTime.isAfter(DateTime(2018, 1, 1)), isTrue); + expect(user.metadata.creationTime.isBefore(DateTime.now()), isTrue); + final IdTokenResult tokenResult = await user.getIdToken(); + expect(tokenResult.token, isNotNull); + expect(tokenResult.expirationTime.isAfter(DateTime.now()), isTrue); + expect(tokenResult.authTime, isNotNull); + expect(tokenResult.issuedAtTime, isNotNull); + // TODO(jackson): Fix behavior to be consistent across platforms + // https://github.com/firebase/firebase-ios-sdk/issues/3445 + expect( + tokenResult.signInProvider == null || + tokenResult.signInProvider == 'anonymous', + isTrue); + expect(tokenResult.claims['provider_id'], 'anonymous'); + expect(tokenResult.claims['firebase']['sign_in_provider'], 'anonymous'); + expect(tokenResult.claims['user_id'], user.uid); + await auth.signOut(); + final FirebaseUser user2 = (await auth.signInAnonymously()).user; + expect(user2.uid, isNot(equals(user.uid))); + expect(user2.metadata.creationTime.isBefore(user.metadata.creationTime), + isFalse); + expect( + user2.metadata.lastSignInTime, equals(user2.metadata.creationTime)); + }); + + test('email auth', () async { + final String testEmail = 'testuser${Uuid().v4()}@example.com'; + final String testPassword = 'testpassword'; + AuthResult result = await auth.createUserWithEmailAndPassword( + email: testEmail, + password: testPassword, + ); + final FirebaseUser user = result.user; + expect(user.uid, isNotNull); + expect(user.isAnonymous, isFalse); + auth.signOut(); + final Future failedResult = auth.signInWithEmailAndPassword( + email: testEmail, + password: 'incorrect password', + ); + expect(failedResult, throwsA(isInstanceOf())); + result = await auth.signInWithEmailAndPassword( + email: testEmail, + password: testPassword, + ); + expect(result.user.uid, equals(user.uid)); + await user.delete(); }); test('isSignInWithEmailLink', () async { diff --git a/packages/firebase_auth/ios/Classes/FirebaseAuthPlugin.m b/packages/firebase_auth/ios/Classes/FirebaseAuthPlugin.m index bbe6f8c4f283..682ffe18e34c 100644 --- a/packages/firebase_auth/ios/Classes/FirebaseAuthPlugin.m +++ b/packages/firebase_auth/ios/Classes/FirebaseAuthPlugin.m @@ -107,13 +107,15 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } else if ([@"signInAnonymously" isEqualToString:call.method]) { [[self getAuth:call.arguments] signInAnonymouslyWithCompletion:^(FIRAuthDataResult *authResult, NSError *error) { - [self sendResult:result forUser:authResult.user error:error]; + [self sendResult:result forAuthDataResult:authResult error:error]; }]; } else if ([@"signInWithCredential" isEqualToString:call.method]) { [[self getAuth:call.arguments] signInAndRetrieveDataWithCredential:[self getCredential:call.arguments] completion:^(FIRAuthDataResult *authResult, NSError *error) { - [self sendResult:result forUser:authResult.user error:error]; + [self sendResult:result + forAuthDataResult:authResult + error:error]; }]; } else if ([@"createUserWithEmailAndPassword" isEqualToString:call.method]) { NSString *email = call.arguments[@"email"]; @@ -122,7 +124,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result createUserWithEmail:email password:password completion:^(FIRAuthDataResult *authResult, NSError *error) { - [self sendResult:result forUser:authResult.user error:error]; + [self sendResult:result forAuthDataResult:authResult error:error]; }]; } else if ([@"fetchSignInMethodsForEmail" isEqualToString:call.method]) { NSString *email = call.arguments[@"email"]; @@ -177,7 +179,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result signInWithEmail:email link:link completion:^(FIRAuthDataResult *_Nullable authResult, NSError *_Nullable error) { - [self sendResult:result forUser:authResult.user error:error]; + [self sendResult:result forAuthDataResult:authResult error:error]; }]; } else if ([@"signInWithEmailAndPassword" isEqualToString:call.method]) { NSString *email = call.arguments[@"email"]; @@ -186,7 +188,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result signInWithEmail:email password:password completion:^(FIRAuthDataResult *authResult, NSError *error) { - [self sendResult:result forUser:authResult.user error:error]; + [self sendResult:result forAuthDataResult:authResult error:error]; }]; } else if ([@"signOut" isEqualToString:call.method]) { NSError *signOutError; @@ -201,10 +203,33 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result NSDictionary *args = call.arguments; BOOL refresh = [args objectForKey:@"refresh"]; [[self getAuth:call.arguments].currentUser - getIDTokenForcingRefresh:refresh - completion:^(NSString *_Nullable token, NSError *_Nullable error) { - [self sendResult:result forObject:token error:error]; - }]; + getIDTokenResultForcingRefresh:refresh + completion:^(FIRAuthTokenResult *_Nullable tokenResult, + NSError *_Nullable error) { + NSMutableDictionary *tokenData = nil; + if (tokenResult != nil) { + long expirationTimestamp = + [tokenResult.expirationDate timeIntervalSince1970]; + long authTimestamp = [tokenResult.authDate timeIntervalSince1970]; + long issuedAtTimestamp = + [tokenResult.issuedAtDate timeIntervalSince1970]; + + tokenData = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"token" : tokenResult.token, + @"expirationTimestamp" : + [NSNumber numberWithInt:expirationTimestamp], + @"authTimestamp" : [NSNumber numberWithInt:authTimestamp], + @"issuedAtTimestamp" : [NSNumber numberWithInt:issuedAtTimestamp], + @"claims" : tokenResult.claims, + }]; + + if (tokenResult.signInProvider != nil) { + tokenData[@"signInProvider"] = tokenResult.signInProvider; + } + } + + [self sendResult:result forObject:tokenData error:error]; + }]; } else if ([@"reauthenticateWithCredential" isEqualToString:call.method]) { [[self getAuth:call.arguments].currentUser reauthenticateAndRetrieveDataWithCredential:[self getCredential:call.arguments] @@ -215,8 +240,8 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } else if ([@"linkWithCredential" isEqualToString:call.method]) { [[self getAuth:call.arguments].currentUser linkAndRetrieveDataWithCredential:[self getCredential:call.arguments] - completion:^(FIRAuthDataResult *r, NSError *error) { - [self sendResult:result forUser:r.user error:error]; + completion:^(FIRAuthDataResult *authResult, NSError *error) { + [self sendResult:result forAuthDataResult:authResult error:error]; }]; } else if ([@"unlinkFromProvider" isEqualToString:call.method]) { NSString *provider = call.arguments[@"provider"]; @@ -266,7 +291,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result [[self getAuth:call.arguments] signInWithCustomToken:token completion:^(FIRAuthDataResult *authResult, NSError *error) { - [self sendResult:result forUser:authResult.user error:error]; + [self sendResult:result forAuthDataResult:authResult error:error]; }]; } else if ([@"startListeningAuthState" isEqualToString:call.method]) { @@ -329,8 +354,11 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result verificationCode:smsCode]; [[self getAuth:call.arguments] signInAndRetrieveDataWithCredential:credential - completion:^(FIRAuthDataResult *r, NSError *_Nullable error) { - [self sendResult:result forUser:r.user error:error]; + completion:^(FIRAuthDataResult *authResult, + NSError *_Nullable error) { + [self sendResult:result + forAuthDataResult:authResult + error:error]; }]; } else if ([@"setLanguageCode" isEqualToString:call.method]) { NSString *language = call.arguments[@"language"]; @@ -348,12 +376,12 @@ - (NSMutableDictionary *)dictionaryFromUser:(FIRUser *)user { [providerData addObject:toDictionary(userInfo)]; } - long creationDate = [user.metadata.creationDate timeIntervalSince1970]; - long lastSignInDate = [user.metadata.lastSignInDate timeIntervalSince1970]; + long creationDate = [user.metadata.creationDate timeIntervalSince1970] * 1000; + long lastSignInDate = [user.metadata.lastSignInDate timeIntervalSince1970] * 1000; NSMutableDictionary *userData = [toDictionary(user) mutableCopy]; userData[@"creationTimestamp"] = [NSNumber numberWithLong:creationDate]; - userData[@"lastSignInTimestamp"] = [NSNumber numberWithInt:lastSignInDate]; + userData[@"lastSignInTimestamp"] = [NSNumber numberWithLong:lastSignInDate]; userData[@"isAnonymous"] = [NSNumber numberWithBool:user.isAnonymous]; userData[@"isEmailVerified"] = [NSNumber numberWithBool:user.isEmailVerified]; userData[@"providerData"] = providerData; @@ -361,6 +389,25 @@ - (NSMutableDictionary *)dictionaryFromUser:(FIRUser *)user { } #pragma clang diagnostic pop +- (void)sendResult:(FlutterResult)result + forAuthDataResult:(FIRAuthDataResult *)authResult + error:(NSError *)error { + FIRUser *user = authResult.user; + FIRAdditionalUserInfo *additionalUserInfo = authResult.additionalUserInfo; + [self sendResult:result + forObject:@{ + @"user" : (user != nil ? [self dictionaryFromUser:user] : [NSNull null]), + @"additionalUserInfo" : additionalUserInfo ? @{ + @"isNewUser" : [NSNumber numberWithBool:additionalUserInfo.isNewUser], + @"username" : additionalUserInfo.username ?: [NSNull null], + @"providerId" : additionalUserInfo.providerID ?: [NSNull null], + @"profile" : additionalUserInfo.profile ?: [NSNull null], + } + : [NSNull null], + } + error:error]; +} + - (void)sendResult:(FlutterResult)result forUser:(FIRUser *)user error:(NSError *)error { [self sendResult:result forObject:(user != nil ? [self dictionaryFromUser:user] : nil) diff --git a/packages/firebase_auth/lib/firebase_auth.dart b/packages/firebase_auth/lib/firebase_auth.dart index 1f31ead02dc0..29100487713b 100755 --- a/packages/firebase_auth/lib/firebase_auth.dart +++ b/packages/firebase_auth/lib/firebase_auth.dart @@ -16,10 +16,13 @@ part 'src/auth_provider/github_auth_provider.dart'; part 'src/auth_provider/google_auth_provider.dart'; part 'src/auth_provider/phone_auth_provider.dart'; part 'src/auth_provider/twitter_auth_provider.dart'; +part 'src/additional_user_info.dart'; part 'src/auth_credential.dart'; part 'src/auth_exception.dart'; +part 'src/auth_result.dart'; part 'src/firebase_auth.dart'; part 'src/firebase_user.dart'; +part 'src/id_token_result.dart'; part 'src/user_info.dart'; part 'src/user_metadata.dart'; part 'src/user_update_info.dart'; diff --git a/packages/firebase_auth/lib/src/additional_user_info.dart b/packages/firebase_auth/lib/src/additional_user_info.dart new file mode 100644 index 000000000000..2fb0f7707fc4 --- /dev/null +++ b/packages/firebase_auth/lib/src/additional_user_info.dart @@ -0,0 +1,26 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of firebase_auth; + +/// Interface representing a user's additional information +class AdditionalUserInfo { + AdditionalUserInfo._(this._data); + + final Map _data; + + /// Returns whether the user is new or existing + bool get isNewUser => _data['isNewUser']; + + /// Returns the username if the provider is GitHub or Twitter + String get username => _data['username']; + + /// Returns the provider ID for specifying which provider the + /// information in [profile] is for. + String get providerId => _data['providerId']; + + /// Returns a Map containing IDP-specific user data if the provider + /// is one of Facebook, GitHub, Google, Twitter, Microsoft, or Yahoo. + Map get profile => _data['profile']?.cast(); +} diff --git a/packages/firebase_auth/lib/src/auth_result.dart b/packages/firebase_auth/lib/src/auth_result.dart new file mode 100644 index 000000000000..dd003dac0791 --- /dev/null +++ b/packages/firebase_auth/lib/src/auth_result.dart @@ -0,0 +1,31 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of firebase_auth; + +/// Result object obtained from operations that can affect the authentication +/// state. Contains a method that returns the currently signed-in user after +/// the operation has completed. +class AuthResult { + AuthResult._(this._data, FirebaseApp app) + : user = FirebaseUser._(_data['user'].cast(), app); + + final Map _data; + + /// Returns the currently signed-in [FirebaseUser], or `null` if there isn't + /// any (i.e. the user is signed out). + final FirebaseUser user; + + /// Returns IDP-specific information for the user if the provider is one of + /// Facebook, Github, Google, or Twitter. + AdditionalUserInfo get additionalUserInfo => + _data['additionalUserInfo'] == null + ? null + : AdditionalUserInfo._(_data['additionalUserInfo']); + + @override + String toString() { + return '$runtimeType($_data)'; + } +} diff --git a/packages/firebase_auth/lib/src/firebase_auth.dart b/packages/firebase_auth/lib/src/firebase_auth.dart index f3c79ffd5cfa..8f0c30a13544 100644 --- a/packages/firebase_auth/lib/src/firebase_auth.dart +++ b/packages/firebase_auth/lib/src/firebase_auth.dart @@ -71,12 +71,12 @@ class FirebaseAuth { /// /// Errors: /// • `ERROR_OPERATION_NOT_ALLOWED` - Indicates that Anonymous accounts are not enabled. - Future signInAnonymously() async { + Future signInAnonymously() async { final Map data = await channel .invokeMapMethod( 'signInAnonymously', {"app": app.name}); - final FirebaseUser currentUser = FirebaseUser._(data, app); - return currentUser; + final AuthResult authResult = AuthResult._(data, app); + return authResult; } /// Tries to create a new user account with the given email address and password. @@ -88,7 +88,7 @@ class FirebaseAuth { /// • `ERROR_WEAK_PASSWORD` - If the password is not strong enough. /// • `ERROR_INVALID_EMAIL` - If the email address is malformed. /// • `ERROR_EMAIL_ALREADY_IN_USE` - If the email is already in use by a different account. - Future createUserWithEmailAndPassword({ + Future createUserWithEmailAndPassword({ @required String email, @required String password, }) async { @@ -99,8 +99,8 @@ class FirebaseAuth { 'createUserWithEmailAndPassword', {'email': email, 'password': password, 'app': app.name}, ); - final FirebaseUser currentUser = FirebaseUser._(data, app); - return currentUser; + final AuthResult authResult = AuthResult._(data, app); + return authResult; } /// Returns a list of sign-in methods that can be used to sign in a given @@ -187,8 +187,7 @@ class FirebaseAuth { /// Firebase console. /// • `ERROR_DISABLED` - Indicates the user's account is disabled. /// • `ERROR_INVALID` - Indicates the email address is invalid. - Future signInWithEmailAndLink( - {String email, String link}) async { + Future signInWithEmailAndLink({String email, String link}) async { final Map data = await channel.invokeMapMethod( 'signInWithEmailAndLink', @@ -198,8 +197,8 @@ class FirebaseAuth { 'link': link, }, ); - final FirebaseUser currentUser = FirebaseUser._(data, app); - return currentUser; + final AuthResult authResult = AuthResult._(data, app); + return authResult; } /// Tries to sign in a user with the given email address and password. @@ -217,7 +216,7 @@ class FirebaseAuth { /// • `ERROR_USER_DISABLED` - If the user has been disabled (for example, in the Firebase console) /// • `ERROR_TOO_MANY_REQUESTS` - If there was too many attempts to sign in as this user. /// • `ERROR_OPERATION_NOT_ALLOWED` - Indicates that Email & Password accounts are not enabled. - Future signInWithEmailAndPassword({ + Future signInWithEmailAndPassword({ @required String email, @required String password, }) { @@ -251,7 +250,7 @@ class FirebaseAuth { /// • `ERROR_OPERATION_NOT_ALLOWED` - Indicates that Google accounts are not enabled. /// • `ERROR_INVALID_ACTION_CODE` - If the action code in the link is malformed, expired, or has already been used. /// This can only occur when using [EmailAuthProvider.getCredentialWithLink] to obtain the credential. - Future signInWithCredential(AuthCredential credential) async { + Future signInWithCredential(AuthCredential credential) async { assert(credential != null); final Map data = await channel.invokeMapMethod( @@ -262,8 +261,8 @@ class FirebaseAuth { 'data': credential._data, }, ); - final FirebaseUser currentUser = FirebaseUser._(data, app); - return currentUser; + final AuthResult authResult = AuthResult._(data, app); + return authResult; } /// Starts the phone number verification process for the given phone number. @@ -356,15 +355,15 @@ class FirebaseAuth { /// Please check the documentation. /// • `ERROR_CUSTOM_TOKEN_MISMATCH` - Invalid configuration. /// Ensure your app's SHA1 is correct in the Firebase console. - Future signInWithCustomToken({@required String token}) async { + Future signInWithCustomToken({@required String token}) async { assert(token != null); final Map data = await channel.invokeMapMethod( 'signInWithCustomToken', {'token': token, 'app': app.name}, ); - final FirebaseUser currentUser = FirebaseUser._(data, app); - return currentUser; + final AuthResult authResult = AuthResult._(data, app); + return authResult; } /// Signs out the current user and clears it from the disk cache. diff --git a/packages/firebase_auth/lib/src/firebase_user.dart b/packages/firebase_auth/lib/src/firebase_user.dart index 60e341648d44..b9e4c8c244ef 100644 --- a/packages/firebase_auth/lib/src/firebase_user.dart +++ b/packages/firebase_auth/lib/src/firebase_user.dart @@ -26,19 +26,21 @@ class FirebaseUser extends UserInfo { /// Returns true if the user's email is verified. bool get isEmailVerified => _data['isEmailVerified']; - /// Obtains the id token for the current user, forcing a [refresh] if desired. + /// Obtains the id token result for the current user, forcing a [refresh] if desired. /// /// Useful when authenticating against your own backend. Use our server /// SDKs or follow the official documentation to securely verify the /// integrity and validity of this token. /// /// Completes with an error if the user is signed out. - Future getIdToken({bool refresh = false}) async { - return await FirebaseAuth.channel - .invokeMethod('getIdToken', { + Future getIdToken({bool refresh = false}) async { + final Map data = await FirebaseAuth.channel + .invokeMapMethod('getIdToken', { 'refresh': refresh, 'app': _app.name, }); + + return IdTokenResult(data, _app); } /// Associates a user account from a third-party identity provider with this @@ -58,7 +60,7 @@ class FirebaseUser extends UserInfo { /// • `ERROR_OPERATION_NOT_ALLOWED` - Indicates that this type of account is not enabled. /// • `ERROR_INVALID_ACTION_CODE` - If the action code in the link is malformed, expired, or has already been used. /// This can only occur when using [EmailAuthProvider.getCredentialWithLink] to obtain the credential. - Future linkWithCredential(AuthCredential credential) async { + Future linkWithCredential(AuthCredential credential) async { assert(credential != null); final Map data = await FirebaseAuth.channel.invokeMapMethod( @@ -69,8 +71,8 @@ class FirebaseUser extends UserInfo { 'data': credential._data, }, ); - final FirebaseUser currentUser = FirebaseUser._(data, _app); - return currentUser; + final AuthResult result = AuthResult._(data, _app); + return result; } /// Initiates email verification for the user. @@ -86,7 +88,13 @@ class FirebaseUser extends UserInfo { .invokeMethod('reload', {'app': _app.name}); } - /// Deletes the user record from your Firebase project's database. + /// Deletes the current user (also signs out the user). + /// + /// Errors: + /// • `ERROR_REQUIRES_RECENT_LOGIN` - If the user's last sign-in time does not meet the security threshold. Use reauthenticate methods to resolve. + /// • `ERROR_INVALID_CREDENTIAL` - If the credential is malformed or has expired. + /// • `ERROR_USER_DISABLED` - If the user has been disabled (for example, in the Firebase console) + /// • `ERROR_USER_NOT_FOUND` - If the user has been deleted (for example, in the Firebase console) Future delete() async { await FirebaseAuth.channel .invokeMethod('delete', {'app': _app.name}); @@ -190,10 +198,11 @@ class FirebaseUser extends UserInfo { /// • `ERROR_USER_DISABLED` - If the user has been disabled (for example, in the Firebase console) /// • `ERROR_USER_NOT_FOUND` - If the user has been deleted (for example, in the Firebase console) /// • `ERROR_OPERATION_NOT_ALLOWED` - Indicates that Email & Password accounts are not enabled. - Future reauthenticateWithCredential( + Future reauthenticateWithCredential( AuthCredential credential) async { assert(credential != null); - await FirebaseAuth.channel.invokeMethod( + final Map data = + await FirebaseAuth.channel.invokeMapMethod( 'reauthenticateWithCredential', { 'app': _app.name, @@ -201,7 +210,7 @@ class FirebaseUser extends UserInfo { 'data': credential._data, }, ); - return this; + return AuthResult._(data, _app); } /// Detaches the [provider] account from the current user. diff --git a/packages/firebase_auth/lib/src/id_token_result.dart b/packages/firebase_auth/lib/src/id_token_result.dart new file mode 100644 index 000000000000..c53e9ae5bb4d --- /dev/null +++ b/packages/firebase_auth/lib/src/id_token_result.dart @@ -0,0 +1,51 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of firebase_auth; + +/// Represents ID token result obtained from [FirebaseUser], containing the +/// ID token JWT string and other helper properties for getting different +/// data associated with the token as well as all the decoded payload claims. +/// +/// Note that these claims are not to be trusted as they are parsed client side. +/// Only server side verification can guarantee the integrity of the token +/// claims. +class IdTokenResult { + @visibleForTesting + IdTokenResult(this._data, this._app); + + final FirebaseApp _app; + + final Map _data; + + /// The Firebase Auth ID token JWT string. + String get token => _data['token']; + + /// The time when the ID token expires. + DateTime get expirationTime => + DateTime.fromMillisecondsSinceEpoch(_data['expirationTimestamp'] * 1000); + + /// The time the user authenticated (signed in). + /// + /// Note that this is not the time the token was refreshed. + DateTime get authTime => + DateTime.fromMillisecondsSinceEpoch(_data['authTimestamp'] * 1000); + + /// The time when ID token was issued. + DateTime get issuedAtTime => + DateTime.fromMillisecondsSinceEpoch(_data['issuedAtTimestamp'] * 1000); + + /// The sign-in provider through which the ID token was obtained (anonymous, + /// custom, phone, password, etc). Note, this does not map to provider IDs. + String get signInProvider => _data['signInProvider']; + + /// The entire payload claims of the ID token including the standard reserved + /// claims as well as the custom claims. + Map get claims => _data['claims']; + + @override + String toString() { + return '$runtimeType($_data)'; + } +} diff --git a/packages/firebase_auth/lib/src/user_metadata.dart b/packages/firebase_auth/lib/src/user_metadata.dart index 66c1fedf4911..293d294b6ecb 100644 --- a/packages/firebase_auth/lib/src/user_metadata.dart +++ b/packages/firebase_auth/lib/src/user_metadata.dart @@ -10,7 +10,13 @@ class FirebaseUserMetadata { final Map _data; - int get creationTimestamp => _data['creationTimestamp']; + /// When this account was created as dictated by the server clock. + DateTime get creationTime => + DateTime.fromMillisecondsSinceEpoch(_data['creationTimestamp']); - int get lastSignInTimestamp => _data['lastSignInTimestamp']; + /// When the user last signed in as dictated by the server clock. + /// + /// This is only accurate up to a granularity of 2 minutes for consecutive sign-in attempts. + DateTime get lastSignInTime => + DateTime.fromMillisecondsSinceEpoch(_data['lastSignInTimestamp']); } diff --git a/packages/firebase_auth/pubspec.yaml b/packages/firebase_auth/pubspec.yaml index 238c1d38128d..d4cf41bc55c7 100755 --- a/packages/firebase_auth/pubspec.yaml +++ b/packages/firebase_auth/pubspec.yaml @@ -4,7 +4,7 @@ description: Flutter plugin for Firebase Auth, enabling Android and iOS like Google, Facebook and Twitter. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/firebase_auth -version: "0.11.1+12" +version: 0.14.0+1 flutter: plugin: @@ -22,6 +22,7 @@ dependencies: dev_dependencies: google_sign_in: ^3.0.4 firebase_dynamic_links: ^0.3.0 + uuid: ^2.0.2 test: ^1.3.0 flutter_test: sdk: flutter diff --git a/packages/firebase_auth/test/firebase_auth_test.dart b/packages/firebase_auth/test/firebase_auth_test.dart index 928cb9916854..5573302c62b6 100755 --- a/packages/firebase_auth/test/firebase_auth_test.dart +++ b/packages/firebase_auth/test/firebase_auth_test.dart @@ -25,6 +25,46 @@ const String kMockPhoneNumber = '5555555555'; const String kMockVerificationId = '12345'; const String kMockSmsCode = '123456'; const String kMockLanguage = 'en'; +const String kMockIdTokenResultSignInProvider = 'password'; +const Map kMockIdTokenResultClaims = { + 'claim1': 'value1', +}; +const int kMockIdTokenResultExpirationTimestamp = 123456; +const int kMockIdTokenResultAuthTimestamp = 1234567; +const int kMockIdTokenResultIssuedAtTimestamp = 12345678; +const Map kMockIdTokenResult = { + 'token': kMockIdToken, + 'expirationTimestamp': kMockIdTokenResultExpirationTimestamp, + 'authTimestamp': kMockIdTokenResultAuthTimestamp, + 'issuedAtTimestamp': kMockIdTokenResultIssuedAtTimestamp, + 'signInProvider': kMockIdTokenResultSignInProvider, + 'claims': kMockIdTokenResultClaims, +}; + +final int kMockCreationTimestamp = DateTime(2019, 1, 1).millisecondsSinceEpoch; +final int kMockLastSignInTimestamp = + DateTime.now().subtract(const Duration(days: 1)).millisecondsSinceEpoch; +final Map kMockUser = { + 'isAnonymous': true, + 'isEmailVerified': false, + 'creationTimestamp': kMockCreationTimestamp, + 'lastSignInTimestamp': kMockLastSignInTimestamp, + 'providerData': >[ + { + 'providerId': kMockProviderId, + 'uid': kMockUid, + 'displayName': kMockDisplayName, + 'photoUrl': kMockPhotoUrl, + 'email': kMockEmail, + }, + ], +}; +const Map kMockAdditionalUserInfo = { + 'isNewUser': false, + 'username': 'flutterUser', + 'providerId': 'testProvider', + 'profile': {'foo': 'bar'}, +}; void main() { group('$FirebaseAuth', () { @@ -41,13 +81,15 @@ void main() { log.add(call); switch (call.method) { case "getIdToken": - return kMockIdToken; + return kMockIdTokenResult; break; case "isSignInWithEmailLink": return true; case "startListeningAuthState": return mockHandleId++; break; + case "currentUser": + return kMockUser; case "sendLinkToEmail": case "sendPasswordResetEmail": case "updateEmail": @@ -63,7 +105,10 @@ void main() { return null; break; default: - return mockFirebaseUser(); + return { + 'user': kMockUser, + 'additionalUserInfo': kMockAdditionalUserInfo, + }; break; } }); @@ -79,29 +124,50 @@ void main() { expect(userInfo.displayName, kMockDisplayName); expect(userInfo.photoUrl, kMockPhotoUrl); expect(userInfo.email, kMockEmail); + expect(user.metadata.creationTime.millisecondsSinceEpoch, + kMockCreationTimestamp); + expect(user.metadata.lastSignInTime.millisecondsSinceEpoch, + kMockLastSignInTimestamp); } - test('currentUser', () async { - final FirebaseUser user = await auth.currentUser(); - verifyUser(user); + void verifyAuthResult(AuthResult result) { + verifyUser(result.user); + final AdditionalUserInfo additionalUserInfo = result.additionalUserInfo; expect( - log, - [ - isMethodCall('currentUser', - arguments: {'app': auth.app.name}), - ], - ); - }); + additionalUserInfo.isNewUser, kMockAdditionalUserInfo['isNewUser']); + expect(additionalUserInfo.username, kMockAdditionalUserInfo['username']); + expect( + additionalUserInfo.providerId, kMockAdditionalUserInfo['providerId']); + expect(additionalUserInfo.profile, kMockAdditionalUserInfo['profile']); + } - test('signInAnonymously', () async { - final FirebaseUser user = await auth.signInAnonymously(); - verifyUser(user); - expect(await user.getIdToken(), equals(kMockIdToken)); - expect(await user.getIdToken(refresh: true), equals(kMockIdToken)); + test('getIdToken', () async { + void verifyIdTokenResult(IdTokenResult idTokenResult) { + expect(idTokenResult.token, equals(kMockIdToken)); + expect( + idTokenResult.expirationTime, + equals(DateTime.fromMillisecondsSinceEpoch( + kMockIdTokenResultExpirationTimestamp * 1000))); + expect( + idTokenResult.authTime, + equals(DateTime.fromMillisecondsSinceEpoch( + kMockIdTokenResultAuthTimestamp * 1000))); + expect( + idTokenResult.issuedAtTime, + equals(DateTime.fromMillisecondsSinceEpoch( + kMockIdTokenResultIssuedAtTimestamp * 1000))); + expect(idTokenResult.signInProvider, + equals(kMockIdTokenResultSignInProvider)); + expect(idTokenResult.claims, equals(kMockIdTokenResultClaims)); + } + + final FirebaseUser user = await auth.currentUser(); + verifyIdTokenResult(await user.getIdToken()); + verifyIdTokenResult(await user.getIdToken(refresh: true)); expect( log, [ - isMethodCall('signInAnonymously', + isMethodCall('currentUser', arguments: {'app': auth.app.name}), isMethodCall( 'getIdToken', @@ -118,6 +184,18 @@ void main() { ); }); + test('signInAnonymously', () async { + final AuthResult result = await auth.signInAnonymously(); + verifyAuthResult(result); + expect( + log, + [ + isMethodCall('signInAnonymously', + arguments: {'app': auth.app.name}), + ], + ); + }); + test('sendSignInWithEmailLink', () async { await auth.sendSignInWithEmailLink( email: 'test@example.com', @@ -158,10 +236,11 @@ void main() { }); test('signInWithEmailAndLink', () async { - await auth.signInWithEmailAndLink( + final AuthResult result = await auth.signInWithEmailAndLink( email: 'test@example.com', link: '', ); + verifyAuthResult(result); expect( log, [ @@ -175,11 +254,11 @@ void main() { }); test('createUserWithEmailAndPassword', () async { - final FirebaseUser user = await auth.createUserWithEmailAndPassword( + final AuthResult result = await auth.createUserWithEmailAndPassword( email: kMockEmail, password: kMockPassword, ); - verifyUser(user); + verifyAuthResult(result); expect( log, [ @@ -219,9 +298,9 @@ void main() { email: 'test@example.com', link: '', ); - FirebaseUser user = await auth.currentUser(); - user = await user.linkWithCredential(credential); - verifyUser(user); + final FirebaseUser user = await auth.currentUser(); + final AuthResult result = await user.linkWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -251,8 +330,8 @@ void main() { email: 'test@example.com', link: '', ); - final FirebaseUser user = await auth.signInWithCredential(credential); - verifyUser(user); + final AuthResult result = await auth.signInWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -302,9 +381,9 @@ void main() { authToken: kMockIdToken, authTokenSecret: kMockAccessToken, ); - FirebaseUser user = await auth.currentUser(); - user = await user.linkWithCredential(credential); - verifyUser(user); + final FirebaseUser user = await auth.currentUser(); + final AuthResult result = await user.linkWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -334,8 +413,8 @@ void main() { authToken: kMockIdToken, authTokenSecret: kMockAccessToken, ); - final FirebaseUser user = await auth.signInWithCredential(credential); - verifyUser(user); + final AuthResult result = await auth.signInWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -358,9 +437,9 @@ void main() { final AuthCredential credential = GithubAuthProvider.getCredential( token: kMockGithubToken, ); - FirebaseUser user = await auth.currentUser(); - user = await user.linkWithCredential(credential); - verifyUser(user); + final FirebaseUser user = await auth.currentUser(); + final AuthResult result = await user.linkWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -388,8 +467,8 @@ void main() { final AuthCredential credential = GithubAuthProvider.getCredential( token: kMockGithubToken, ); - final FirebaseUser user = await auth.signInWithCredential(credential); - verifyUser(user); + final AuthResult result = await auth.signInWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -412,9 +491,9 @@ void main() { email: kMockEmail, password: kMockPassword, ); - FirebaseUser user = await auth.currentUser(); - user = await user.linkWithCredential(credential); - verifyUser(user); + final FirebaseUser user = await auth.currentUser(); + final AuthResult result = await user.linkWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -444,8 +523,8 @@ void main() { idToken: kMockIdToken, accessToken: kMockAccessToken, ); - final FirebaseUser user = await auth.signInWithCredential(credential); - verifyUser(user); + final AuthResult result = await auth.signInWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -469,7 +548,8 @@ void main() { verificationId: kMockVerificationId, smsCode: kMockSmsCode, ); - await auth.signInWithCredential(credential); + final AuthResult result = await auth.signInWithCredential(credential); + verifyAuthResult(result); expect(log, [ isMethodCall('signInWithCredential', arguments: { 'app': auth.app.name, @@ -508,7 +588,9 @@ void main() { email: kMockEmail, password: kMockPassword, ); - await user.reauthenticateWithCredential(credential); + final AuthResult result = + await user.reauthenticateWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -533,7 +615,9 @@ void main() { idToken: kMockIdToken, accessToken: kMockAccessToken, ); - await user.reauthenticateWithCredential(credential); + final AuthResult result = + await user.reauthenticateWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -558,7 +642,9 @@ void main() { final AuthCredential credential = FacebookAuthProvider.getCredential( accessToken: kMockAccessToken, ); - await user.reauthenticateWithCredential(credential); + final AuthResult result = + await user.reauthenticateWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -583,7 +669,9 @@ void main() { authToken: kMockAuthToken, authTokenSecret: kMockAuthTokenSecret, ); - await user.reauthenticateWithCredential(credential); + final AuthResult result = + await user.reauthenticateWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -608,7 +696,9 @@ void main() { final AuthCredential credential = GithubAuthProvider.getCredential( token: kMockGithubToken, ); - await user.reauthenticateWithCredential(credential); + final AuthResult result = + await user.reauthenticateWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -631,9 +721,9 @@ void main() { idToken: kMockIdToken, accessToken: kMockAccessToken, ); - FirebaseUser user = await auth.currentUser(); - user = await user.linkWithCredential(credential); - verifyUser(user); + final FirebaseUser user = await auth.currentUser(); + final AuthResult result = await user.linkWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -662,9 +752,9 @@ void main() { final AuthCredential credential = FacebookAuthProvider.getCredential( accessToken: kMockAccessToken, ); - FirebaseUser user = await auth.currentUser(); - user = await user.linkWithCredential(credential); - verifyUser(user); + final FirebaseUser user = await auth.currentUser(); + final AuthResult result = await user.linkWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -692,8 +782,8 @@ void main() { final AuthCredential credential = FacebookAuthProvider.getCredential( accessToken: kMockAccessToken, ); - final FirebaseUser user = await auth.signInWithCredential(credential); - verifyUser(user); + final AuthResult result = await auth.signInWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -716,9 +806,9 @@ void main() { authToken: kMockAuthToken, authTokenSecret: kMockAuthTokenSecret, ); - FirebaseUser user = await auth.currentUser(); - user = await user.linkWithCredential(credential); - verifyUser(user); + final FirebaseUser user = await auth.currentUser(); + final AuthResult result = await user.linkWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -748,8 +838,8 @@ void main() { authToken: kMockAuthToken, authTokenSecret: kMockAuthTokenSecret, ); - final FirebaseUser user = await auth.signInWithCredential(credential); - verifyUser(user); + final AuthResult result = await auth.signInWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -772,9 +862,9 @@ void main() { final AuthCredential credential = GithubAuthProvider.getCredential( token: kMockGithubToken, ); - FirebaseUser user = await auth.currentUser(); - user = await user.linkWithCredential(credential); - verifyUser(user); + final FirebaseUser user = await auth.currentUser(); + final AuthResult result = await user.linkWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -802,8 +892,8 @@ void main() { final AuthCredential credential = GithubAuthProvider.getCredential( token: kMockGithubToken, ); - final FirebaseUser user = await auth.signInWithCredential(credential); - verifyUser(user); + final AuthResult result = await auth.signInWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -826,9 +916,9 @@ void main() { email: kMockEmail, password: kMockPassword, ); - FirebaseUser user = await auth.currentUser(); - user = await user.linkWithCredential(credential); - verifyUser(user); + final FirebaseUser user = await auth.currentUser(); + final AuthResult result = await user.linkWithCredential(credential); + verifyAuthResult(result); expect( log, [ @@ -1122,9 +1212,9 @@ void main() { }); test('signInWithCustomToken', () async { - final FirebaseUser user = + final AuthResult result = await auth.signInWithCustomToken(token: kMockCustomToken); - verifyUser(user); + verifyAuthResult(result); expect( log, [ @@ -1163,7 +1253,7 @@ void main() { await Future.delayed(const Duration(seconds: 0)); await simulateEvent(null); - await simulateEvent(mockFirebaseUser()); + await simulateEvent(kMockUser); final FirebaseUser user1 = await events.remove(); expect(user1, isNull); @@ -1211,26 +1301,6 @@ void main() { }); } -Map mockFirebaseUser( - {String providerId = kMockProviderId, - String uid = kMockUid, - String displayName = kMockDisplayName, - String photoUrl = kMockPhotoUrl, - String email = kMockEmail}) => - { - 'isAnonymous': true, - 'isEmailVerified': false, - 'providerData': >[ - { - 'providerId': providerId, - 'uid': uid, - 'displayName': displayName, - 'photoUrl': photoUrl, - 'email': email, - }, - ], - }; - /// Queue whose remove operation is asynchronous, awaiting a corresponding add. class AsyncQueue { Map> _completers = >{}; diff --git a/packages/firebase_crashlytics/CHANGELOG.md b/packages/firebase_crashlytics/CHANGELOG.md index 17da29918f4e..6b85c00fea17 100644 --- a/packages/firebase_crashlytics/CHANGELOG.md +++ b/packages/firebase_crashlytics/CHANGELOG.md @@ -1,6 +1,21 @@ +## 0.1.0+2 + +* [iOS] Fixes crash when trying to report a crash without any context + +## 0.1.0+1 + +* Added additional exception information from the Flutter framework to the reports. +* Refactored debug printing of exceptions to be human-readable. +* Passing `null` stack traces is now supported. +* Added the "Error reported to Crashlytics." print statement that was previously missing. +* Updated `README.md` to include both the breaking change from `0.1.0` and the newly added + `recordError` function in the setup section. +* Adjusted `README.md` formatting. +* Fixed `recordFlutterError` method name in the `0.1.0` changelog entry. + ## 0.1.0 -* **Breaking Change** Renamed `onError` to `reportFlutterError`. +* **Breaking Change** Renamed `onError` to `recordFlutterError`. * Added `recordError` method for errors caught using `runZoned`'s `onError`. ## 0.0.4+12 diff --git a/packages/firebase_crashlytics/README.md b/packages/firebase_crashlytics/README.md index 1876b3f10ea1..9f3980a0d16b 100644 --- a/packages/firebase_crashlytics/README.md +++ b/packages/firebase_crashlytics/README.md @@ -11,13 +11,14 @@ For Flutter plugins for other Firebase products, see [FlutterFire.md](https://gi ## Usage ### Import the firebase_crashlytics plugin -To use the firebase_crashlytics plugin, follow the [plugin installation instructions](https://pub.dartlang.org/packages/firebase_crashlytics#pub-pkg-tab-installing). + +To use the `firebase_crashlytics` plugin, follow the [plugin installation instructions](https://pub.dartlang.org/packages/firebase_crashlytics#pub-pkg-tab-installing). ### Android integration -Enable the Google services by configuring the Gradle scripts as such. +Enable the Google services by configuring the Gradle scripts as such: -1. Add Fabric repository to the `[project]/android/build.gradle` file. +1. Add the Fabric repository to the `[project]/android/build.gradle` file. ``` repositories { google() @@ -29,7 +30,7 @@ repositories { } ``` -2. Add the classpaths to the `[project]/android/build.gradle` file. +2. Add the following classpaths to the `[project]/android/build.gradle` file. ```gradle dependencies { // Example existing classpath @@ -41,14 +42,14 @@ dependencies { } ``` -2. Add the apply plugins to the `[project]/android/app/build.gradle` file. +2. Apply the following plugins in the `[project]/android/app/build.gradle` file. ```gradle // ADD THIS AT THE BOTTOM apply plugin: 'io.fabric' apply plugin: 'com.google.gms.google-services' ``` -*Note:* If this section is not completed you will get an error like this: +*Note:* If this section is not completed, you will get an error like this: ``` java.lang.IllegalStateException: Default FirebaseApp is not initialized in this process [package name]. @@ -56,18 +57,18 @@ Make sure to call FirebaseApp.initializeApp(Context) first. ``` *Note:* When you are debugging on Android, use a device or AVD with Google Play services. -Otherwise you will not be able to use Firebase Crashlytics. +Otherwise, you will not be able to use Firebase Crashlytics. ### iOS Integration -Add the Crashlytics run scripts +Add the Crashlytics run scripts: -1. From Xcode select Runner from the project navigation. -1. Select the Build Phases tab. -1. Click + Add a new build phase, and select New Run Script Phase. +1. From Xcode select `Runner` from the project navigation. +1. Select the `Build Phases` tab. +1. Click `+ Add a new build phase`, and select `New Run Script Phase`. 1. Add `${PODS_ROOT}/Fabric/run` to the `Type a script...` text box. -1. If on Xcode 10 Add your app's built Info.plist location to the Build Phase's Input Files field. -Eg: `$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)` +1. If you are using Xcode 10, add the location of `Info.plist`, built by your app, to the `Build Phase's Input Files` field. + E.g.: `$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)` ### Use the plugin @@ -85,20 +86,32 @@ void main() { // development. Crashlytics.instance.enableInDevMode = true; - // Pass all uncaught errors to Crashlytics. - FlutterError.onError = (FlutterErrorDetails details) { - Crashlytics.instance.onError(details); - }; + // Pass all uncaught errors from the framework to Crashlytics. + FlutterError.onError = Crashlytics.instance.recordFlutterError; + runApp(MyApp()); } ``` +Overriding `FlutterError.onError` with `Crashlytics.instance.recordFlutterError` will automatically catch all +errors that are thrown from within the Flutter framework. +If you want to catch errors that occur in `runZoned`, +you can supply `Crashlytics.instance.recordError` to the `onError` parameter: +```dart +runZoned>(() async { + // ... + }, onError: Crashlytics.instance.recordError); +``` + ## Result If an error is caught, you should see the following messages in your logs: ``` -flutter: Error caught by Crashlytics plugin: -... +flutter: Flutter error caught by Crashlytics plugin: +// OR if you use recordError for runZoned: +flutter: Error caught by Crashlytics plugin : +// Exception, context, information, and stack trace in debug mode +// OR if not in debug mode: flutter: Error reported to Crashlytics. ``` @@ -107,7 +120,7 @@ flutter: Error reported to Crashlytics. ## Example See the [example application](https://github.com/flutter/plugins/tree/master/packages/firebase_crashlytics/example) source -for a complete sample app using the Firebase Crashlytics. +for a complete sample app using `firebase_crashlytics`. ## Issues and feedback diff --git a/packages/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/firebasecrashlytics/FirebaseCrashlyticsPlugin.java b/packages/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/firebasecrashlytics/FirebaseCrashlyticsPlugin.java index b3b04c6f37c3..0368738c939e 100644 --- a/packages/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/firebasecrashlytics/FirebaseCrashlyticsPlugin.java +++ b/packages/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/firebasecrashlytics/FirebaseCrashlyticsPlugin.java @@ -70,6 +70,15 @@ public void onMethodCall(MethodCall call, Result result) { exception.setStackTrace(elements.toArray(new StackTraceElement[elements.size()])); Crashlytics.setString("exception", (String) call.argument("exception")); + + // Set a "reason" (to match iOS) to show where the exception was thrown. + final String context = call.argument("context"); + if (context != null) Crashlytics.setString("reason", "thrown " + context); + + // Log information. + final String information = call.argument("information"); + if (information != null && !information.isEmpty()) Crashlytics.log(information); + Crashlytics.logException(exception); result.success("Error reported to Crashlytics."); } else if (call.method.equals("Crashlytics#isDebuggable")) { diff --git a/packages/firebase_crashlytics/example/test_driver/crashlytics.dart b/packages/firebase_crashlytics/example/test_driver/crashlytics.dart index 40bb08717189..2cd26d0a1612 100644 --- a/packages/firebase_crashlytics/example/test_driver/crashlytics.dart +++ b/packages/firebase_crashlytics/example/test_driver/crashlytics.dart @@ -24,11 +24,13 @@ void main() { crashlytics.setDouble('testDouble', 42.0); crashlytics.setString('testString', 'bar'); Crashlytics.instance.log('testing'); - await crashlytics.recordFlutterError( - FlutterErrorDetails( + await crashlytics.recordFlutterError(FlutterErrorDetails( exception: 'testing', stack: StackTrace.fromString(''), - ), - ); + context: DiagnosticsNode.message('during testing'), + informationCollector: () => [ + DiagnosticsNode.message('testing'), + DiagnosticsNode.message('information'), + ])); }); } diff --git a/packages/firebase_crashlytics/ios/Classes/FirebaseCrashlyticsPlugin.m b/packages/firebase_crashlytics/ios/Classes/FirebaseCrashlyticsPlugin.m index f84a0abe17f0..862072f756f0 100644 --- a/packages/firebase_crashlytics/ios/Classes/FirebaseCrashlyticsPlugin.m +++ b/packages/firebase_crashlytics/ios/Classes/FirebaseCrashlyticsPlugin.m @@ -59,14 +59,32 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } } + // Add additional information from the Flutter framework to the exception reported in + // Crashlytics. Using CLSLog instead of CLS_LOG to try to avoid the automatic inclusion of the + // line number. It also ensures that the log is only written to Crashlytics and not also to the + // offline log as explained here: + // https://support.crashlytics.com/knowledgebase/articles/92519-how-do-i-use-logging + // Although, that would only happen in debug mode, which this method call is never called in. + NSString *information = call.arguments[@"information"]; + if ([information length] != 0) { + CLSLog(information); + } + // Report crash. NSArray *errorElements = call.arguments[@"stackTraceElements"]; NSMutableArray *frames = [NSMutableArray array]; for (NSDictionary *errorElement in errorElements) { [frames addObject:[self generateFrame:errorElement]]; } + + NSString *context = call.arguments[@"context"]; + NSString *reason; + if (context != nil) { + reason = [NSString stringWithFormat:@"thrown %@", context]; + } + [[Crashlytics sharedInstance] recordCustomExceptionName:call.arguments[@"exception"] - reason:call.arguments[@"context"] + reason:reason frameArray:frames]; result(@"Error reported to Crashlytics."); } else if ([@"Crashlytics#isDebuggable" isEqualToString:call.method]) { diff --git a/packages/firebase_crashlytics/lib/src/firebase_crashlytics.dart b/packages/firebase_crashlytics/lib/src/firebase_crashlytics.dart index 1409c50f8bfa..23ed4ecea288 100644 --- a/packages/firebase_crashlytics/lib/src/firebase_crashlytics.dart +++ b/packages/firebase_crashlytics/lib/src/firebase_crashlytics.dart @@ -30,7 +30,10 @@ class Crashlytics { print('Flutter error caught by Crashlytics plugin:'); _recordError(details.exceptionAsString(), details.stack, - context: details.context); + context: details.context, + information: details.informationCollector == null + ? null + : details.informationCollector()); } /// Submits a report of a non-fatal error. @@ -165,6 +168,12 @@ class Crashlytics { 'line': lineNumber, }; + // The next section would throw an exception in some cases if there was no stop here. + if (lineParts.length < 3) { + elements.add(element); + continue; + } + if (lineParts[2].contains(".")) { final String className = lineParts[2].substring(0, lineParts[2].indexOf(".")).trim(); @@ -185,29 +194,65 @@ class Crashlytics { return elements; } + // On top of the default exception components, [information] can be passed as well. + // This allows the developer to get a better understanding of exceptions thrown + // by the Flutter framework. [FlutterErrorDetails] often explain why an exception + // occurred and give useful background information in [FlutterErrorDetails.informationCollector]. + // Crashlytics will log this information in addition to the stack trace. + // If [information] is `null` or empty, it will be ignored. Future _recordError(dynamic exception, StackTrace stack, - {dynamic context}) async { + {dynamic context, Iterable information}) async { bool inDebugMode = false; if (!enableInDevMode) { assert(inDebugMode = true); } + final String _information = (information == null || information.isEmpty) + ? '' + : (StringBuffer()..writeAll(information, '\n')).toString(); + if (inDebugMode && !enableInDevMode) { - print(Trace.format(stack)); + // If available, give context to the exception. + if (context != null) + print('The following exception was thrown $context:'); + + // Need to print the exception to explain why the exception was thrown. + print(exception); + + // Print information provided by the Flutter framework about the exception. + if (_information.isNotEmpty) print('\n$_information'); + + // Not using Trace.format here to stick to the default stack trace format + // that Flutter developers are used to seeing. + if (stack != null) print('\n$stack'); } else { - // Report error + // The stack trace can be null. To avoid the following exception: + // Invalid argument(s): Cannot create a Trace from null. + // To avoid that exception, we can check for null and provide an empty stack trace. + stack ??= StackTrace.fromString(''); + + // Report error. final List stackTraceLines = Trace.format(stack).trimRight().split('\n'); final List> stackTraceElements = getStackTraceElements(stackTraceLines); - await channel - .invokeMethod('Crashlytics#onError', { + + // The context is a string that "should be in a form that will make sense in + // English when following the word 'thrown'" according to the documentation for + // [FlutterErrorDetails.context]. It is displayed to the user on Crashlytics + // as the "reason", which is forced by iOS, with the "thrown" prefix added. + final String result = await channel + .invokeMethod('Crashlytics#onError', { 'exception': "${exception.toString()}", 'context': '$context', + 'information': _information, 'stackTraceElements': stackTraceElements, 'logs': _logs.toList(), 'keys': _prepareKeys(), }); + + // Print result. + print(result); } } } diff --git a/packages/firebase_crashlytics/pubspec.yaml b/packages/firebase_crashlytics/pubspec.yaml index d2d3d854c2a1..5b1542554213 100644 --- a/packages/firebase_crashlytics/pubspec.yaml +++ b/packages/firebase_crashlytics/pubspec.yaml @@ -2,7 +2,7 @@ name: firebase_crashlytics description: Flutter plugin for Firebase Crashlytics. It reports uncaught errors to the Firebase console. -version: 0.1.0 +version: 0.1.0+2 author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/firebase_crashlytics diff --git a/packages/firebase_crashlytics/test/firebase_crashlytics_test.dart b/packages/firebase_crashlytics/test/firebase_crashlytics_test.dart index 5fef5850814d..da6dafffb1e2 100644 --- a/packages/firebase_crashlytics/test/firebase_crashlytics_test.dart +++ b/packages/firebase_crashlytics/test/firebase_crashlytics_test.dart @@ -16,6 +16,8 @@ void main() { .setMockMethodCallHandler((MethodCall methodCall) async { log.add(methodCall); switch (methodCall.method) { + case 'Crashlytics#onError': + return 'Error reported to Crashlytics.'; case 'Crashlytics#isDebuggable': return true; case 'Crashlytics#setUserEmail': @@ -38,6 +40,10 @@ void main() { exception: 'foo exception', stack: StackTrace.current, library: 'foo library', + informationCollector: () => [ + DiagnosticsNode.message('test message'), + DiagnosticsNode.message('second message'), + ], context: ErrorDescription('foo context'), ); crashlytics.enableInDevMode = true; @@ -50,6 +56,7 @@ void main() { expect(log[0].method, 'Crashlytics#onError'); expect(log[0].arguments['exception'], 'foo exception'); expect(log[0].arguments['context'], 'foo context'); + expect(log[0].arguments['information'], 'test message\nsecond message'); expect(log[0].arguments['logs'], isNotEmpty); expect(log[0].arguments['logs'], contains('foo')); expect(log[0].arguments['keys'][0]['key'], 'testBool'); diff --git a/packages/firebase_in_app_messaging/CHANGELOG.md b/packages/firebase_in_app_messaging/CHANGELOG.md index 90e5ad1611f2..12a046c09fbf 100644 --- a/packages/firebase_in_app_messaging/CHANGELOG.md +++ b/packages/firebase_in_app_messaging/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.0.1+3 + +* Update AGP, gradle and inappmessaging-display versions on Android. + +## 0.0.1+2 + +* Remove dependency `androidx.annotation:annotation:1.0.0`. + ## 0.0.1+1 * Update google-services Android gradle plugin to 4.3.0 in documentation and examples. diff --git a/packages/firebase_in_app_messaging/android/build.gradle b/packages/firebase_in_app_messaging/android/build.gradle index bcafa7898e5c..280a86f48056 100644 --- a/packages/firebase_in_app_messaging/android/build.gradle +++ b/packages/firebase_in_app_messaging/android/build.gradle @@ -8,7 +8,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:3.4.2' } } @@ -32,7 +32,6 @@ android { disable 'InvalidPackage' } dependencies { - api 'com.google.firebase:firebase-inappmessaging-display:18.0.1' - implementation 'androidx.annotation:annotation:1.0.0' + api 'com.google.firebase:firebase-inappmessaging-display:18.0.2' } } diff --git a/packages/firebase_in_app_messaging/example/android/app/build.gradle b/packages/firebase_in_app_messaging/example/android/app/build.gradle index 2ea74b83e3e0..ce9e3c1143fb 100644 --- a/packages/firebase_in_app_messaging/example/android/app/build.gradle +++ b/packages/firebase_in_app_messaging/example/android/app/build.gradle @@ -56,8 +56,8 @@ flutter { dependencies { testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' } apply plugin: 'com.google.gms.google-services' diff --git a/packages/firebase_in_app_messaging/example/android/build.gradle b/packages/firebase_in_app_messaging/example/android/build.gradle index 647e14dce1a0..f373ec6839c9 100644 --- a/packages/firebase_in_app_messaging/example/android/build.gradle +++ b/packages/firebase_in_app_messaging/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:3.4.2' classpath 'com.google.gms:google-services:4.3.0' } diff --git a/packages/firebase_in_app_messaging/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/firebase_in_app_messaging/example/android/gradle/wrapper/gradle-wrapper.properties index 2819f022f1fd..659814bcc887 100644 --- a/packages/firebase_in_app_messaging/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/firebase_in_app_messaging/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Jun 23 08:50:38 CEST 2017 +#Mon Aug 05 20:16:36 BRT 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip diff --git a/packages/firebase_in_app_messaging/example/pubspec.yaml b/packages/firebase_in_app_messaging/example/pubspec.yaml index dc1b1fc5adb2..a2acd35b132f 100644 --- a/packages/firebase_in_app_messaging/example/pubspec.yaml +++ b/packages/firebase_in_app_messaging/example/pubspec.yaml @@ -19,8 +19,8 @@ dev_dependencies: firebase_in_app_messaging: path: ../ - firebase_core: ^0.4.0 - firebase_analytics: ^3.0.3 + firebase_core: ^0.4.0+8 + firebase_analytics: ^4.0.2 flutter: uses-material-design: true diff --git a/packages/firebase_in_app_messaging/pubspec.yaml b/packages/firebase_in_app_messaging/pubspec.yaml index 6f47318d4d2f..e74b6405ad8d 100644 --- a/packages/firebase_in_app_messaging/pubspec.yaml +++ b/packages/firebase_in_app_messaging/pubspec.yaml @@ -1,6 +1,6 @@ name: firebase_in_app_messaging description: Flutter plugin for Firebase In-App Messaging. -version: 0.0.1+1 +version: 0.0.1+3 author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/firebase_in_app_messaging diff --git a/packages/firebase_storage/CHANGELOG.md b/packages/firebase_storage/CHANGELOG.md index d9fbd1d1fe6c..86b8003b2fea 100644 --- a/packages/firebase_storage/CHANGELOG.md +++ b/packages/firebase_storage/CHANGELOG.md @@ -1,3 +1,9 @@ +## 3.0.5 +* Removed automatic print statements for `StorageTaskEvent`'s. + If you want to see the event status in your logs now, you will have to use the following: + `storageReference.put{File/Data}(..).events.listen((event) => print('EVENT ${event.type}'));` +* Updated `README.md` to explain the above. + ## 3.0.4 * Update google-services Android gradle plugin to 4.3.0 in documentation and examples. diff --git a/packages/firebase_storage/README.md b/packages/firebase_storage/README.md index 370f50abda11..70aea955cd52 100755 --- a/packages/firebase_storage/README.md +++ b/packages/firebase_storage/README.md @@ -9,8 +9,34 @@ For Flutter plugins for other Firebase products, see [FlutterFire.md](https://gi *Note*: This plugin is still under development, and some APIs might not be available yet. [Feedback](https://github.com/flutter/flutter/issues) and [Pull Requests](https://github.com/flutter/plugins/pulls) are most welcome! ## Usage + To use this plugin, add `firebase_storage` as a [dependency in your pubspec.yaml file](https://flutter.io/platform-plugins/). +### Logging + +If you wish to see status events for your upload tasks in your logs, you should listen to the `StorageUploadTask.events` stream. +This could look like the following if you are using `StorageReference.putData`: + +```dart +final StorageReference storageReference = FirebaseStorage().ref().child(path); + +final StorageUploadTask uploadTask = storageReference.putData(data); + +final StreamSubscription streamSubscription = uploadTask.events.listen((event) { + // You can use this to notify yourself or your user in any kind of way. + // For example: you could use the uploadTask.events stream in a StreamBuilder instead + // to show your user what the current status is. In that case, you would not need to cancel any + // subscription as StreamBuilder handles this automatically. + + // Here, every StorageTaskEvent concerning the upload is printed to the logs. + print('EVENT ${event.type}'); +}); + +// Cancel your subscription when done. +await uploadTask.onComplete; +streamSubscription.cancel(); +``` + ## Getting Started See the `example` directory for a complete sample app using Firebase Cloud Storage. diff --git a/packages/firebase_storage/lib/src/upload_task.dart b/packages/firebase_storage/lib/src/upload_task.dart index 9c263779e7e1..70d4f9ebcc61 100644 --- a/packages/firebase_storage/lib/src/upload_task.dart +++ b/packages/firebase_storage/lib/src/upload_task.dart @@ -56,7 +56,6 @@ abstract class StorageUploadTask { void _changeState(StorageTaskEvent event) { _resetState(); - print('EVENT ${event.type}'); switch (event.type) { case StorageTaskEventType.progress: isInProgress = true; diff --git a/packages/firebase_storage/pubspec.yaml b/packages/firebase_storage/pubspec.yaml index c479081607f1..cffac66c8a1b 100755 --- a/packages/firebase_storage/pubspec.yaml +++ b/packages/firebase_storage/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Firebase Cloud Storage, a powerful, simple, and cost-effective object storage service for Android and iOS. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/firebase_storage -version: 3.0.4 +version: 3.0.5 flutter: plugin: diff --git a/packages/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/CHANGELOG.md index ba8774ce450a..aecdc28a1541 100644 --- a/packages/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.20+1 + +* Android: Unregister ActivityLifecycleCallbacks on activity destroy (fixes a memory leak). + ## 0.5.20 * Add map toolbar support diff --git a/packages/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapsPlugin.java b/packages/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapsPlugin.java index aced16772d50..b27fea425ba5 100644 --- a/packages/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapsPlugin.java +++ b/packages/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapsPlugin.java @@ -88,6 +88,7 @@ public void onActivityDestroyed(Activity activity) { if (activity.hashCode() != registrarActivityHashCode) { return; } + activity.getApplication().unregisterActivityLifecycleCallbacks(this); state.set(DESTROYED); } diff --git a/packages/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/pubspec.yaml index 4ca1734202af..7b595046e1a3 100644 --- a/packages/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter -version: 0.5.20 +version: 0.5.20+1 dependencies: flutter: diff --git a/packages/google_sign_in/CHANGELOG.md b/packages/google_sign_in/CHANGELOG.md index 8dcaa3a61f55..13aafeea58af 100644 --- a/packages/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.0.6 + +* Fixed the `PlatformException` leaking from `catchError()` in debug mode. + ## 4.0.5 * Update README with solution to `APIException` errors. @@ -6,7 +10,7 @@ * Revert changes in 4.0.3. -## 4.0.3 +## 4.0.3 * Update guava to `27.0.1-android`. * Add correct @NonNull annotations to reduce compiler warnings. diff --git a/packages/google_sign_in/lib/google_sign_in.dart b/packages/google_sign_in/lib/google_sign_in.dart index 740188fd8767..ca6e6bbaf705 100755 --- a/packages/google_sign_in/lib/google_sign_in.dart +++ b/packages/google_sign_in/lib/google_sign_in.dart @@ -342,9 +342,13 @@ class _MethodCompleter { final Completer _completer = Completer(); - void complete(FutureOr value) { + Future complete(FutureOr value) async { if (value is Future) { - value.then(_completer.complete, onError: _completer.completeError); + try { + _completer.complete(await value); + } catch (e, stacktrace) { + _completer.completeError(e, stacktrace); + } } else { _completer.complete(value); } diff --git a/packages/google_sign_in/pubspec.yaml b/packages/google_sign_in/pubspec.yaml index d337bd1fc9e9..ee5ad7e3f861 100755 --- a/packages/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/google_sign_in -version: 4.0.5 +version: 4.0.6 flutter: plugin: diff --git a/packages/image_picker/CHANGELOG.md b/packages/image_picker/CHANGELOG.md index 44c8cc9dd01e..c3b5a2a02c00 100644 --- a/packages/image_picker/CHANGELOG.md +++ b/packages/image_picker/CHANGELOG.md @@ -1,3 +1,24 @@ +## 0.6.1+1 + +* Add dependency on `androidx.annotation:annotation:1.0.0`. + +## 0.6.1 + +* New feature : Get images with custom quality. While picking images, user can pass `imageQuality` +parameter to compress image. + +## 0.6.0+20 + +* Android: Migrated information cache methods to use instance methods. + +## 0.6.0+19 + +* Android: Fix memory leak due not unregistering ActivityLifecycleCallbacks. + +## 0.6.0+18 + +* Fix video play in example and update video_player plugin dependency. + ## 0.6.0+17 * iOS: Fix a crash when user captures image from the camera with devices under iOS 11. diff --git a/packages/image_picker/android/build.gradle b/packages/image_picker/android/build.gradle index 7de0adc60eb9..d234a8fef294 100755 --- a/packages/image_picker/android/build.gradle +++ b/packages/image_picker/android/build.gradle @@ -47,8 +47,8 @@ android { lintOptions { disable 'InvalidPackage' } -} - -dependencies { - api 'androidx.legacy:legacy-support-v4:1.0.0' + dependencies { + api 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.annotation:annotation:1.0.0' + } } diff --git a/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerCache.java b/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerCache.java index 0f4e97681a1a..6e5121f10200 100644 --- a/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerCache.java +++ b/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerCache.java @@ -4,7 +4,6 @@ package io.flutter.plugins.imagepicker; -import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; import android.net.Uri; @@ -18,6 +17,7 @@ class ImagePickerCache { static final String MAP_KEY_PATH = "path"; static final String MAP_KEY_MAX_WIDTH = "maxWidth"; static final String MAP_KEY_MAX_HEIGHT = "maxHeight"; + static final String MAP_KEY_IMAGE_QUALITY = "imageQuality"; private static final String MAP_KEY_TYPE = "type"; private static final String MAP_KEY_ERROR_CODE = "errorCode"; private static final String MAP_KEY_ERROR_MESSAGE = "errorMessage"; @@ -29,21 +29,20 @@ class ImagePickerCache { "flutter_image_picker_error_message"; private static final String SHARED_PREFERENCE_MAX_WIDTH_KEY = "flutter_image_picker_max_width"; private static final String SHARED_PREFERENCE_MAX_HEIGHT_KEY = "flutter_image_picker_max_height"; + private static final String SHARED_PREFERENCE_IMAGE_QUALITY_KEY = + "flutter_image_picker_image_quality"; private static final String SHARED_PREFERENCE_TYPE_KEY = "flutter_image_picker_type"; private static final String SHARED_PREFERENCE_PENDING_IMAGE_URI_PATH_KEY = "flutter_image_picker_pending_image_uri"; private static final String SHARED_PREFERENCES_NAME = "flutter_image_picker_shared_preference"; - private static SharedPreferences getFilePref; + private SharedPreferences prefs; - static void setUpWithActivity(Activity activity) { - getFilePref = - activity - .getApplicationContext() - .getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); + ImagePickerCache(Context context) { + prefs = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); } - static void saveTypeWithMethodCallName(String methodCallName) { + void saveTypeWithMethodCallName(String methodCallName) { if (methodCallName.equals(ImagePickerPlugin.METHOD_CALL_IMAGE)) { setType("image"); } else if (methodCallName.equals(ImagePickerPlugin.METHOD_CALL_VIDEO)) { @@ -51,57 +50,51 @@ static void saveTypeWithMethodCallName(String methodCallName) { } } - private static void setType(String type) { - if (getFilePref == null) { - return; - } - getFilePref.edit().putString(SHARED_PREFERENCE_TYPE_KEY, type).apply(); - } + private void setType(String type) { - static void saveDemensionWithMethodCall(MethodCall methodCall) { - Double maxWidth = methodCall.argument("maxWidth"); - Double maxHeight = methodCall.argument("maxHeight"); - setMaxDimension(maxWidth, maxHeight); + prefs.edit().putString(SHARED_PREFERENCE_TYPE_KEY, type).apply(); } - private static void setMaxDimension(Double maxWidth, Double maxHeight) { - if (getFilePref == null) { - return; - } + void saveDimensionWithMethodCall(MethodCall methodCall) { + Double maxWidth = methodCall.argument(MAP_KEY_MAX_WIDTH); + Double maxHeight = methodCall.argument(MAP_KEY_MAX_HEIGHT); + int imageQuality = + methodCall.argument(MAP_KEY_IMAGE_QUALITY) == null + ? 100 + : (int) methodCall.argument(MAP_KEY_IMAGE_QUALITY); + + setMaxDimension(maxWidth, maxHeight, imageQuality); + } - SharedPreferences.Editor editor = getFilePref.edit(); + private void setMaxDimension(Double maxWidth, Double maxHeight, int imageQuality) { + SharedPreferences.Editor editor = prefs.edit(); if (maxWidth != null) { editor.putLong(SHARED_PREFERENCE_MAX_WIDTH_KEY, Double.doubleToRawLongBits(maxWidth)); } if (maxHeight != null) { editor.putLong(SHARED_PREFERENCE_MAX_HEIGHT_KEY, Double.doubleToRawLongBits(maxHeight)); } + if (imageQuality > -1 && imageQuality < 101) { + editor.putInt(SHARED_PREFERENCE_IMAGE_QUALITY_KEY, imageQuality); + } else { + editor.putInt(SHARED_PREFERENCE_IMAGE_QUALITY_KEY, 100); + } editor.apply(); } - static void savePendingCameraMediaUriPath(Uri uri) { - if (getFilePref == null) { - return; - } - getFilePref - .edit() - .putString(SHARED_PREFERENCE_PENDING_IMAGE_URI_PATH_KEY, uri.getPath()) - .apply(); + void savePendingCameraMediaUriPath(Uri uri) { + prefs.edit().putString(SHARED_PREFERENCE_PENDING_IMAGE_URI_PATH_KEY, uri.getPath()).apply(); } - static String retrievePendingCameraMediaUriPath() { - if (getFilePref == null) { - return null; - } - return getFilePref.getString(SHARED_PREFERENCE_PENDING_IMAGE_URI_PATH_KEY, ""); + String retrievePendingCameraMediaUriPath() { + + return prefs.getString(SHARED_PREFERENCE_PENDING_IMAGE_URI_PATH_KEY, ""); } - static void saveResult( + void saveResult( @Nullable String path, @Nullable String errorCode, @Nullable String errorMessage) { - if (getFilePref == null) { - return; - } - SharedPreferences.Editor editor = getFilePref.edit(); + + SharedPreferences.Editor editor = prefs.edit(); if (path != null) { editor.putString(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY, path); } @@ -114,50 +107,49 @@ static void saveResult( editor.apply(); } - static void clear() { - if (getFilePref == null) { - return; - } - getFilePref.edit().clear().apply(); + void clear() { + prefs.edit().clear().apply(); } - static Map getCacheMap() { - if (getFilePref == null) { - return new HashMap<>(); - } + Map getCacheMap() { + Map resultMap = new HashMap<>(); - Boolean hasData = false; + boolean hasData = false; - if (getFilePref.contains(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY)) { - resultMap.put(MAP_KEY_PATH, getFilePref.getString(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY, "")); + if (prefs.contains(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY)) { + final String imagePathValue = prefs.getString(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY, ""); + resultMap.put(MAP_KEY_PATH, imagePathValue); hasData = true; } - if (getFilePref.contains(SHARED_PREFERENCE_ERROR_CODE_KEY)) { - resultMap.put( - MAP_KEY_ERROR_CODE, getFilePref.getString(SHARED_PREFERENCE_ERROR_CODE_KEY, "")); + if (prefs.contains(SHARED_PREFERENCE_ERROR_CODE_KEY)) { + final String errorCodeValue = prefs.getString(SHARED_PREFERENCE_ERROR_CODE_KEY, ""); + resultMap.put(MAP_KEY_ERROR_CODE, errorCodeValue); hasData = true; - if (getFilePref.contains(SHARED_PREFERENCE_ERROR_MESSAGE_KEY)) { - resultMap.put( - MAP_KEY_ERROR_MESSAGE, getFilePref.getString(SHARED_PREFERENCE_ERROR_MESSAGE_KEY, "")); + if (prefs.contains(SHARED_PREFERENCE_ERROR_MESSAGE_KEY)) { + final String errorMessageValue = prefs.getString(SHARED_PREFERENCE_ERROR_MESSAGE_KEY, ""); + resultMap.put(MAP_KEY_ERROR_MESSAGE, errorMessageValue); } } if (hasData) { - if (getFilePref.contains(SHARED_PREFERENCE_TYPE_KEY)) { - resultMap.put(MAP_KEY_TYPE, getFilePref.getString(SHARED_PREFERENCE_TYPE_KEY, "")); + if (prefs.contains(SHARED_PREFERENCE_TYPE_KEY)) { + final String typeValue = prefs.getString(SHARED_PREFERENCE_TYPE_KEY, ""); + resultMap.put(MAP_KEY_TYPE, typeValue); } - - if (getFilePref.contains(SHARED_PREFERENCE_MAX_WIDTH_KEY)) { - resultMap.put( - MAP_KEY_MAX_WIDTH, - Double.longBitsToDouble(getFilePref.getLong(SHARED_PREFERENCE_MAX_WIDTH_KEY, 0))); + if (prefs.contains(SHARED_PREFERENCE_MAX_WIDTH_KEY)) { + final long maxWidthValue = prefs.getLong(SHARED_PREFERENCE_MAX_WIDTH_KEY, 0); + resultMap.put(MAP_KEY_MAX_WIDTH, Double.longBitsToDouble(maxWidthValue)); } - - if (getFilePref.contains(SHARED_PREFERENCE_MAX_HEIGHT_KEY)) { - resultMap.put( - MAP_KEY_MAX_HEIGHT, - Double.longBitsToDouble(getFilePref.getLong(SHARED_PREFERENCE_MAX_HEIGHT_KEY, 0))); + if (prefs.contains(SHARED_PREFERENCE_MAX_HEIGHT_KEY)) { + final long maxHeightValue = prefs.getLong(SHARED_PREFERENCE_MAX_HEIGHT_KEY, 0); + resultMap.put(MAP_KEY_MAX_HEIGHT, Double.longBitsToDouble(maxHeightValue)); + } + if (prefs.contains(SHARED_PREFERENCE_IMAGE_QUALITY_KEY)) { + final int imageQuality = prefs.getInt(SHARED_PREFERENCE_IMAGE_QUALITY_KEY, 100); + resultMap.put(MAP_KEY_MAX_HEIGHT, imageQuality); + } else { + resultMap.put(MAP_KEY_MAX_HEIGHT, 100); } } diff --git a/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java b/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java index 2f2522f53c5c..a568eca4dc22 100644 --- a/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java +++ b/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java @@ -80,6 +80,7 @@ public class ImagePickerDelegate private final Activity activity; private final File externalFilesDirectory; private final ImageResizer imageResizer; + private final ImagePickerCache cache; private final PermissionManager permissionManager; private final IntentResolver intentResolver; private final FileUriResolver fileUriResolver; @@ -112,13 +113,17 @@ interface OnPathReadyListener { private MethodCall methodCall; public ImagePickerDelegate( - final Activity activity, File externalFilesDirectory, ImageResizer imageResizer) { + final Activity activity, + final File externalFilesDirectory, + final ImageResizer imageResizer, + final ImagePickerCache cache) { this( activity, externalFilesDirectory, imageResizer, null, null, + cache, new PermissionManager() { @Override public boolean isPermissionGranted(String permissionName) { @@ -171,15 +176,16 @@ public void onScanCompleted(String path, Uri uri) { */ @VisibleForTesting ImagePickerDelegate( - Activity activity, - File externalFilesDirectory, - ImageResizer imageResizer, - MethodChannel.Result result, - MethodCall methodCall, - PermissionManager permissionManager, - IntentResolver intentResolver, - FileUriResolver fileUriResolver, - FileUtils fileUtils) { + final Activity activity, + final File externalFilesDirectory, + final ImageResizer imageResizer, + final MethodChannel.Result result, + final MethodCall methodCall, + final ImagePickerCache cache, + final PermissionManager permissionManager, + final IntentResolver intentResolver, + final FileUriResolver fileUriResolver, + final FileUtils fileUtils) { this.activity = activity; this.externalFilesDirectory = externalFilesDirectory; this.imageResizer = imageResizer; @@ -190,6 +196,7 @@ public void onScanCompleted(String path, Uri uri) { this.intentResolver = intentResolver; this.fileUriResolver = fileUriResolver; this.fileUtils = fileUtils; + this.cache = cache; } void saveStateBeforeResult() { @@ -197,28 +204,33 @@ void saveStateBeforeResult() { return; } - ImagePickerCache.saveTypeWithMethodCallName(methodCall.method); - ImagePickerCache.saveDemensionWithMethodCall(methodCall); + cache.saveTypeWithMethodCallName(methodCall.method); + cache.saveDimensionWithMethodCall(methodCall); if (pendingCameraMediaUri != null) { - ImagePickerCache.savePendingCameraMediaUriPath(pendingCameraMediaUri); + cache.savePendingCameraMediaUriPath(pendingCameraMediaUri); } } void retrieveLostImage(MethodChannel.Result result) { - Map resultMap = ImagePickerCache.getCacheMap(); - String path = (String) resultMap.get(ImagePickerCache.MAP_KEY_PATH); + Map resultMap = cache.getCacheMap(); + String path = (String) resultMap.get(cache.MAP_KEY_PATH); if (path != null) { - Double maxWidth = (Double) resultMap.get(ImagePickerCache.MAP_KEY_MAX_WIDTH); - Double maxHeight = (Double) resultMap.get(ImagePickerCache.MAP_KEY_MAX_HEIGHT); - String newPath = imageResizer.resizeImageIfNeeded(path, maxWidth, maxHeight); - resultMap.put(ImagePickerCache.MAP_KEY_PATH, newPath); + Double maxWidth = (Double) resultMap.get(cache.MAP_KEY_MAX_WIDTH); + Double maxHeight = (Double) resultMap.get(cache.MAP_KEY_MAX_HEIGHT); + int imageQuality = + resultMap.get(cache.MAP_KEY_IMAGE_QUALITY) == null + ? 100 + : (int) resultMap.get(cache.MAP_KEY_IMAGE_QUALITY); + + String newPath = imageResizer.resizeImageIfNeeded(path, maxWidth, maxHeight, imageQuality); + resultMap.put(cache.MAP_KEY_PATH, newPath); } if (resultMap.isEmpty()) { result.success(null); } else { result.success(resultMap); } - ImagePickerCache.clear(); + cache.clear(); } public void chooseVideoFromGallery(MethodCall methodCall, MethodChannel.Result result) { @@ -472,7 +484,7 @@ private void handleCaptureImageResult(int resultCode) { fileUriResolver.getFullImagePath( pendingCameraMediaUri != null ? pendingCameraMediaUri - : Uri.parse(ImagePickerCache.retrievePendingCameraMediaUriPath()), + : Uri.parse(cache.retrievePendingCameraMediaUriPath()), new OnPathReadyListener() { @Override public void onPathReady(String path) { @@ -491,7 +503,7 @@ private void handleCaptureVideoResult(int resultCode) { fileUriResolver.getFullImagePath( pendingCameraMediaUri != null ? pendingCameraMediaUri - : Uri.parse(ImagePickerCache.retrievePendingCameraMediaUriPath()), + : Uri.parse(cache.retrievePendingCameraMediaUriPath()), new OnPathReadyListener() { @Override public void onPathReady(String path) { @@ -509,12 +521,18 @@ private void handleImageResult(String path, boolean shouldDeleteOriginalIfScaled if (methodCall != null) { Double maxWidth = methodCall.argument("maxWidth"); Double maxHeight = methodCall.argument("maxHeight"); - String finalImagePath = imageResizer.resizeImageIfNeeded(path, maxWidth, maxHeight); + int imageQuality = + methodCall.argument("imageQuality") == null + ? 100 + : (int) methodCall.argument("imageQuality"); + + String finalImagePath = + imageResizer.resizeImageIfNeeded(path, maxWidth, maxHeight, imageQuality); finishWithSuccess(finalImagePath); //delete original file if scaled - if (!finalImagePath.equals(path) && shouldDeleteOriginalIfScaled) { + if (finalImagePath != null && !finalImagePath.equals(path) && shouldDeleteOriginalIfScaled) { new File(path).delete(); } } else { @@ -536,14 +554,14 @@ private boolean setPendingMethodCallAndResult( pendingResult = result; // Clean up cache if a new image picker is launched. - ImagePickerCache.clear(); + cache.clear(); return true; } private void finishWithSuccess(String imagePath) { if (pendingResult == null) { - ImagePickerCache.saveResult(imagePath, null, null); + cache.saveResult(imagePath, null, null); return; } pendingResult.success(imagePath); @@ -556,7 +574,7 @@ private void finishWithAlreadyActiveError(MethodChannel.Result result) { private void finishWithError(String errorCode, String errorMessage) { if (pendingResult == null) { - ImagePickerCache.saveResult(null, errorCode, errorMessage); + cache.saveResult(null, errorCode, errorMessage); return; } pendingResult.error(errorCode, errorMessage, null); diff --git a/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java b/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java index 371a258966c0..1276963623a0 100644 --- a/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java +++ b/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java @@ -37,7 +37,7 @@ public static void registerWith(PluginRegistry.Registrar registrar) { // we stop the registering process immediately because the ImagePicker requires an activity. return; } - ImagePickerCache.setUpWithActivity(registrar.activity()); + final ImagePickerCache cache = new ImagePickerCache(registrar.activity()); final MethodChannel channel = new MethodChannel(registrar.messenger(), CHANNEL); @@ -46,7 +46,7 @@ public static void registerWith(PluginRegistry.Registrar registrar) { final ExifDataCopier exifDataCopier = new ExifDataCopier(); final ImageResizer imageResizer = new ImageResizer(externalFilesDirectory, exifDataCopier); final ImagePickerDelegate delegate = - new ImagePickerDelegate(registrar.activity(), externalFilesDirectory, imageResizer); + new ImagePickerDelegate(registrar.activity(), externalFilesDirectory, imageResizer, cache); registrar.addActivityResultListener(delegate); registrar.addRequestPermissionsResultListener(delegate); @@ -80,18 +80,18 @@ public void onActivitySaveInstanceState(Activity activity, Bundle outState) { } @Override - public void onActivityDestroyed(Activity activity) {} + public void onActivityDestroyed(Activity activity) { + if (activity == registrar.activity()) { + ((Application) registrar.context()).unregisterActivityLifecycleCallbacks(this); + } + } @Override public void onActivityStopped(Activity activity) {} }; - if (this.registrar != null - && this.registrar.activity() != null - && this.registrar.activity().getApplication() != null) { - this.registrar - .activity() - .getApplication() + if (this.registrar != null) { + ((Application) this.registrar.context()) .registerActivityLifecycleCallbacks(this.activityLifecycleCallbacks); } } diff --git a/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImageResizer.java b/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImageResizer.java index f9bd3adb2e36..363bfec8281b 100644 --- a/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImageResizer.java +++ b/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImageResizer.java @@ -6,6 +6,7 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.util.Log; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; @@ -26,15 +27,17 @@ class ImageResizer { * *

If no resizing is needed, returns the path for the original image. */ - String resizeImageIfNeeded(String imagePath, Double maxWidth, Double maxHeight) { - boolean shouldScale = maxWidth != null || maxHeight != null; + String resizeImageIfNeeded( + String imagePath, Double maxWidth, Double maxHeight, int imageQuality) { + boolean shouldScale = + maxWidth != null || maxHeight != null || (imageQuality > -1 && imageQuality < 101); if (!shouldScale) { return imagePath; } try { - File scaledImage = resizedImage(imagePath, maxWidth, maxHeight); + File scaledImage = resizedImage(imagePath, maxWidth, maxHeight, imageQuality); exifDataCopier.copyExif(imagePath, scaledImage.getPath()); return scaledImage.getPath(); @@ -43,11 +46,16 @@ String resizeImageIfNeeded(String imagePath, Double maxWidth, Double maxHeight) } } - private File resizedImage(String path, Double maxWidth, Double maxHeight) throws IOException { + private File resizedImage(String path, Double maxWidth, Double maxHeight, int imageQuality) + throws IOException { Bitmap bmp = BitmapFactory.decodeFile(path); double originalWidth = bmp.getWidth() * 1.0; double originalHeight = bmp.getHeight() * 1.0; + if (imageQuality < 0 || imageQuality > 100) { + imageQuality = 100; + } + boolean hasMaxWidth = maxWidth != null; boolean hasMaxHeight = maxHeight != null; @@ -86,8 +94,15 @@ private File resizedImage(String path, Double maxWidth, Double maxHeight) throws Bitmap scaledBmp = Bitmap.createScaledBitmap(bmp, width.intValue(), height.intValue(), false); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); boolean saveAsPNG = bmp.hasAlpha(); + if (saveAsPNG) { + Log.d( + "ImageResizer", + "image_picker: compressing is not supported for type PNG. Returning the image with original quality"); + } scaledBmp.compress( - saveAsPNG ? Bitmap.CompressFormat.PNG : Bitmap.CompressFormat.JPEG, 100, outputStream); + saveAsPNG ? Bitmap.CompressFormat.PNG : Bitmap.CompressFormat.JPEG, + imageQuality, + outputStream); String[] pathParts = path.split("/"); String imageName = pathParts[pathParts.length - 1]; @@ -96,7 +111,6 @@ private File resizedImage(String path, Double maxWidth, Double maxHeight) throws FileOutputStream fileOutput = new FileOutputStream(imageFile); fileOutput.write(outputStream.toByteArray()); fileOutput.close(); - return imageFile; } } diff --git a/packages/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java index 02bb91b7914f..60e1167cd87a 100644 --- a/packages/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java +++ b/packages/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -26,6 +26,7 @@ public class ImagePickerDelegateTest { private static final double WIDTH = 10.0; private static final double HEIGHT = 10.0; + private static final int IMAGE_QUALITY = 100; @Mock Activity mockActivity; @Mock ImageResizer mockImageResizer; @@ -35,6 +36,7 @@ public class ImagePickerDelegateTest { @Mock ImagePickerDelegate.IntentResolver mockIntentResolver; @Mock FileUtils mockFileUtils; @Mock Intent mockIntent; + @Mock ImagePickerCache cache; ImagePickerDelegate.FileUriResolver mockFileUriResolver; @@ -60,12 +62,13 @@ public void setUp() { when(mockFileUtils.getPathFromUri(any(Context.class), any(Uri.class))) .thenReturn("pathFromUri"); - when(mockImageResizer.resizeImageIfNeeded("pathFromUri", null, null)) + when(mockImageResizer.resizeImageIfNeeded("pathFromUri", null, null, IMAGE_QUALITY)) .thenReturn("originalPath"); - when(mockImageResizer.resizeImageIfNeeded("pathFromUri", WIDTH, HEIGHT)) + when(mockImageResizer.resizeImageIfNeeded("pathFromUri", WIDTH, HEIGHT, IMAGE_QUALITY)) .thenReturn("scaledPath"); - when(mockImageResizer.resizeImageIfNeeded("pathFromUri", WIDTH, null)).thenReturn("scaledPath"); - when(mockImageResizer.resizeImageIfNeeded("pathFromUri", null, HEIGHT)) + when(mockImageResizer.resizeImageIfNeeded("pathFromUri", WIDTH, null, IMAGE_QUALITY)) + .thenReturn("scaledPath"); + when(mockImageResizer.resizeImageIfNeeded("pathFromUri", null, HEIGHT, IMAGE_QUALITY)) .thenReturn("scaledPath"); mockFileUriResolver = new MockFileUriResolver(); @@ -375,6 +378,7 @@ private ImagePickerDelegate createDelegate() { mockImageResizer, null, null, + cache, mockPermissionManager, mockIntentResolver, mockFileUriResolver, @@ -388,6 +392,7 @@ private ImagePickerDelegate createDelegateWithPendingResultAndMethodCall() { mockImageResizer, mockResult, mockMethodCall, + cache, mockPermissionManager, mockIntentResolver, mockFileUriResolver, diff --git a/packages/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java b/packages/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java index efb9b4526f9e..e37fceb7fdea 100644 --- a/packages/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java +++ b/packages/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import android.app.Activity; +import android.app.Application; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.PluginRegistry; @@ -28,6 +29,7 @@ public class ImagePickerPluginTest { @Mock PluginRegistry.Registrar mockRegistrar; @Mock Activity mockActivity; + @Mock Application mockApplication; @Mock ImagePickerDelegate mockImagePickerDelegate; @Mock MethodChannel.Result mockResult; @@ -36,6 +38,7 @@ public class ImagePickerPluginTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); + when(mockRegistrar.context()).thenReturn(mockApplication); plugin = new ImagePickerPlugin(mockRegistrar, mockImagePickerDelegate); } diff --git a/packages/image_picker/example/lib/main.dart b/packages/image_picker/example/lib/main.dart index 066c12380b40..9c9b019fbe05 100755 --- a/packages/image_picker/example/lib/main.dart +++ b/packages/image_picker/example/lib/main.dart @@ -39,76 +39,72 @@ class _MyHomePageState extends State { VideoPlayerController _controller; String _retrieveDataError; + Future _playVideo(File file) async { + if (file != null && mounted) { + await _disposeVideoController(); + _controller = VideoPlayerController.file(file); + await _controller.setVolume(1.0); + await _controller.initialize(); + await _controller.setLooping(true); + await _controller.play(); + setState(() {}); + } + } + void _onImageButtonPressed(ImageSource source) async { if (_controller != null) { - _controller.setVolume(0.0); - _controller.removeListener(_onVideoControllerUpdate); + await _controller.setVolume(0.0); } if (isVideo) { - ImagePicker.pickVideo(source: source).then((File file) { - if (file != null && mounted) { - setState(() { - _controller = VideoPlayerController.file(file) - ..addListener(_onVideoControllerUpdate) - ..setVolume(1.0) - ..initialize() - ..setLooping(true) - ..play(); - }); - } - }); + final File file = await ImagePicker.pickVideo(source: source); + await _playVideo(file); } else { try { _imageFile = await ImagePicker.pickImage(source: source); + setState(() {}); } catch (e) { _pickImageError = e; } - setState(() {}); } } - void _onVideoControllerUpdate() { - setState(() {}); - } - @override void deactivate() { if (_controller != null) { _controller.setVolume(0.0); - _controller.removeListener(_onVideoControllerUpdate); + _controller.pause(); } super.deactivate(); } @override void dispose() { + _disposeVideoController(); + super.dispose(); + } + + Future _disposeVideoController() async { if (_controller != null) { - _controller.dispose(); + await _controller.dispose(); + _controller = null; } - super.dispose(); } - Widget _previewVideo(VideoPlayerController controller) { + Widget _previewVideo() { final Text retrieveError = _getRetrieveErrorWidget(); if (retrieveError != null) { return retrieveError; } - if (controller == null) { + if (_controller == null) { return const Text( 'You have not yet picked a video', textAlign: TextAlign.center, ); - } else if (controller.value.initialized) { - return Padding( - padding: const EdgeInsets.all(10.0), - child: AspectRatioVideo(controller), - ); - } else { - return const Text( - 'Error Loading Video', - textAlign: TextAlign.center, - ); } + return Padding( + padding: const EdgeInsets.all(10.0), + child: AspectRatioVideo(_controller), + ); } Widget _previewImage() { @@ -137,20 +133,15 @@ class _MyHomePageState extends State { return; } if (response.file != null) { - setState(() { - if (response.type == RetrieveType.video) { - isVideo = true; - _controller = VideoPlayerController.file(response.file) - ..addListener(_onVideoControllerUpdate) - ..setVolume(1.0) - ..initialize() - ..setLooping(true) - ..play(); - } else { - isVideo = false; + if (response.type == RetrieveType.video) { + isVideo = true; + await _playVideo(response.file); + } else { + isVideo = false; + setState(() { _imageFile = response.file; - } - }); + }); + } } else { _retrieveDataError = response.exception.code; } @@ -175,9 +166,7 @@ class _MyHomePageState extends State { textAlign: TextAlign.center, ); case ConnectionState.done: - return isVideo - ? _previewVideo(_controller) - : _previewImage(); + return isVideo ? _previewVideo() : _previewImage(); default: if (snapshot.hasError) { return Text( @@ -193,7 +182,7 @@ class _MyHomePageState extends State { } }, ) - : (isVideo ? _previewVideo(_controller) : _previewImage()), + : (isVideo ? _previewVideo() : _previewImage()), ), floatingActionButton: Column( mainAxisAlignment: MainAxisAlignment.end, @@ -289,6 +278,12 @@ class AspectRatioVideoState extends State { controller.addListener(_onVideoControllerUpdate); } + @override + void dispose() { + controller.removeListener(_onVideoControllerUpdate); + super.dispose(); + } + @override Widget build(BuildContext context) { if (initialized) { diff --git a/packages/image_picker/example/pubspec.yaml b/packages/image_picker/example/pubspec.yaml index e8c45e60aee7..1f793d7aa91f 100755 --- a/packages/image_picker/example/pubspec.yaml +++ b/packages/image_picker/example/pubspec.yaml @@ -3,7 +3,7 @@ description: Demonstrates how to use the image_picker plugin. author: Flutter Team dependencies: - video_player: 0.5.2 + video_player: 0.10.1+5 flutter: sdk: flutter image_picker: diff --git a/packages/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.m b/packages/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.m index 5123dec11ece..a789853d32aa 100644 --- a/packages/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.m +++ b/packages/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.m @@ -64,12 +64,9 @@ + (NSData *)convertImage:(UIImage *)image usingType:(FLTImagePickerMIMEType)type quality:(nullable NSNumber *)quality { if (quality && type != FLTImagePickerMIMETypeJPEG) { - @throw [NSException - exceptionWithName:@"flutter_image_picker_convert_image_exception" - reason:[NSString stringWithFormat:@"quality is not available for type %@", - [FLTImagePickerMetaDataUtil - imageTypeSuffixFromType:type]] - userInfo:nil]; + NSLog(@"image_picker: compressing is not supported for type %@. Returning the image with " + @"original quality", + [FLTImagePickerMetaDataUtil imageTypeSuffixFromType:type]); } switch (type) { diff --git a/packages/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.h b/packages/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.h index e2b8357cf06f..417a6f9a7399 100644 --- a/packages/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.h +++ b/packages/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.h @@ -18,10 +18,13 @@ NS_ASSUME_NONNULL_BEGIN + (NSString *)saveImageWithOriginalImageData:(NSData *)originalImageData image:(UIImage *)image maxWidth:(nullable NSNumber *)maxWidth - maxHeight:(nullable NSNumber *)maxHeight; + maxHeight:(nullable NSNumber *)maxHeight + imageQuality:(nullable NSNumber *)imageQuality; // Save image with correct meta data and extention copied from image picker result info. -+ (NSString *)saveImageWithPickerInfo:(nullable NSDictionary *)info image:(UIImage *)image; ++ (NSString *)saveImageWithPickerInfo:(nullable NSDictionary *)info + image:(UIImage *)image + imageQuality:(NSNumber *)imageQuality; @end diff --git a/packages/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.m b/packages/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.m index bf22e0069278..59e9d51b9ed8 100644 --- a/packages/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.m +++ b/packages/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.m @@ -26,7 +26,8 @@ + (PHAsset *)getAssetFromImagePickerInfo:(NSDictionary *)info { + (NSString *)saveImageWithOriginalImageData:(NSData *)originalImageData image:(UIImage *)image maxWidth:(NSNumber *)maxWidth - maxHeight:(NSNumber *)maxHeight { + maxHeight:(NSNumber *)maxHeight + imageQuality:(NSNumber *)imageQuality { NSString *suffix = kFLTImagePickerDefaultSuffix; FLTImagePickerMIMEType type = kFLTImagePickerMIMETypeDefault; NSDictionary *metaData = nil; @@ -44,16 +45,23 @@ + (NSString *)saveImageWithOriginalImageData:(NSData *)originalImageData return [self saveImageWithMetaData:metaData gifInfo:gifInfo suffix:suffix]; } else { - return [self saveImageWithMetaData:metaData image:image suffix:suffix type:type]; + return [self saveImageWithMetaData:metaData + image:image + suffix:suffix + type:type + imageQuality:imageQuality]; } } -+ (NSString *)saveImageWithPickerInfo:(nullable NSDictionary *)info image:(UIImage *)image { ++ (NSString *)saveImageWithPickerInfo:(nullable NSDictionary *)info + image:(UIImage *)image + imageQuality:(NSNumber *)imageQuality { NSDictionary *metaData = info[UIImagePickerControllerMediaMetadata]; return [self saveImageWithMetaData:metaData image:image suffix:kFLTImagePickerDefaultSuffix - type:kFLTImagePickerMIMETypeDefault]; + type:kFLTImagePickerMIMETypeDefault + imageQuality:imageQuality]; } + (NSString *)saveImageWithMetaData:(NSDictionary *)metaData @@ -66,7 +74,8 @@ + (NSString *)saveImageWithMetaData:(NSDictionary *)metaData + (NSString *)saveImageWithMetaData:(NSDictionary *)metaData image:(UIImage *)image suffix:(NSString *)suffix - type:(FLTImagePickerMIMEType)type { + type:(FLTImagePickerMIMEType)type + imageQuality:(NSNumber *)imageQuality { CGImagePropertyOrientation orientation = (CGImagePropertyOrientation)[metaData[( __bridge NSString *)kCGImagePropertyOrientation] integerValue]; UIImage *newImage = [UIImage @@ -76,7 +85,9 @@ + (NSString *)saveImageWithMetaData:(NSDictionary *)metaData [FLTImagePickerMetaDataUtil getNormalizedUIImageOrientationFromCGImagePropertyOrientation:orientation]]; - NSData *data = [FLTImagePickerMetaDataUtil convertImage:newImage usingType:type quality:nil]; + NSData *data = [FLTImagePickerMetaDataUtil convertImage:newImage + usingType:type + quality:imageQuality]; if (metaData) { data = [FLTImagePickerMetaDataUtil updateMetaData:metaData toImage:data]; } diff --git a/packages/image_picker/ios/Classes/ImagePickerPlugin.m b/packages/image_picker/ios/Classes/ImagePickerPlugin.m index 18803d2e7fff..975c47778bbc 100644 --- a/packages/image_picker/ios/Classes/ImagePickerPlugin.m +++ b/packages/image_picker/ios/Classes/ImagePickerPlugin.m @@ -251,6 +251,15 @@ - (void)imagePickerController:(UIImagePickerController *)picker NSNumber *maxWidth = [_arguments objectForKey:@"maxWidth"]; NSNumber *maxHeight = [_arguments objectForKey:@"maxHeight"]; + NSNumber *imageQuality = [_arguments objectForKey:@"imageQuality"]; + + if (![imageQuality isKindOfClass:[NSNumber class]]) { + imageQuality = @1; + } else if (imageQuality.intValue < 0 || imageQuality.intValue > 100) { + imageQuality = [NSNumber numberWithInt:1]; + } else { + imageQuality = @([imageQuality floatValue] / 100); + } if (maxWidth != (id)[NSNull null] || maxHeight != (id)[NSNull null]) { image = [FLTImagePickerImageUtil scaledImage:image maxWidth:maxWidth maxHeight:maxHeight]; @@ -259,7 +268,7 @@ - (void)imagePickerController:(UIImagePickerController *)picker PHAsset *originalAsset = [FLTImagePickerPhotoAssetUtil getAssetFromImagePickerInfo:info]; if (!originalAsset) { // Image picked without an original asset (e.g. User took a photo directly) - [self saveImageWithPickerInfo:info image:image]; + [self saveImageWithPickerInfo:info image:image imageQuality:imageQuality]; } else { __weak typeof(self) weakSelf = self; [[PHImageManager defaultManager] @@ -271,7 +280,8 @@ - (void)imagePickerController:(UIImagePickerController *)picker [weakSelf saveImageWithOriginalImageData:imageData image:image maxWidth:maxWidth - maxHeight:maxHeight]; + maxHeight:maxHeight + imageQuality:imageQuality]; }]; } } @@ -289,17 +299,23 @@ - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { - (void)saveImageWithOriginalImageData:(NSData *)originalImageData image:(UIImage *)image maxWidth:(NSNumber *)maxWidth - maxHeight:(NSNumber *)maxHeight { + maxHeight:(NSNumber *)maxHeight + imageQuality:(NSNumber *)imageQuality { NSString *savedPath = [FLTImagePickerPhotoAssetUtil saveImageWithOriginalImageData:originalImageData image:image maxWidth:maxWidth - maxHeight:maxHeight]; + maxHeight:maxHeight + imageQuality:imageQuality]; [self handleSavedPath:savedPath]; } -- (void)saveImageWithPickerInfo:(NSDictionary *)info image:(UIImage *)image { - NSString *savedPath = [FLTImagePickerPhotoAssetUtil saveImageWithPickerInfo:info image:image]; +- (void)saveImageWithPickerInfo:(NSDictionary *)info + image:(UIImage *)image + imageQuality:(NSNumber *)imageQuality { + NSString *savedPath = [FLTImagePickerPhotoAssetUtil saveImageWithPickerInfo:info + image:image + imageQuality:imageQuality]; [self handleSavedPath:savedPath]; } diff --git a/packages/image_picker/lib/image_picker.dart b/packages/image_picker/lib/image_picker.dart index b224b6331ad8..a6ca94713ee3 100755 --- a/packages/image_picker/lib/image_picker.dart +++ b/packages/image_picker/lib/image_picker.dart @@ -26,21 +26,27 @@ class ImagePicker { /// Returns a [File] object pointing to the image that was picked. /// - /// The [source] argument controls where the image comes from. This can + /// The `source` argument controls where the image comes from. This can /// be either [ImageSource.camera] or [ImageSource.gallery]. /// - /// If specified, the image will be at most [maxWidth] wide and - /// [maxHeight] tall. Otherwise the image will be returned at it's + /// If specified, the image will be at most `maxWidth` wide and + /// `maxHeight` tall. Otherwise the image will be returned at it's /// original width and height. + /// The `imageQuality` argument modifies the quality of the image, ranging from 0-100 + /// where 100 is the original/max quality. If `imageQuality` is null, the image with + /// the original quality will be returned. Compression is only supportted for certain + /// image types such as JPEG. If compression is not supported for the image that is picked, + /// an warning message will be logged. /// /// In Android, the MainActivity can be destroyed for various reasons. If that happens, the result will be lost /// in this call. You can then call [retrieveLostData] when your app relaunches to retrieve the lost data. - static Future pickImage({ - @required ImageSource source, - double maxWidth, - double maxHeight, - }) async { + static Future pickImage( + {@required ImageSource source, + double maxWidth, + double maxHeight, + int imageQuality}) async { assert(source != null); + assert(imageQuality == null || (imageQuality >= 0 && imageQuality <= 100)); if (maxWidth != null && maxWidth < 0) { throw ArgumentError.value(maxWidth, 'maxWidth cannot be negative'); @@ -56,6 +62,7 @@ class ImagePicker { 'source': source.index, 'maxWidth': maxWidth, 'maxHeight': maxHeight, + 'imageQuality': imageQuality }, ); diff --git a/packages/image_picker/pubspec.yaml b/packages/image_picker/pubspec.yaml index 721e6abdfbeb..88a52c1c96b7 100755 --- a/packages/image_picker/pubspec.yaml +++ b/packages/image_picker/pubspec.yaml @@ -5,8 +5,7 @@ authors: - Flutter Team - Rhodes Davis Jr. homepage: https://github.com/flutter/plugins/tree/master/packages/image_picker - -version: 0.6.0+17 +version: 0.6.1+1 flutter: plugin: @@ -19,7 +18,7 @@ dependencies: sdk: flutter dev_dependencies: - video_player: 0.5.2 + video_player: 0.10.1+5 flutter_test: sdk: flutter diff --git a/packages/image_picker/test/image_picker_test.dart b/packages/image_picker/test/image_picker_test.dart index 40226afb77ce..7d962e6b916b 100644 --- a/packages/image_picker/test/image_picker_test.dart +++ b/packages/image_picker/test/image_picker_test.dart @@ -34,11 +34,13 @@ void main() { 'source': 0, 'maxWidth': null, 'maxHeight': null, + 'imageQuality': null }), isMethodCall('pickImage', arguments: { 'source': 1, 'maxWidth': null, 'maxHeight': null, + 'imageQuality': null }), ], ); @@ -59,6 +61,15 @@ void main() { maxWidth: 10.0, maxHeight: 20.0, ); + await ImagePicker.pickImage( + source: ImageSource.camera, maxWidth: 10.0, imageQuality: 70); + await ImagePicker.pickImage( + source: ImageSource.camera, maxHeight: 10.0, imageQuality: 70); + await ImagePicker.pickImage( + source: ImageSource.camera, + maxWidth: 10.0, + maxHeight: 20.0, + imageQuality: 70); expect( log, @@ -67,21 +78,43 @@ void main() { 'source': 0, 'maxWidth': null, 'maxHeight': null, + 'imageQuality': null + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': null + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': null + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': null }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': null, + 'imageQuality': 70 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': null, 'maxHeight': 10.0, + 'imageQuality': 70 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': 20.0, + 'imageQuality': 70 }), ], ); diff --git a/packages/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/CHANGELOG.md index 5e3ce5ca35c0..891818199c1a 100644 --- a/packages/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.2.1 + +* iOS: Add currencyCode to priceLocale on productDetails. + +## 0.2.0+8 + +* Add dependency on `androidx.annotation:annotation:1.0.0`. + ## 0.2.0+7 * Make Gradle version compatible with the Android Gradle plugin version. diff --git a/packages/in_app_purchase/android/build.gradle b/packages/in_app_purchase/android/build.gradle index ce9afcdfc376..6be1b770312a 100644 --- a/packages/in_app_purchase/android/build.gradle +++ b/packages/in_app_purchase/android/build.gradle @@ -47,6 +47,7 @@ android { } dependencies { + implementation 'androidx.annotation:annotation:1.0.0' implementation 'com.android.billingclient:billing:1.2' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:2.17.0' diff --git a/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.m b/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.m index b64df0538aa6..f1e5c538cb0e 100644 --- a/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.m +++ b/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.m @@ -109,6 +109,8 @@ + (NSDictionary *)getMapFromNSLocale:(NSLocale *)locale { NSMutableDictionary *map = [[NSMutableDictionary alloc] init]; [map setObject:[locale objectForKey:NSLocaleCurrencySymbol] ?: [NSNull null] forKey:@"currencySymbol"]; + [map setObject:[locale objectForKey:NSLocaleCurrencyCode] ?: [NSNull null] + forKey:@"currencyCode"]; return map; } diff --git a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.dart b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.dart index 0f6e42a49167..8f4c815a8f50 100644 --- a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.dart +++ b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.dart @@ -304,7 +304,8 @@ class SKProductWrapper { // https://github.com/flutter/flutter/issues/26610 @JsonSerializable() class SKPriceLocaleWrapper { - SKPriceLocaleWrapper({@required this.currencySymbol}); + SKPriceLocaleWrapper( + {@required this.currencySymbol, @required this.currencyCode}); /// Constructing an instance from a map from the Objective-C layer. /// @@ -318,6 +319,9 @@ class SKPriceLocaleWrapper { ///The currency symbol for the locale, e.g. $ for US locale. final String currencySymbol; + ///The currency code for the locale, e.g. USD for US locale. + final String currencyCode; + @override bool operator ==(Object other) { if (identical(other, this)) { @@ -327,9 +331,10 @@ class SKPriceLocaleWrapper { return false; } final SKPriceLocaleWrapper typedOther = other; - return typedOther.currencySymbol == currencySymbol; + return typedOther.currencySymbol == currencySymbol && + typedOther.currencyCode == currencyCode; } @override - int get hashCode => this.currencySymbol.hashCode; + int get hashCode => hashValues(this.currencySymbol, this.currencyCode); } diff --git a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart index 10a421878f55..70d947a77a64 100644 --- a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart +++ b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart @@ -132,9 +132,14 @@ Map _$SKProductWrapperToJson(SKProductWrapper instance) => }; SKPriceLocaleWrapper _$SKPriceLocaleWrapperFromJson(Map json) { - return SKPriceLocaleWrapper(currencySymbol: json['currencySymbol'] as String); + return SKPriceLocaleWrapper( + currencySymbol: json['currencySymbol'] as String, + currencyCode: json['currencyCode'] as String); } Map _$SKPriceLocaleWrapperToJson( SKPriceLocaleWrapper instance) => - {'currencySymbol': instance.currencySymbol}; + { + 'currencySymbol': instance.currencySymbol, + 'currencyCode': instance.currencyCode + }; diff --git a/packages/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/pubspec.yaml index 1db8d89b73d1..a398d945fa03 100644 --- a/packages/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/pubspec.yaml @@ -2,7 +2,8 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase -version: 0.2.0+7 +version: 0.2.1 + dependencies: async: ^2.0.8 diff --git a/packages/in_app_purchase/test/store_kit_wrappers/sk_methodchannel_apis_test.dart b/packages/in_app_purchase/test/store_kit_wrappers/sk_methodchannel_apis_test.dart index cc259f972a0e..f644e6f66ff1 100644 --- a/packages/in_app_purchase/test/store_kit_wrappers/sk_methodchannel_apis_test.dart +++ b/packages/in_app_purchase/test/store_kit_wrappers/sk_methodchannel_apis_test.dart @@ -30,10 +30,15 @@ void main() { productResponseWrapper.products.first.priceLocale.currencySymbol, '\$', ); + expect( productResponseWrapper.products.first.priceLocale.currencySymbol, isNot('A'), ); + expect( + productResponseWrapper.products.first.priceLocale.currencyCode, + 'USD', + ); expect( productResponseWrapper.invalidProductIdentifiers, isNotEmpty, diff --git a/packages/in_app_purchase/test/store_kit_wrappers/sk_test_stub_objects.dart b/packages/in_app_purchase/test/store_kit_wrappers/sk_test_stub_objects.dart index b4fa0165a18c..1dc70748f1db 100644 --- a/packages/in_app_purchase/test/store_kit_wrappers/sk_test_stub_objects.dart +++ b/packages/in_app_purchase/test/store_kit_wrappers/sk_test_stub_objects.dart @@ -33,7 +33,7 @@ final SKPaymentTransactionWrapper dummyTransaction = ); final SKPriceLocaleWrapper dummyLocale = - SKPriceLocaleWrapper(currencySymbol: '\$'); + SKPriceLocaleWrapper(currencySymbol: '\$', currencyCode: 'USD'); final SKProductSubscriptionPeriodWrapper dummySubscription = SKProductSubscriptionPeriodWrapper( @@ -67,7 +67,10 @@ final SkProductResponseWrapper dummyProductResponseWrapper = ); Map buildLocaleMap(SKPriceLocaleWrapper local) { - return {'currencySymbol': local.currencySymbol}; + return { + 'currencySymbol': local.currencySymbol, + 'currencyCode': local.currencyCode + }; } Map buildSubscriptionPeriodMap( diff --git a/packages/local_auth/CHANGELOG.md b/packages/local_auth/CHANGELOG.md index fbbd49351d62..1988028a1f9d 100644 --- a/packages/local_auth/CHANGELOG.md +++ b/packages/local_auth/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.3 + +* Add face id detection as well by not relying on FingerprintCompat. + ## 0.5.2+4 * Update README to fix syntax error. diff --git a/packages/local_auth/README.md b/packages/local_auth/README.md index 3a429be8feea..9cb56e619b24 100644 --- a/packages/local_auth/README.md +++ b/packages/local_auth/README.md @@ -142,6 +142,12 @@ Update your project's `AndroidManifest.xml` file to include the ``` +On Android, you can check only for existence of fingerprint hardware prior +to API 29 (Android Q). Therefore, if you would like to support other biometrics +types (such as face scanning) and you want to support SDKs lower than Q, +*do not* call `getAvailableBiometrics`. Simply call `authenticateWithBiometrics`. +This will return an error if there was no hardware available. + ## Sticky Auth You can set the `stickyAuth` option on the plugin to true so that plugin does not diff --git a/packages/local_auth/android/build.gradle b/packages/local_auth/android/build.gradle index 4da3597692c1..142b606405c4 100644 --- a/packages/local_auth/android/build.gradle +++ b/packages/local_auth/android/build.gradle @@ -35,7 +35,7 @@ rootProject.allprojects { apply plugin: 'com.android.library' android { - compileSdkVersion 28 + compileSdkVersion 29 defaultConfig { minSdkVersion 16 diff --git a/packages/local_auth/android/src/main/java/io/flutter/plugins/localauth/AuthenticationHelper.java b/packages/local_auth/android/src/main/java/io/flutter/plugins/localauth/AuthenticationHelper.java index b265dbda1a67..46d7bf3dec9a 100644 --- a/packages/local_auth/android/src/main/java/io/flutter/plugins/localauth/AuthenticationHelper.java +++ b/packages/local_auth/android/src/main/java/io/flutter/plugins/localauth/AuthenticationHelper.java @@ -7,7 +7,6 @@ import android.app.Activity; import android.app.AlertDialog; import android.app.Application; -import android.app.KeyguardManager; import android.content.Context; import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; @@ -21,7 +20,6 @@ import android.view.View; import android.widget.TextView; import androidx.biometric.BiometricPrompt; -import androidx.core.hardware.fingerprint.FingerprintManagerCompat; import androidx.fragment.app.FragmentActivity; import io.flutter.plugin.common.MethodCall; import java.util.concurrent.Executor; @@ -60,8 +58,6 @@ interface AuthCompletionHandler { private final FragmentActivity activity; private final AuthCompletionHandler completionHandler; - private final KeyguardManager keyguardManager; - private final FingerprintManagerCompat fingerprintManager; private final MethodCall call; private final BiometricPrompt.PromptInfo promptInfo; private final boolean isAuthSticky; @@ -73,8 +69,6 @@ public AuthenticationHelper( this.activity = activity; this.completionHandler = completionHandler; this.call = call; - this.keyguardManager = (KeyguardManager) activity.getSystemService(Context.KEYGUARD_SERVICE); - this.fingerprintManager = FingerprintManagerCompat.from(activity); this.isAuthSticky = call.argument("stickyAuth"); this.uiThreadExecutor = new UiThreadExecutor(); this.promptInfo = @@ -86,28 +80,8 @@ public AuthenticationHelper( .build(); } - public void authenticate() { - if (fingerprintManager.isHardwareDetected()) { - if (keyguardManager.isKeyguardSecure() && fingerprintManager.hasEnrolledFingerprints()) { - start(); - } else { - if (call.argument("useErrorDialogs")) { - showGoToSettingsDialog(); - } else if (!keyguardManager.isKeyguardSecure()) { - completionHandler.onError( - "PasscodeNotSet", - "Phone not secured by PIN, pattern or password, or SIM is currently locked."); - } else { - completionHandler.onError("NotEnrolled", "No fingerprint enrolled on this device."); - } - } - } else { - completionHandler.onError("NotAvailable", "Fingerprint is not available on this device."); - } - } - /** Start the fingerprint listener. */ - private void start() { + public void authenticate() { activity.getApplication().registerActivityLifecycleCallbacks(this); new BiometricPrompt(activity, uiThreadExecutor, this).authenticate(promptInfo); } @@ -121,6 +95,25 @@ private void stop() { @Override public void onAuthenticationError(int errorCode, CharSequence errString) { switch (errorCode) { + // TODO(mehmetf): Re-enable when biometric alpha05 is released. + // https://developer.android.com/jetpack/androidx/releases/biometric + // case BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL: + // completionHandler.onError( + // "PasscodeNotSet", + // "Phone not secured by PIN, pattern or password, or SIM is currently locked."); + // break; + case BiometricPrompt.ERROR_NO_SPACE: + case BiometricPrompt.ERROR_NO_BIOMETRICS: + if (call.argument("useErrorDialogs")) { + showGoToSettingsDialog(); + return; + } + completionHandler.onError("NotEnrolled", "No Biometrics enrolled on this device."); + break; + case BiometricPrompt.ERROR_HW_UNAVAILABLE: + case BiometricPrompt.ERROR_HW_NOT_PRESENT: + completionHandler.onError("NotAvailable", "Biometrics is not available on this device."); + break; case BiometricPrompt.ERROR_LOCKOUT: completionHandler.onError( "LockedOut", diff --git a/packages/local_auth/android/src/main/java/io/flutter/plugins/localauth/LocalAuthPlugin.java b/packages/local_auth/android/src/main/java/io/flutter/plugins/localauth/LocalAuthPlugin.java index 9c5f8bd7d014..ae69942c8229 100644 --- a/packages/local_auth/android/src/main/java/io/flutter/plugins/localauth/LocalAuthPlugin.java +++ b/packages/local_auth/android/src/main/java/io/flutter/plugins/localauth/LocalAuthPlugin.java @@ -5,7 +5,8 @@ package io.flutter.plugins.localauth; import android.app.Activity; -import androidx.core.hardware.fingerprint.FingerprintManagerCompat; +import android.content.pm.PackageManager; +import android.os.Build; import androidx.fragment.app.FragmentActivity; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -87,21 +88,30 @@ public void onError(String code, String error) { authenticationHelper.authenticate(); } else if (call.method.equals("getAvailableBiometrics")) { try { + Activity activity = registrar.activity(); + if (activity == null || activity.isFinishing()) { + result.error("no_activity", "local_auth plugin requires a foreground activity", null); + return; + } ArrayList biometrics = new ArrayList(); - FingerprintManagerCompat fingerprintMgr = - FingerprintManagerCompat.from(registrar.activity()); - if (fingerprintMgr.isHardwareDetected()) { - if (fingerprintMgr.hasEnrolledFingerprints()) { + PackageManager packageManager = activity.getPackageManager(); + if (Build.VERSION.SDK_INT >= 23) { + if (packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) { biometrics.add("fingerprint"); - } else { - biometrics.add("undefined"); + } + } + if (Build.VERSION.SDK_INT >= 29) { + if (packageManager.hasSystemFeature(PackageManager.FEATURE_FACE)) { + biometrics.add("face"); + } + if (packageManager.hasSystemFeature(PackageManager.FEATURE_IRIS)) { + biometrics.add("iris"); } } result.success(biometrics); } catch (Exception e) { result.error("no_biometrics_available", e.getMessage(), null); } - } else { result.notImplemented(); } diff --git a/packages/local_auth/pubspec.yaml b/packages/local_auth/pubspec.yaml index 7531d03a9e03..286d7aa73871 100644 --- a/packages/local_auth/pubspec.yaml +++ b/packages/local_auth/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Android and iOS device authentication sensors such as Fingerprint Reader and Touch ID. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/local_auth -version: 0.5.2+4 +version: 0.5.3 flutter: plugin: diff --git a/packages/url_launcher/CHANGELOG.md b/packages/url_launcher/CHANGELOG.md index ea0bf686f93c..db2ace15b1e9 100644 --- a/packages/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/CHANGELOG.md @@ -1,3 +1,12 @@ +## 5.1.2 + +* Update AGP and gradle. +* Split plugin and WebViewActivity class files. + +## 5.1.1 + +* Suppress a handled deprecation warning on iOS + ## 5.1.0 * Add `headers` field to enable headers in the Android implementation. diff --git a/packages/url_launcher/android/build.gradle b/packages/url_launcher/android/build.gradle index 641b2e29f81a..e2c6ea5af7ae 100644 --- a/packages/url_launcher/android/build.gradle +++ b/packages/url_launcher/android/build.gradle @@ -21,7 +21,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:3.4.2' } } diff --git a/packages/url_launcher/android/src/main/AndroidManifest.xml b/packages/url_launcher/android/src/main/AndroidManifest.xml index 3b455eabc021..f43e5ba2474d 100644 --- a/packages/url_launcher/android/src/main/AndroidManifest.xml +++ b/packages/url_launcher/android/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - diff --git a/packages/url_launcher/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncherPlugin.java b/packages/url_launcher/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncherPlugin.java index b877a45bdb25..b2b0eb906952 100644 --- a/packages/url_launcher/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncherPlugin.java +++ b/packages/url_launcher/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncherPlugin.java @@ -4,26 +4,17 @@ package io.flutter.plugins.urllauncher; -import android.app.Activity; -import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.provider.Browser; -import android.view.KeyEvent; -import android.webkit.WebResourceRequest; -import android.webkit.WebView; -import android.webkit.WebViewClient; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.plugin.common.PluginRegistry.Registrar; -import java.util.HashMap; import java.util.Map; /** UrlLauncherPlugin */ @@ -43,15 +34,20 @@ private UrlLauncherPlugin(Registrar registrar) { @Override public void onMethodCall(MethodCall call, Result result) { - String url = call.argument("url"); - if (call.method.equals("canLaunch")) { - canLaunch(url, result); - } else if (call.method.equals("launch")) { - launch(call, result, url); - } else if (call.method.equals("closeWebView")) { - closeWebView(result); - } else { - result.notImplemented(); + final String url = call.argument("url"); + switch (call.method) { + case "canLaunch": + canLaunch(url, result); + break; + case "launch": + launch(call, result, url); + break; + case "closeWebView": + closeWebView(result); + break; + default: + result.notImplemented(); + break; } } @@ -74,31 +70,31 @@ private void launch(MethodCall call, Result result, String url) { final boolean enableJavaScript = call.argument("enableJavaScript"); final boolean enableDomStorage = call.argument("enableDomStorage"); final Map headersMap = call.argument("headers"); - final Activity activity = mRegistrar.activity(); + final Bundle headersBundle = extractBundle(headersMap); + final Context context = mRegistrar.activity(); - if (activity == null) { + if (context == null) { result.error("NO_ACTIVITY", "Launching a URL requires a foreground activity.", null); return; } + if (useWebView) { - launchIntent = new Intent(activity, WebViewActivity.class); - launchIntent.putExtra("url", url); - launchIntent.putExtra("enableJavaScript", enableJavaScript); - launchIntent.putExtra("enableDomStorage", enableDomStorage); + launchIntent = + WebViewActivity.createIntent( + context, url, enableJavaScript, enableDomStorage, headersBundle); } else { - launchIntent = new Intent(Intent.ACTION_VIEW); - launchIntent.setData(Uri.parse(url)); + launchIntent = + new Intent(Intent.ACTION_VIEW) + .setData(Uri.parse(url)) + .putExtra(Browser.EXTRA_HEADERS, headersBundle); } - final Bundle headersBundle = extractBundle(headersMap); - launchIntent.putExtra(Browser.EXTRA_HEADERS, headersBundle); - - activity.startActivity(launchIntent); + context.startActivity(launchIntent); result.success(true); } private void closeWebView(Result result) { - Intent intent = new Intent("close"); + Intent intent = new Intent(WebViewActivity.ACTION_CLOSE); mRegistrar.context().sendBroadcast(intent); result.success(null); } @@ -111,89 +107,4 @@ private Bundle extractBundle(Map headersMap) { } return headersBundle; } - - /* Launches WebView activity */ - public static class WebViewActivity extends Activity { - private WebView webview; - private BroadcastReceiver broadcastReceiver; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - webview = new WebView(this); - setContentView(webview); - // Get the Intent that started this activity and extract the string - final Intent intent = getIntent(); - final String url = intent.getStringExtra("url"); - final boolean enableJavaScript = intent.getBooleanExtra("enableJavaScript", false); - final boolean enableDomStorage = intent.getBooleanExtra("enableDomStorage", false); - final Bundle headersBundle = intent.getBundleExtra(Browser.EXTRA_HEADERS); - - final Map headersMap = extractHeaders(headersBundle); - webview.loadUrl(url, headersMap); - - webview.getSettings().setJavaScriptEnabled(enableJavaScript); - webview.getSettings().setDomStorageEnabled(enableDomStorage); - - // Open new urls inside the webview itself. - webview.setWebViewClient( - new WebViewClient() { - - @Override - @SuppressWarnings("deprecation") - public boolean shouldOverrideUrlLoading(WebView view, String url) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - view.loadUrl(url); - return false; - } - return super.shouldOverrideUrlLoading(view, url); - } - - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - view.loadUrl(request.getUrl().toString()); - } - return false; - } - }); - - // Set broadcast receiver to handle calls to close the web view - broadcastReceiver = - new BroadcastReceiver() { - @Override - public void onReceive(Context arg0, Intent intent) { - String action = intent.getAction(); - if ("close".equals(action)) { - finish(); - } - } - }; - registerReceiver(broadcastReceiver, new IntentFilter("close")); - } - - private Map extractHeaders(Bundle headersBundle) { - final Map headersMap = new HashMap<>(); - for (String key : headersBundle.keySet()) { - final String value = headersBundle.getString(key); - headersMap.put(key, value); - } - return headersMap; - } - - @Override - protected void onDestroy() { - super.onDestroy(); - unregisterReceiver(broadcastReceiver); - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - if (keyCode == KeyEvent.KEYCODE_BACK && webview.canGoBack()) { - webview.goBack(); - return true; - } - return super.onKeyDown(keyCode, event); - } - } } diff --git a/packages/url_launcher/android/src/main/java/io/flutter/plugins/urllauncher/WebViewActivity.java b/packages/url_launcher/android/src/main/java/io/flutter/plugins/urllauncher/WebViewActivity.java new file mode 100644 index 000000000000..52714790a25c --- /dev/null +++ b/packages/url_launcher/android/src/main/java/io/flutter/plugins/urllauncher/WebViewActivity.java @@ -0,0 +1,129 @@ +package io.flutter.plugins.urllauncher; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; +import android.os.Bundle; +import android.provider.Browser; +import android.view.KeyEvent; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import java.util.HashMap; +import java.util.Map; + +/* Launches WebView activity */ +public class WebViewActivity extends Activity { + + /* + * Use this to trigger a BroadcastReceiver inside WebViewActivity + * that will request the current instance to finish. + * */ + public static String ACTION_CLOSE = "close action"; + + private final BroadcastReceiver broadcastReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (ACTION_CLOSE.equals(action)) { + finish(); + } + } + }; + + private final WebViewClient webViewClient = + new WebViewClient() { + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + view.loadUrl(url); + return false; + } + return super.shouldOverrideUrlLoading(view, url); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + view.loadUrl(request.getUrl().toString()); + } + return false; + } + }; + + private WebView webview; + + private IntentFilter closeIntentFilter = new IntentFilter(ACTION_CLOSE); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + webview = new WebView(this); + setContentView(webview); + // Get the Intent that started this activity and extract the string + final Intent intent = getIntent(); + final String url = intent.getStringExtra(URL_EXTRA); + final boolean enableJavaScript = intent.getBooleanExtra(ENABLE_JS_EXTRA, false); + final boolean enableDomStorage = intent.getBooleanExtra(ENABLE_DOM_EXTRA, false); + final Bundle headersBundle = intent.getBundleExtra(Browser.EXTRA_HEADERS); + + final Map headersMap = extractHeaders(headersBundle); + webview.loadUrl(url, headersMap); + + webview.getSettings().setJavaScriptEnabled(enableJavaScript); + webview.getSettings().setDomStorageEnabled(enableDomStorage); + + // Open new urls inside the webview itself. + webview.setWebViewClient(webViewClient); + + // Register receiver that may finish this Activity. + registerReceiver(broadcastReceiver, closeIntentFilter); + } + + private Map extractHeaders(Bundle headersBundle) { + final Map headersMap = new HashMap<>(); + for (String key : headersBundle.keySet()) { + final String value = headersBundle.getString(key); + headersMap.put(key, value); + } + return headersMap; + } + + @Override + protected void onDestroy() { + super.onDestroy(); + unregisterReceiver(broadcastReceiver); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK && webview.canGoBack()) { + webview.goBack(); + return true; + } + return super.onKeyDown(keyCode, event); + } + + private static String URL_EXTRA = "url"; + private static String ENABLE_JS_EXTRA = "enableJavaScript"; + private static String ENABLE_DOM_EXTRA = "enableDomStorage"; + + /* Hides the constants used to forward data to the Activity instance. */ + public static Intent createIntent( + Context context, + String url, + boolean enableJavaScript, + boolean enableDomStorage, + Bundle headersBundle) { + return new Intent(context, WebViewActivity.class) + .putExtra(URL_EXTRA, url) + .putExtra(ENABLE_JS_EXTRA, enableJavaScript) + .putExtra(ENABLE_DOM_EXTRA, enableDomStorage) + .putExtra(Browser.EXTRA_HEADERS, headersBundle); + } +} diff --git a/packages/url_launcher/example/android/build.gradle b/packages/url_launcher/example/android/build.gradle index 541636cc492a..6b1a639efd76 100644 --- a/packages/url_launcher/example/android/build.gradle +++ b/packages/url_launcher/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:3.4.2' } } diff --git a/packages/url_launcher/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/url_launcher/example/android/gradle/wrapper/gradle-wrapper.properties index 019065d1d650..1cedb28ea41f 100644 --- a/packages/url_launcher/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/url_launcher/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Wed Jul 31 20:16:04 BRT 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip diff --git a/packages/url_launcher/ios/Classes/UrlLauncherPlugin.m b/packages/url_launcher/ios/Classes/UrlLauncherPlugin.m index 2136df1bf784..56681dcd1ee3 100644 --- a/packages/url_launcher/ios/Classes/UrlLauncherPlugin.m +++ b/packages/url_launcher/ios/Classes/UrlLauncherPlugin.m @@ -138,7 +138,10 @@ - (void)launchURL:(NSString *)urlString result(@(success)); }]; } else { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" BOOL success = [application openURL:url]; +#pragma clang diagnostic pop result(@(success)); } } diff --git a/packages/url_launcher/pubspec.yaml b/packages/url_launcher/pubspec.yaml index 864e803d5552..f7b77db58818 100644 --- a/packages/url_launcher/pubspec.yaml +++ b/packages/url_launcher/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for launching a URL on Android and iOS. Supports web, phone, SMS, and email schemes. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/url_launcher -version: 5.1.0 +version: 5.1.2 flutter: plugin: diff --git a/packages/video_player/CHANGELOG.md b/packages/video_player/CHANGELOG.md index d96ebfecb839..207a9ca272ce 100644 --- a/packages/video_player/CHANGELOG.md +++ b/packages/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.1+6 + +* [iOS] Fixed a memory leak with notification observing. + ## 0.10.1+5 * Fix race condition while disposing the VideoController. diff --git a/packages/video_player/ios/Classes/VideoPlayerPlugin.m b/packages/video_player/ios/Classes/VideoPlayerPlugin.m index 4aea59f34e8b..bf449ec0e8e2 100644 --- a/packages/video_player/ios/Classes/VideoPlayerPlugin.m +++ b/packages/video_player/ios/Classes/VideoPlayerPlugin.m @@ -82,20 +82,22 @@ - (void)addObservers:(AVPlayerItem*)item { options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:playbackBufferFullContext]; - [[NSNotificationCenter defaultCenter] addObserverForName:AVPlayerItemDidPlayToEndTimeNotification - object:[_player currentItem] - queue:[NSOperationQueue mainQueue] - usingBlock:^(NSNotification* note) { - if (self->_isLooping) { - AVPlayerItem* p = [note object]; - [p seekToTime:kCMTimeZero - completionHandler:nil]; - } else { - if (self->_eventSink) { - self->_eventSink(@{@"event" : @"completed"}); - } - } - }]; + // Add an observer that will respond to itemDidPlayToEndTime + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(itemDidPlayToEndTime:) + name:AVPlayerItemDidPlayToEndTimeNotification + object:item]; +} + +- (void)itemDidPlayToEndTime:(NSNotification*)notification { + if (_isLooping) { + AVPlayerItem* p = [notification object]; + [p seekToTime:kCMTimeZero completionHandler:nil]; + } else { + if (_eventSink) { + _eventSink(@{@"event" : @"completed"}); + } + } } static inline CGFloat radiansToDegrees(CGFloat radians) { diff --git a/packages/video_player/pubspec.yaml b/packages/video_player/pubspec.yaml index bbb2dcda02ef..218c50f64349 100644 --- a/packages/video_player/pubspec.yaml +++ b/packages/video_player/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player description: Flutter plugin for displaying inline video with other Flutter widgets on Android and iOS. author: Flutter Team -version: 0.10.1+5 +version: 0.10.1+6 homepage: https://github.com/flutter/plugins/tree/master/packages/video_player flutter: diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 7904efa1998a..d5801c7a6516 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.3.10+5 + +* Add dependency on `androidx.annotation:annotation:1.0.0`. + +## 0.3.10+4 + +* Add keyboard text to README. + ## 0.3.10+3 * Don't log an unknown setting key error for 'debuggingEnabled' on iOS. diff --git a/packages/webview_flutter/README.md b/packages/webview_flutter/README.md index 948d90d1aac7..8e8d3979d8f8 100644 --- a/packages/webview_flutter/README.md +++ b/packages/webview_flutter/README.md @@ -18,6 +18,17 @@ To use this plugin on iOS you need to opt-in for the embedded views preview by adding a boolean property to the app's `Info.plist` file, with the key `io.flutter.embedded_views_preview` and the value `YES`. +Keyboard support within webviews is also experimental. The above tags also +surface known issues with keyboard input. Some currently known keyboard issues, +as of `webview_flutter` version `0.3.10+4`: + +* [Input needs to be tapped twice to be registered on Samsung + devices](https://github.com/flutter/flutter/issues/35867) +* [Keyboard behavior is buggy after a + resize](https://github.com/flutter/flutter/issues/36978) +* [Keyboard persists after tapping outside text + field](https://github.com/flutter/flutter/issues/36478) + ## Setup ### iOS diff --git a/packages/webview_flutter/android/build.gradle b/packages/webview_flutter/android/build.gradle index 45ab74d37938..4fe7629b5f76 100644 --- a/packages/webview_flutter/android/build.gradle +++ b/packages/webview_flutter/android/build.gradle @@ -46,6 +46,7 @@ android { } dependencies { + implementation 'androidx.annotation:annotation:1.0.0' implementation 'androidx.webkit:webkit:1.0.0' } } diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index 4d230dfbc4af..c4940c215e0e 100644 --- a/packages/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. -version: 0.3.10+3 +version: 0.3.10+5 author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/webview_flutter