From e0a3d78a8115e832e030159064da441b36202434 Mon Sep 17 00:00:00 2001 From: ywatanabe-dev <102775353+ywatanabe-dev@users.noreply.github.com> Date: Mon, 30 Oct 2023 10:00:12 +0900 Subject: [PATCH 1/8] Add converters of latestEnabledExposureDelayTime/videoStitching/visibilityReduction for RN Android --- .../java/com/ricoh360/thetaclientreactnative/Converter.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/Converter.kt b/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/Converter.kt index 6cba9025d1..df85d31b69 100644 --- a/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/Converter.kt +++ b/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/Converter.kt @@ -51,6 +51,7 @@ val optionItemNameToEnum: Map = mutableMapOf( "iso" to OptionNameEnum.Iso, "isoAutoHighLimit" to OptionNameEnum.IsoAutoHighLimit, "language" to OptionNameEnum.Language, + "latestEnabledExposureDelayTime" to OptionNameEnum.LatestEnabledExposureDelayTime, "maxRecordableTime" to OptionNameEnum.MaxRecordableTime, "networkType" to OptionNameEnum.NetworkType, "offDelay" to OptionNameEnum.OffDelay, @@ -69,6 +70,8 @@ val optionItemNameToEnum: Map = mutableMapOf( "timeShift" to OptionNameEnum.TimeShift, "totalSpace" to OptionNameEnum.TotalSpace, "username" to OptionNameEnum.Username, + "videoStitching" to OptionNameEnum.VideoStitching, + "visibilityReduction" to OptionNameEnum.VisibilityReduction, "whiteBalance" to OptionNameEnum.WhiteBalance, "whiteBalanceAutoStrength" to OptionNameEnum.WhiteBalanceAutoStrength, "wlanFrequency" to OptionNameEnum.WlanFrequency, @@ -519,6 +522,7 @@ fun getOptionValueEnum(name: OptionNameEnum, valueName: String): Any? { OptionNameEnum.Iso -> IsoEnum.values().find { it.name == valueName } OptionNameEnum.IsoAutoHighLimit -> IsoAutoHighLimitEnum.values().find { it.name == valueName } OptionNameEnum.Language -> LanguageEnum.values().find { it.name == valueName } + OptionNameEnum.LatestEnabledExposureDelayTime -> ExposureDelayEnum.values().find { it.name == valueName } OptionNameEnum.MaxRecordableTime -> MaxRecordableTimeEnum.values().find { it.name == valueName } OptionNameEnum.NetworkType -> NetworkTypeEnum.values().find { it.name == valueName } OptionNameEnum.OffDelay -> OffDelayEnum.values().find { it.name == valueName } @@ -528,6 +532,8 @@ fun getOptionValueEnum(name: OptionNameEnum, valueName: String): Any? { OptionNameEnum.ShootingMethod -> ShootingMethodEnum.values().find { it.name == valueName } OptionNameEnum.ShutterSpeed -> ShutterSpeedEnum.values().find { it.name == valueName } OptionNameEnum.SleepDelay -> SleepDelayEnum.values().find { it.name == valueName } + OptionNameEnum.VideoStitching -> VideoStitchingEnum.values().find { it.name == valueName } + OptionNameEnum.VisibilityReduction -> VisibilityReductionEnum.values().find { it.name == valueName } OptionNameEnum.WhiteBalance -> WhiteBalanceEnum.values().find { it.name == valueName } OptionNameEnum.WhiteBalanceAutoStrength -> WhiteBalanceAutoStrengthEnum.values().find { it.name == valueName } OptionNameEnum.WlanFrequency -> WlanFrequencyEnum.values().find { it.name == valueName } From a3b7aea36329a04861e1d2d3ed732f402b0eeedb Mon Sep 17 00:00:00 2001 From: simago Date: Fri, 10 Nov 2023 10:43:16 +0900 Subject: [PATCH 2/8] Improve README.md * Add badges * Modify wording * Add screen shots * Add some badges * Add Swift badge --- README.md | 19 ++++++++++++++++++- docs/assets/screen_capture.jpg | Bin 0 -> 36504 bytes docs/assets/screen_info.jpg | Bin 0 -> 38675 bytes docs/assets/screen_menu.jpg | Bin 0 -> 35394 bytes 4 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 docs/assets/screen_capture.jpg create mode 100644 docs/assets/screen_info.jpg create mode 100644 docs/assets/screen_menu.jpg diff --git a/README.md b/README.md index cae47e7d3e..2e5b4745ec 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # THETA Client +[![theta-client CI with Gradle](https://github.com/ricohapi/theta-client/actions/workflows/buildAndTest.yaml/badge.svg)](https://github.com/ricohapi/theta-client/actions/workflows/buildAndTest.yaml) +[![KDoc](https://img.shields.io/badge/API_reference-KDoc-green.svg)](https://ricohapi.github.io/theta-client/) + +[![Kotlin](https://img.shields.io/badge/Kotlin-1.9.10-navy.svg?style=flat&logo=kotlin)](https://kotlinlang.org) +[![Swift](https://img.shields.io/badge/for_Swift-5-FF9900.svg)](https://kotlinlang.org) +[![Flutter](https://img.shields.io/badge/for_Flutter->=2.5.0-blue.svg)](https://ricohapi.github.io/theta-client/) +[![React Native](https://img.shields.io/badge/for_React_Native-0.70.8-aqua.svg)](https://ricohapi.github.io/theta-client/) + + This library provides a way to control RICOH THETA using [RICOH THETA API v2.1](https://github.com/ricohapi/theta-api-specs/tree/main/theta-web-api-v2.1). Your app can perform the following actions: * Take a photo and video @@ -54,8 +63,16 @@ theta-client$ ./gradlew testReleaseUnitTest ``` ## How to Use -See tutorials in `docs` directory and [KDoc](https://ricohapi.github.io/theta-client/) of this library. +See tutorials in `docs` directory and [API reference](https://ricohapi.github.io/theta-client/) of this library. +Demo applications in `demos` directory may help you understand how to use this library. + +## Verification tool +A tool, written in React Native, is prepared to verify the responses and behavior of Theta to verious requests. +Using this verification tool, you can select and send a command with its parameters then its response is displayed in JSON converted from Android or iOS object. +![menu screen](docs/assets/screen_menu.jpg) +![info screen](docs/assets/screen_info.jpg) +![capture screen](docs/assets/screen_capture.jpg) ## License diff --git a/docs/assets/screen_capture.jpg b/docs/assets/screen_capture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5b64f6092e4c848d3f3142ba31f3a4f3f087b4e1 GIT binary patch literal 36504 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%0A5q5 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

h+#yyo+Q^# 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 Wn;4PI^3^(o@hn3Oa9a@b|C<0Q0E*%O literal 0 HcmV?d00001 diff --git a/docs/assets/screen_menu.jpg b/docs/assets/screen_menu.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a8def013db7e84193c2495c8d797b04880be2882 GIT binary patch literal 35394 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 nw_D^$ujY{)qsY;13Qsp) Date: Thu, 16 Nov 2023 16:19:54 +0900 Subject: [PATCH 3/8] Improve TimeShiftCapture stop error I/F --- .../ThetaClientFlutterPlugin.kt | 18 ++++-- .../SwiftThetaClientFlutterPlugin.swift | 22 ++++--- flutter/lib/capture/capture.dart | 14 ++--- .../theta_client_flutter_method_channel.dart | 21 +++++-- ...eta_client_flutter_platform_interface.dart | 3 +- ..._interval_capture_method_channel_test.dart | 14 ++--- ...ime_shift_capture_method_channel_test.dart | 16 ++--- .../test/capture/time_shift_capture_test.dart | 46 +++++++++++++- flutter/test/theta_client_flutter_test.dart | 9 +-- .../thetaclient/capture/TimeShiftCapture.kt | 35 ++++++----- .../thetaclient/capture/TimeShiftCapturing.kt | 8 +-- .../ThetaClientSdkModule.kt | 14 ++++- react-native/ios/ThetaClientReactNative.swift | 16 ++++- ...t-count-specified-interval-capture.test.ts | 16 ++--- .../capture/time-shift-capture.test.ts | 61 +++++++++++++++++++ .../shot-count-specified-interval-capture.ts | 1 + .../src/capture/time-shift-capture.ts | 20 +++++- .../time-shift-capture-screen.tsx | 13 ++-- 18 files changed, 264 insertions(+), 83 deletions(-) diff --git a/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ThetaClientFlutterPlugin.kt b/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ThetaClientFlutterPlugin.kt index 1b7cf9f5a8..3d17c40f17 100644 --- a/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ThetaClientFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ThetaClientFlutterPlugin.kt @@ -60,11 +60,12 @@ class ThetaClientFlutterPlugin : FlutterPlugin, MethodCallHandler { const val eventNameNotify = "theta_client_flutter/theta_notify" const val notifyIdLivePreview = 10001 - const val notifyIdTimeShiftProgress = 10002 + const val notifyIdTimeShiftProgress = 10011 + const val notifyIdTimeShiftStopError = 10012 const val notifyIdVideoCaptureStopError = 10003 const val notifyIdLimitlessIntervalCaptureStopError = 10004 - const val notifyIdShotCountSpecifiedIntervalCaptureProgress = 10005 - const val notifyIdShotCountSpecifiedIntervalCaptureStopError = 10006 + const val notifyIdShotCountSpecifiedIntervalCaptureProgress = 10021 + const val notifyIdShotCountSpecifiedIntervalCaptureStopError = 10022 } fun sendNotifyEvent(id: Int, params: Map) { @@ -599,10 +600,17 @@ class ThetaClientFlutterPlugin : FlutterPlugin, MethodCallHandler { } timeShiftCapturing = timeShiftCapture.startCapture(object : TimeShiftCapture.StartCaptureCallback { - override fun onError(exception: ThetaRepository.ThetaRepositoryException) { + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { result.error(exception.javaClass.simpleName, exception.message, null) } + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + sendNotifyEvent( + notifyIdTimeShiftStopError, + toMessageNotifyParam(exception.message ?: exception.toString()) + ) + } + override fun onProgress(completion: Float) { sendNotifyEvent( notifyIdTimeShiftProgress, @@ -610,7 +618,7 @@ class ThetaClientFlutterPlugin : FlutterPlugin, MethodCallHandler { ) } - override fun onSuccess(fileUrl: String?) { + override fun onCaptureCompleted(fileUrl: String?) { result.success(fileUrl) } }) diff --git a/flutter/ios/Classes/SwiftThetaClientFlutterPlugin.swift b/flutter/ios/Classes/SwiftThetaClientFlutterPlugin.swift index fff2b24976..d46257055c 100644 --- a/flutter/ios/Classes/SwiftThetaClientFlutterPlugin.swift +++ b/flutter/ios/Classes/SwiftThetaClientFlutterPlugin.swift @@ -4,11 +4,12 @@ import UIKit let EVENT_NOTIFY = "theta_client_flutter/theta_notify" let NOTIFY_LIVE_PREVIEW = 10001 -let NOTIFY_TIME_SHIFT_PROGRESS = 10002 +let NOTIFY_TIME_SHIFT_PROGRESS = 10011 +let NOTIFY_TIME_SHIFT_STOP_ERROR = 10011 let NOTIFY_VIDEO_CAPTURE_STOP_ERROR = 10003 let NOTIFY_LIMITLESS_INTERVAL_CAPTURE_STOP_ERROR = 10004 -let NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_CAPTURE_PROGRESS = 10005 -let NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_CAPTURE_STOP_ERROR = 10006 +let NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_CAPTURE_PROGRESS = 10021 +let NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_CAPTURE_STOP_ERROR = 10022 public class SwiftThetaClientFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { public func onListen(withArguments _: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { @@ -541,16 +542,21 @@ public class SwiftThetaClientFlutterPlugin: NSObject, FlutterPlugin, FlutterStre self.callback = callback self.plugin = plugin } - - func onError(exception: ThetaRepository.ThetaRepositoryException) { + + func onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { callback(nil, exception.asError()) } - + + func onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + let error = exception.asError() + plugin?.sendNotifyEvent(id: NOTIFY_TIME_SHIFT_STOP_ERROR, params: toMessageNotifyParam(message: error.localizedDescription)) + } + func onProgress(completion: Float) { plugin?.sendNotifyEvent(id: NOTIFY_TIME_SHIFT_PROGRESS, params: toCaptureProgressNotifyParam(value: completion)) } - - func onSuccess(fileUrl: String?) { + + func onCaptureCompleted(fileUrl: String?) { callback(fileUrl, nil) } } diff --git a/flutter/lib/capture/capture.dart b/flutter/lib/capture/capture.dart index 7f492ae0d8..251da609d4 100644 --- a/flutter/lib/capture/capture.dart +++ b/flutter/lib/capture/capture.dart @@ -97,14 +97,14 @@ class TimeShiftCapture extends Capture { /// Starts TimeShift capture. TimeShiftCapturing startCapture( - void Function(String? fileUrl) onSuccess, - void Function(double completion) onProgress, - void Function(Exception exception) onError, - ) { + void Function(String? fileUrl) onCaptureCompleted, + void Function(double completion) onProgress, + void Function(Exception exception) onCaptureFailed, + {void Function(Exception exception)? onStopFailed}) { ThetaClientFlutterPlatform.instance - .startTimeShiftCapture(onProgress) - .then((value) => onSuccess(value)) - .onError((error, stackTrace) => onError(error as Exception)); + .startTimeShiftCapture(onProgress, onStopFailed) + .then((value) => onCaptureCompleted(value)) + .onError((error, stackTrace) => onCaptureFailed(error as Exception)); return TimeShiftCapturing(); } } diff --git a/flutter/lib/theta_client_flutter_method_channel.dart b/flutter/lib/theta_client_flutter_method_channel.dart index 3be53ddcaa..12f81876af 100644 --- a/flutter/lib/theta_client_flutter_method_channel.dart +++ b/flutter/lib/theta_client_flutter_method_channel.dart @@ -8,11 +8,12 @@ import 'package:theta_client_flutter/utils/convert_utils.dart'; import 'theta_client_flutter_platform_interface.dart'; const notifyIdLivePreview = 10001; -const notifyIdTimeShiftProgress = 10002; +const notifyIdTimeShiftProgress = 10011; +const notifyIdTimeShiftStopError = 10012; const notifyIdVideoCaptureStopError = 10003; const notifyIdLimitlessIntervalCaptureStopError = 10004; -const notifyIdShotCountSpecifiedIntervalCaptureProgress = 10005; -const notifyIdShotCountSpecifiedIntervalCaptureStopError = 10006; +const notifyIdShotCountSpecifiedIntervalCaptureProgress = 10021; +const notifyIdShotCountSpecifiedIntervalCaptureStopError = 10022; /// An implementation of [ThetaClientFlutterPlatform] that uses method channels. class MethodChannelThetaClientFlutter extends ThetaClientFlutterPlatform { @@ -238,8 +239,8 @@ class MethodChannelThetaClientFlutter extends ThetaClientFlutterPlatform { } @override - Future startTimeShiftCapture( - void Function(double)? onProgress) async { + Future startTimeShiftCapture(void Function(double)? onProgress, + void Function(Exception exception)? onStopFailed) async { var completer = Completer(); try { enableNotifyEventReceiver(); @@ -251,12 +252,22 @@ class MethodChannelThetaClientFlutter extends ThetaClientFlutterPlatform { } }); } + if (onStopFailed != null) { + addNotify(notifyIdTimeShiftStopError, (params) { + final message = params?['message'] as String?; + if (message != null) { + onStopFailed(Exception(message)); + } + }); + } final fileUrl = await methodChannel.invokeMethod('startTimeShiftCapture'); removeNotify(notifyIdTimeShiftProgress); + removeNotify(notifyIdTimeShiftStopError); completer.complete(fileUrl); } catch (e) { removeNotify(notifyIdTimeShiftProgress); + removeNotify(notifyIdTimeShiftStopError); completer.completeError(e); } return completer.future; diff --git a/flutter/lib/theta_client_flutter_platform_interface.dart b/flutter/lib/theta_client_flutter_platform_interface.dart index 6e86e30b58..bc02a1fd6a 100644 --- a/flutter/lib/theta_client_flutter_platform_interface.dart +++ b/flutter/lib/theta_client_flutter_platform_interface.dart @@ -107,7 +107,8 @@ abstract class ThetaClientFlutterPlatform extends PlatformInterface { 'buildTimeShiftCapture() has not been implemented.'); } - Future startTimeShiftCapture(void Function(double)? onProgress) { + Future startTimeShiftCapture(void Function(double)? onProgress, + void Function(Exception exception)? onStopFailed) { throw UnimplementedError( 'startTimeShiftCapture() has not been implemented.'); } diff --git a/flutter/test/capture/shot_count_specified_interval_capture_method_channel_test.dart b/flutter/test/capture/shot_count_specified_interval_capture_method_channel_test.dart index a454ead2b3..461774af14 100644 --- a/flutter/test/capture/shot_count_specified_interval_capture_method_channel_test.dart +++ b/flutter/test/capture/shot_count_specified_interval_capture_method_channel_test.dart @@ -99,19 +99,19 @@ void main() { 'http://192.168.1.1/files/150100524436344d4201375fda9dc400/100RICOH/R0013336.MP4' ]; channel.setMockMethodCallHandler((MethodCall methodCall) async { - expect(platform.notifyList.containsKey(10005), true, + expect(platform.notifyList.containsKey(10021), true, reason: 'add notify progress'); // native event platform.onNotify({ - 'id': 10005, + 'id': 10021, 'params': { 'completion': 0.1, }, }); await Future.delayed(const Duration(milliseconds: 10)); platform.onNotify({ - 'id': 10005, + 'id': 10021, 'params': { 'completion': 0.2, }, @@ -129,7 +129,7 @@ void main() { var result = await resultCapture.timeout(const Duration(seconds: 5)); expect(result, fileUrls); expect(progressCount, 2); - expect(platform.notifyList.containsKey(10005), false, + expect(platform.notifyList.containsKey(10021), false, reason: 'remove notify progress'); }); @@ -138,13 +138,13 @@ void main() { 'http://192.168.1.1/files/150100524436344d4201375fda9dc400/100RICOH/R0013336.MP4' ]; channel.setMockMethodCallHandler((MethodCall methodCall) async { - expect(platform.notifyList.containsKey(10006), true, + expect(platform.notifyList.containsKey(10022), true, reason: 'add notify stop error'); await Future.delayed(const Duration(milliseconds: 1)); // native event platform.onNotify({ - 'id': 10006, + 'id': 10022, 'params': { 'message': "stop error", }, @@ -159,7 +159,7 @@ void main() { }); var result = await resultCapture.timeout(const Duration(seconds: 5)); expect(result, fileUrls); - expect(platform.notifyList.containsKey(10006), false, + expect(platform.notifyList.containsKey(10022), false, reason: 'remove notify stop error'); expect(isOnStopFailed, true); }); diff --git a/flutter/test/capture/time_shift_capture_method_channel_test.dart b/flutter/test/capture/time_shift_capture_method_channel_test.dart index 8f8317e5d4..32b37d583c 100644 --- a/flutter/test/capture/time_shift_capture_method_channel_test.dart +++ b/flutter/test/capture/time_shift_capture_method_channel_test.dart @@ -48,7 +48,7 @@ void main() { channel.setMockMethodCallHandler((MethodCall methodCall) async { return fileUrl; }); - expect(await platform.startTimeShiftCapture(null), fileUrl); + expect(await platform.startTimeShiftCapture(null, null), fileUrl); }); test('startTimeShiftCapture no file', () async { @@ -56,7 +56,7 @@ void main() { channel.setMockMethodCallHandler((MethodCall methodCall) async { return fileUrl; }); - expect(await platform.startTimeShiftCapture(null), null); + expect(await platform.startTimeShiftCapture(null, null), null); }); test('startTimeShiftCapture exception', () async { @@ -64,7 +64,7 @@ void main() { throw Exception('test error'); }); try { - await platform.startTimeShiftCapture(null); + await platform.startTimeShiftCapture(null, null); expect(true, false, reason: 'not exception'); } catch (error) { expect(error.toString().contains('test error'), true); @@ -76,19 +76,19 @@ void main() { 'http://192.168.1.1/files/150100524436344d4201375fda9dc400/100RICOH/R0013336.MP4'; channel.setMockMethodCallHandler((MethodCall methodCall) async { - expect(platform.notifyList.containsKey(10002), true, + expect(platform.notifyList.containsKey(10011), true, reason: 'add notify progress'); // native event platform.onNotify({ - 'id': 10002, + 'id': 10011, 'params': { 'completion': 0.1, }, }); await Future.delayed(const Duration(milliseconds: 10)); platform.onNotify({ - 'id': 10002, + 'id': 10011, 'params': { 'completion': 0.2, }, @@ -101,11 +101,11 @@ void main() { int progressCount = 0; var resultCapture = platform.startTimeShiftCapture((completion) { progressCount++; - }); + }, null); var result = await resultCapture.timeout(const Duration(seconds: 5)); expect(result, fileUrl); expect(progressCount, 2); - expect(platform.notifyList.containsKey(10002), false, + expect(platform.notifyList.containsKey(10011), false, reason: 'remove notify progress'); }); } diff --git a/flutter/test/capture/time_shift_capture_test.dart b/flutter/test/capture/time_shift_capture_test.dart index 933835e9ca..c3e7cca423 100644 --- a/flutter/test/capture/time_shift_capture_test.dart +++ b/flutter/test/capture/time_shift_capture_test.dart @@ -67,7 +67,7 @@ void main() { const imageUrl = 'http://test.JPG'; - onCallStartTimeShiftCapture = (onProgress) { + onCallStartTimeShiftCapture = (onProgress, onStopFailed) { return Future.value(imageUrl); }; @@ -95,7 +95,7 @@ void main() { ThetaClientFlutterPlatform.instance = fakePlatform; var completer = Completer(); - onCallStartTimeShiftCapture = (onProgress) { + onCallStartTimeShiftCapture = (onProgress, onStopFailed) { return completer.future; }; onCallStopTimeShiftCapture = () { @@ -127,7 +127,7 @@ void main() { const imageUrl = 'http://test.mp4'; var completer = Completer(); - onCallStartTimeShiftCapture = (onProgress) { + onCallStartTimeShiftCapture = (onProgress, onStopFailed) { return completer.future; }; onCallStopTimeShiftCapture = () { @@ -152,4 +152,44 @@ void main() { await Future.delayed(const Duration(milliseconds: 10), () {}); expect(fileUrl, imageUrl); }); + + test('call onStopFailed', () async { + ThetaClientFlutter thetaClientPlugin = ThetaClientFlutter(); + MockThetaClientFlutterPlatform fakePlatform = + MockThetaClientFlutterPlatform(); + ThetaClientFlutterPlatform.instance = fakePlatform; + + var completer = Completer(); + + void Function(Exception exception)? paramStopFailed; + + onCallStartTimeShiftCapture = (onProgress, onStopFailed) { + paramStopFailed = onStopFailed; + return Completer().future; + }; + onCallStopTimeShiftCapture = () { + paramStopFailed?.call(Exception("on stop error.")); + return Future.value(); + }; + + var builder = thetaClientPlugin + .getTimeShiftCaptureBuilder(); + var capture = await builder.build(); + var isOnStopFailed = false; + var capturing = capture.startCapture( + (fileUrl) { + expect(false, isTrue, reason: 'startCapture'); + }, + (completion) {}, + (exception) {}, + onStopFailed: (exception) { + expect(exception, isNotNull, reason: 'Error. stopCapture'); + isOnStopFailed = true; + completer.complete(null); + }); + + capturing.stopCapture(); + await completer.future.timeout(const Duration(milliseconds: 10)); + expect(isOnStopFailed, true); + }); } diff --git a/flutter/test/theta_client_flutter_test.dart b/flutter/test/theta_client_flutter_test.dart index b88eb5221a..d411ba915f 100644 --- a/flutter/test/theta_client_flutter_test.dart +++ b/flutter/test/theta_client_flutter_test.dart @@ -76,8 +76,9 @@ class MockThetaClientFlutterPlatform } @override - Future startTimeShiftCapture(void Function(double)? onProgress) { - return onCallStartTimeShiftCapture(onProgress); + Future startTimeShiftCapture(void Function(double)? onProgress, + void Function(Exception exception)? onStopFailed) { + return onCallStartTimeShiftCapture(onProgress, onStopFailed); } @override @@ -327,8 +328,8 @@ Future Function() onCallTakePicture = Future.value; Future Function() onCallGetTimeShiftCaptureBuilder = Future.value; Future Function(Map options, int interval) onCallBuildTimeShiftCapture = (options, interval) => Future.value(); -Future Function(void Function(double)? onProgress) - onCallStartTimeShiftCapture = (onProgress) => Future.value(); +Future Function(void Function(double)? onProgress, void Function(Exception exception)? onStopFailed) + onCallStartTimeShiftCapture = (onProgress, onStopFailed) => Future.value(); Future Function() onCallStopTimeShiftCapture = Future.value; Future Function() onCallGetVideoCaptureBuilder = Future.value; Future Function(Map options) onCallBuildVideoCapture = diff --git a/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/TimeShiftCapture.kt b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/TimeShiftCapture.kt index 865ff4d00c..2e36126bc3 100644 --- a/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/TimeShiftCapture.kt +++ b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/TimeShiftCapture.kt @@ -41,25 +41,32 @@ class TimeShiftCapture private constructor( */ interface StartCaptureCallback { /** - * Called when successful. + * Called when state "inProgress". * - * @param fileUrl URL of the time-shift. When the time-shift is canceled, this URL will be null. + * @param completion Progress rate of command executed */ - fun onSuccess(fileUrl: String?) + fun onProgress(completion: Float) /** - * Called when state "inProgress". + * Called when stopCapture error occurs. * - * @param completion Progress rate of command executed + * @param exception Exception of error occurs */ - fun onProgress(completion: Float) + fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) /** * Called when error occurs. * * @param exception Exception of error occurs */ - fun onError(exception: ThetaRepository.ThetaRepositoryException) + fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) + + /** + * Called when successful. + * + * @param fileUrl URL of the time-shift. When the time-shift is canceled, this URL will be null. + */ + fun onCaptureCompleted(fileUrl: String?) } /** @@ -109,36 +116,36 @@ class TimeShiftCapture private constructor( "camera.takePicture" -> (response as TakePictureResponse).results?.fileUrl else -> null } - callback.onSuccess(fileUrl = fileUrl) + callback.onCaptureCompleted(fileUrl = fileUrl) return@runBlocking } val error = response.error if (error != null && !error.isCanceledShootingCode()) { - callback.onError(exception = ThetaRepository.ThetaWebApiException(message = error.message)) + callback.onCaptureFailed(exception = ThetaRepository.ThetaWebApiException(message = error.message)) } else { println("timeShift canceled") - callback.onSuccess(fileUrl = null) // canceled + callback.onCaptureCompleted(fileUrl = null) // canceled } } } catch (e: JsonConvertException) { - callback.onError( + callback.onCaptureFailed( exception = ThetaRepository.ThetaWebApiException( message = e.message ?: e.toString() ) ) } catch (e: ResponseException) { if (isCanceledShootingResponse(e.response)) { - callback.onSuccess(fileUrl = null) // canceled + callback.onCaptureCompleted(fileUrl = null) // canceled } else { - callback.onError( + callback.onCaptureFailed( exception = ThetaRepository.ThetaWebApiException.create( exception = e ) ) } } catch (e: Exception) { - callback.onError( + callback.onCaptureFailed( exception = ThetaRepository.NotConnectedException( message = e.message ?: e.toString() ) diff --git a/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/TimeShiftCapturing.kt b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/TimeShiftCapturing.kt index 9a5dedc595..3a9c3d7ed3 100644 --- a/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/TimeShiftCapturing.kt +++ b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/TimeShiftCapturing.kt @@ -36,17 +36,17 @@ class TimeShiftCapturing internal constructor( try { response = ThetaApi.callStopCaptureCommand(endpoint = endpoint) response.error?.let { - callback.onError(exception = ThetaRepository.ThetaWebApiException(message = it.message)) + callback.onStopFailed(exception = ThetaRepository.ThetaWebApiException(message = it.message)) return@launch } } catch (e: JsonConvertException) { - callback.onError(exception = ThetaRepository.ThetaWebApiException(message = e.message ?: e.toString())) + callback.onStopFailed(exception = ThetaRepository.ThetaWebApiException(message = e.message ?: e.toString())) return@launch } catch (e: ResponseException) { - callback.onError(exception = ThetaRepository.ThetaWebApiException.create(exception = e)) + callback.onStopFailed(exception = ThetaRepository.ThetaWebApiException.create(exception = e)) return@launch } catch (e: Exception) { - callback.onError(exception = ThetaRepository.NotConnectedException(message = e.message ?: e.toString())) + callback.onStopFailed(exception = ThetaRepository.NotConnectedException(message = e.message ?: e.toString())) return@launch } } diff --git a/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/ThetaClientSdkModule.kt b/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/ThetaClientSdkModule.kt index 1929364692..60e067bc1b 100644 --- a/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/ThetaClientSdkModule.kt +++ b/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/ThetaClientSdkModule.kt @@ -594,7 +594,7 @@ class ThetaClientReactNativeModule( return } class StartCaptureCallback : TimeShiftCapture.StartCaptureCallback { - override fun onSuccess(fileUrl: String?) { + override fun onCaptureCompleted(fileUrl: String?) { promise.resolve(fileUrl) timeShiftCapture = null } @@ -605,10 +605,19 @@ class ThetaClientReactNativeModule( ) } - override fun onError(exception: ThetaRepository.ThetaRepositoryException) { + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { promise.reject(exception) timeShiftCapture = null } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + sendNotifyEvent( + toNotify( + NOTIFY_TIMESHIFT_STOP_ERROR, + toMessageNotifyParam(exception.message ?: exception.toString()) + ) + ) + } } timeShiftCapturing = timeShiftCapture?.startCapture(StartCaptureCallback()) } @@ -1545,6 +1554,7 @@ class ThetaClientReactNativeModule( const val EVENT_NAME = "ThetaFrameEvent" const val EVENT_NOTIFY = "ThetaNotify" const val NOTIFY_TIMESHIFT_PROGRESS = "TIME-SHIFT-PROGRESS" + const val NOTIFY_TIMESHIFT_STOP_ERROR = "TIME-SHIFT-STOP-ERROR" const val NOTIFY_VIDEO_CAPTURE_STOP_ERROR = "VIDEO-CAPTURE-STOP-ERROR" const val NOTIFY_LIMITLESS_INTERVAL_CAPTURE_STOP_ERROR = "LIMITLESS-INTERVAL-CAPTURE-STOP-ERROR" const val NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_PROGRESS = "SHOT-COUNT-SPECIFIED-INTERVAL-PROGRESS" diff --git a/react-native/ios/ThetaClientReactNative.swift b/react-native/ios/ThetaClientReactNative.swift index 9d7be7dd5a..09edc7aaa4 100644 --- a/react-native/ios/ThetaClientReactNative.swift +++ b/react-native/ios/ThetaClientReactNative.swift @@ -42,6 +42,7 @@ class ThetaClientReactNative: RCTEventEmitter { static let EVENT_NOTIFY = "ThetaNotify" static let NOTIFY_TIMESHIFT_PROGRESS = "TIME-SHIFT-PROGRESS" + static let NOTIFY_TIMESHIFT_STOP_ERROR = "TIME-SHIFT-STOP-ERROR" static let NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_PROGRESS = "SHOT-COUNT-SPECIFIED-INTERVAL-PROGRESS" static let NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_STOP_ERROR = "SHOT-COUNT-SPECIFIED-INTERVAL-STOP-ERROR" static let NOTIFY_VIDEO_CAPTURE_STOP_ERROR = "VIDEO-CAPTURE-STOP-ERROR" @@ -584,10 +585,21 @@ class ThetaClientReactNative: RCTEventEmitter { self.client = client } - func onError(exception: ThetaRepository.ThetaRepositoryException) { + func onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { callback(nil, exception.asError()) } + func onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + let error = exception.asError() + client?.sendEvent( + withName: ThetaClientReactNative.EVENT_NOTIFY, + body: toNotify( + name: ThetaClientReactNative.NOTIFY_TIMESHIFT_STOP_ERROR, + params: toMessageNotifyParam(value: error.localizedDescription) + ) + ) + } + func onProgress(completion: Float) { client?.sendEvent( withName: ThetaClientReactNative.EVENT_NOTIFY, @@ -598,7 +610,7 @@ class ThetaClientReactNative: RCTEventEmitter { ) } - func onSuccess(fileUrl: String?) { + func onCaptureCompleted(fileUrl: String?) { callback(fileUrl, nil) } } diff --git a/react-native/src/__tests__/capture/shot-count-specified-interval-capture.test.ts b/react-native/src/__tests__/capture/shot-count-specified-interval-capture.test.ts index 70ff0807de..0283e8b455 100644 --- a/react-native/src/__tests__/capture/shot-count-specified-interval-capture.test.ts +++ b/react-native/src/__tests__/capture/shot-count-specified-interval-capture.test.ts @@ -198,7 +198,7 @@ describe('interval shooting with the shot count specified', () => { jest .mocked(thetaClient.buildShotCountSpecifiedIntervalCapture) .mockImplementation(jest.fn(async () => {})); - const testUrl = 'http://192.168.1.1/files/100RICOH/R100.JPG'; + const testUrls = ['http://192.168.1.1/files/100RICOH/R100.JPG']; const sendProgress = (progress: number) => { notifyCallback({ @@ -214,17 +214,17 @@ describe('interval shooting with the shot count specified', () => { .mockImplementation( jest.fn(async () => { sendProgress(0.5); - return testUrl; + return testUrls; }) ); const capture = await builder.build(); let isOnProgress = false; - const fileUrl = await capture.startCapture((completion) => { + const fileUrls = await capture.startCapture((completion) => { expect(completion).toBe(0.5); isOnProgress = true; }); - expect(fileUrl).toBe(testUrl); + expect(fileUrls).toBe(testUrls); let done: (value: unknown) => void; const promise = new Promise((resolve) => { @@ -258,7 +258,7 @@ describe('interval shooting with the shot count specified', () => { jest .mocked(thetaClient.buildShotCountSpecifiedIntervalCapture) .mockImplementation(jest.fn(async () => {})); - const testUrl = 'http://192.168.1.1/files/100RICOH/R100.JPG'; + const testUrls = ['http://192.168.1.1/files/100RICOH/R100.JPG']; const sendStopError = (message: string) => { notifyCallback({ @@ -274,20 +274,20 @@ describe('interval shooting with the shot count specified', () => { .mockImplementation( jest.fn(async () => { sendStopError('stop error'); - return testUrl; + return testUrls; }) ); const capture = await builder.build(); let isOnStopError = false; - const fileUrl = await capture.startCapture( + const fileUrls = await capture.startCapture( () => {}, (error) => { expect(error.message).toBe('stop error'); isOnStopError = true; } ); - expect(fileUrl).toBe(testUrl); + expect(fileUrls).toBe(testUrls); let done: (value: unknown) => void; const promise = new Promise((resolve) => { diff --git a/react-native/src/__tests__/capture/time-shift-capture.test.ts b/react-native/src/__tests__/capture/time-shift-capture.test.ts index fb8197a81a..b7698461ea 100644 --- a/react-native/src/__tests__/capture/time-shift-capture.test.ts +++ b/react-native/src/__tests__/capture/time-shift-capture.test.ts @@ -235,4 +235,65 @@ describe('time shift capture', () => { return promise; }); + + test('stop error events', async () => { + let notifyCallback: (notify: BaseNotify) => void = () => { + expect(true).toBeFalsy(); + }; + jest.mocked(NativeEventEmitter_addListener).mockImplementation( + jest.fn((_, callback) => { + notifyCallback = callback; + return { + remove: jest.fn(), + }; + }) + ); + + await initialize(); + const builder = getTimeShiftCaptureBuilder(); + jest + .mocked(thetaClient.buildShotCountSpecifiedIntervalCapture) + .mockImplementation(jest.fn(async () => {})); + const testUrl = 'http://192.168.1.1/files/100RICOH/R100.JPG'; + + const sendStopError = (message: string) => { + notifyCallback({ + name: 'TIME-SHIFT-STOP-ERROR', + params: { + message, + }, + }); + }; + + jest.mocked(thetaClient.startTimeShiftCapture).mockImplementation( + jest.fn(async () => { + sendStopError('stop error'); + return testUrl; + }) + ); + + const capture = await builder.build(); + let isOnStopError = false; + const fileUrl = await capture.startCapture( + () => {}, + (error) => { + expect(error.message).toBe('stop error'); + isOnStopError = true; + } + ); + expect(fileUrl).toBe(testUrl); + + let done: (value: unknown) => void; + const promise = new Promise((resolve) => { + done = resolve; + }); + + setTimeout(() => { + expect(NotifyController.instance.notifyList.size).toBe(0); + expect(isOnStopError).toBeTruthy(); + done(0); + }, 1); + + return promise; + }); }); diff --git a/react-native/src/capture/shot-count-specified-interval-capture.ts b/react-native/src/capture/shot-count-specified-interval-capture.ts index f9f8dc0db2..5ea209d7c5 100644 --- a/react-native/src/capture/shot-count-specified-interval-capture.ts +++ b/react-native/src/capture/shot-count-specified-interval-capture.ts @@ -32,6 +32,7 @@ export class ShotCountSpecifiedIntervalCapture { /** * start interval shooting with the shot count specified * @param onProgress the block for interval shooting with the shot count specified onProgress + * @param onStopFailed the block for error of cancelCapture * @return promise of captured file url */ async startCapture( diff --git a/react-native/src/capture/time-shift-capture.ts b/react-native/src/capture/time-shift-capture.ts index 33febef0e4..7db1472890 100644 --- a/react-native/src/capture/time-shift-capture.ts +++ b/react-native/src/capture/time-shift-capture.ts @@ -8,6 +8,7 @@ import { const ThetaClientReactNative = NativeModules.ThetaClientReactNative; const NOTIFY_NAME = 'TIME-SHIFT-PROGRESS'; +const NOTIFY_STOP_ERROR = 'TIME-SHIFT-STOP-ERROR'; interface CaptureProgressNotify extends BaseNotify { params?: { @@ -15,6 +16,12 @@ interface CaptureProgressNotify extends BaseNotify { }; } +interface CaptureStopErrorNotify extends BaseNotify { + params?: { + message: string; + }; +} + /** * TimeShiftCapture class */ @@ -26,16 +33,26 @@ export class TimeShiftCapture { /** * start time-shift * @param onProgress the block for time-shift onProgress + * @param onStopFailed the block for error of cancelCapture * @return promise of captured file url */ async startCapture( - onProgress?: (completion?: number) => void + onProgress?: (completion?: number) => void, + onStopFailed?: (error: any) => void ): Promise { if (onProgress) { this.notify.addNotify(NOTIFY_NAME, (event: CaptureProgressNotify) => { onProgress(event.params?.completion); }); } + if (onStopFailed) { + this.notify.addNotify( + NOTIFY_STOP_ERROR, + (event: CaptureStopErrorNotify) => { + onStopFailed(event.params); + } + ); + } return new Promise(async (resolve, reject) => { await ThetaClientReactNative.startTimeShiftCapture() @@ -47,6 +64,7 @@ export class TimeShiftCapture { }) .finally(() => { this.notify.removeNotify(NOTIFY_NAME); + this.notify.removeNotify(NOTIFY_STOP_ERROR); }); }); } diff --git a/react-native/verification-tool/src/screen/time-shift-capture-screen/time-shift-capture-screen.tsx b/react-native/verification-tool/src/screen/time-shift-capture-screen/time-shift-capture-screen.tsx index 63f28a34e3..397ee74bf1 100644 --- a/react-native/verification-tool/src/screen/time-shift-capture-screen/time-shift-capture-screen.tsx +++ b/react-native/verification-tool/src/screen/time-shift-capture-screen/time-shift-capture-screen.tsx @@ -86,10 +86,15 @@ const TimeShiftCaptureScreen: React.FC = ({ navigation }) => { } try { console.log('startTimeShiftCapture startCapture'); - const url = await capture.startCapture((completion) => { - if (isTaking) return; - setMessage(`progress = ${completion}`); - }); + const url = await capture.startCapture( + (completion) => { + if (isTaking) return; + setMessage(`progress = ${completion}`); + }, + (error) => { + Alert.alert('Cancel error', JSON.stringify(error), [{ text: 'OK' }]); + } + ); initCapture(); if (url) { Alert.alert('TimeShift file url : ', url, [{ text: 'OK' }]); From 33dbc79044200c2f69c55361bb25577c8c542f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=BE=E5=B2=A1=E3=80=80=E4=BE=91=E5=87=9B?= <129148471+LassicYM@users.noreply.github.com> Date: Thu, 16 Nov 2023 16:55:56 +0900 Subject: [PATCH 4/8] Add interval composite shooting. --- .../theta_client_flutter/ConvertUtil.kt | 18 +- .../ThetaClientFlutterPlugin.kt | 109 ++- flutter/ios/Classes/ConvertUtil.swift | 11 + .../SwiftThetaClientFlutterPlugin.swift | 106 ++- flutter/lib/capture/capture.dart | 32 + flutter/lib/capture/capture_builder.dart | 31 + flutter/lib/capture/capturing.dart | 18 +- flutter/lib/theta_client_flutter.dart | 8 + .../theta_client_flutter_method_channel.dart | 63 ++ ...eta_client_flutter_platform_interface.dart | 23 + ..._interval_capture_method_channel_test.dart | 163 ++++ .../composite_interval_capture_test.dart | 277 ++++++ flutter/test/theta_client_flutter_test.dart | 32 + .../ricoh360/thetaclient/ThetaRepository.kt | 21 +- .../capture/CompositeIntervalCapture.kt | 211 +++++ .../capture/CompositeIntervalCapturing.kt | 54 ++ .../capture/CompositeIntervalCaptureTest.kt | 817 ++++++++++++++++++ .../start_capture_cancel.json | 8 + .../start_capture_done.json | 1 + .../start_capture_done_empty.json | 1 + .../start_capture_error.json | 8 + .../start_capture_progress.json | 1 + .../CompositeIntervalCapture/state_idle.json | 22 + .../state_self_timer.json | 1 + .../state_shooting.json | 22 + .../stop_capture_done.json | 1 + .../stop_capture_error.json | 8 + .../thetaclientreactnative/Converter.kt | 19 +- .../ThetaClientSdkModule.kt | 121 +++ react-native/ios/ConvertUtil.swift | 12 + react-native/ios/ThetaClientReactNative.m | 14 + react-native/ios/ThetaClientReactNative.swift | 143 ++- react-native/src/__mocks__/react-native.ts | 4 + .../composite-interval-capture.test.ts | 288 ++++++ .../src/capture/composite-interval-capture.ts | 140 +++ react-native/src/capture/index.ts | 1 + .../src/theta-repository/theta-repository.ts | 14 + react-native/verification-tool/src/App.tsx | 6 + .../composite-interval-capture-screen.tsx | 216 +++++ .../index.ts | 1 + .../styles.tsx | 33 + .../src/screen/menu-screen/menu-screen.tsx | 8 + 42 files changed, 3052 insertions(+), 35 deletions(-) create mode 100644 flutter/test/capture/composite_interval_capture_method_channel_test.dart create mode 100644 flutter/test/capture/composite_interval_capture_test.dart create mode 100644 kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCapture.kt create mode 100644 kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCapturing.kt create mode 100644 kotlin-multiplatform/src/commonTest/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCaptureTest.kt create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_cancel.json create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_done.json create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_done_empty.json create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_error.json create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_idle.json create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_self_timer.json create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_shooting.json create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/stop_capture_done.json create mode 100644 kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/stop_capture_error.json create mode 100644 react-native/src/__tests__/capture/composite-interval-capture.test.ts create mode 100644 react-native/src/capture/composite-interval-capture.ts create mode 100644 react-native/verification-tool/src/screen/composite-interval-capture-screen/composite-interval-capture-screen.tsx create mode 100644 react-native/verification-tool/src/screen/composite-interval-capture-screen/index.ts create mode 100644 react-native/verification-tool/src/screen/composite-interval-capture-screen/styles.tsx diff --git a/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ConvertUtil.kt b/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ConvertUtil.kt index f05b26a29d..7b5c7cebff 100644 --- a/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ConvertUtil.kt +++ b/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ConvertUtil.kt @@ -2,12 +2,7 @@ package com.ricoh360.thetaclient.theta_client_flutter import com.ricoh360.thetaclient.DigestAuth import com.ricoh360.thetaclient.ThetaRepository.* -import com.ricoh360.thetaclient.capture.Capture -import com.ricoh360.thetaclient.capture.PhotoCapture -import com.ricoh360.thetaclient.capture.TimeShiftCapture -import com.ricoh360.thetaclient.capture.VideoCapture -import com.ricoh360.thetaclient.capture.LimitlessIntervalCapture -import com.ricoh360.thetaclient.capture.ShotCountSpecifiedIntervalCapture +import com.ricoh360.thetaclient.capture.* import io.flutter.plugin.common.MethodCall const val KEY_CLIENT_MODE = "clientMode" @@ -287,6 +282,17 @@ fun setShotCountSpecifiedIntervalCaptureBuilderParams(call: MethodCall, builder: } } +fun setCompositeIntervalCaptureBuilderParams(call: MethodCall, builder: CompositeIntervalCapture.Builder) { + call.argument("_capture_interval")?.let { + if (it >= 0) { + builder.setCheckStatusCommandInterval(it.toLong()) + } + } + call.argument(OptionNameEnum.CompositeShootingOutputInterval.name)?.also { + builder.setCompositeShootingOutputInterval(it) + } +} + fun toGetOptionsParam(data: List): List { val optionNames = mutableListOf() data.forEach { name -> diff --git a/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ThetaClientFlutterPlugin.kt b/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ThetaClientFlutterPlugin.kt index 3d17c40f17..ef56531607 100644 --- a/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ThetaClientFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/com/ricoh360/thetaclient/theta_client_flutter/ThetaClientFlutterPlugin.kt @@ -2,15 +2,7 @@ package com.ricoh360.thetaclient.theta_client_flutter import android.util.Log import com.ricoh360.thetaclient.ThetaRepository -import com.ricoh360.thetaclient.capture.PhotoCapture -import com.ricoh360.thetaclient.capture.TimeShiftCapture -import com.ricoh360.thetaclient.capture.TimeShiftCapturing -import com.ricoh360.thetaclient.capture.VideoCapture -import com.ricoh360.thetaclient.capture.VideoCapturing -import com.ricoh360.thetaclient.capture.LimitlessIntervalCapture -import com.ricoh360.thetaclient.capture.LimitlessIntervalCapturing -import com.ricoh360.thetaclient.capture.ShotCountSpecifiedIntervalCapture -import com.ricoh360.thetaclient.capture.ShotCountSpecifiedIntervalCapturing +import com.ricoh360.thetaclient.capture.* import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall @@ -51,6 +43,9 @@ class ThetaClientFlutterPlugin : FlutterPlugin, MethodCallHandler { var shotCountSpecifiedIntervalCaptureBuilder: ShotCountSpecifiedIntervalCapture.Builder? = null var shotCountSpecifiedIntervalCapture: ShotCountSpecifiedIntervalCapture? = null var shotCountSpecifiedIntervalCapturing: ShotCountSpecifiedIntervalCapturing? = null + var compositeIntervalCaptureBuilder: CompositeIntervalCapture.Builder? = null + var compositeIntervalCapture: CompositeIntervalCapture? = null + var compositeIntervalCapturing: CompositeIntervalCapturing? = null companion object { const val errorCode: String = "Error" @@ -66,6 +61,8 @@ class ThetaClientFlutterPlugin : FlutterPlugin, MethodCallHandler { const val notifyIdLimitlessIntervalCaptureStopError = 10004 const val notifyIdShotCountSpecifiedIntervalCaptureProgress = 10021 const val notifyIdShotCountSpecifiedIntervalCaptureStopError = 10022 + const val notifyIdCompositeIntervalCaptureProgress = 10031; + const val notifyIdCompositeIntervalCaptureStopError = 10032; } fun sendNotifyEvent(id: Int, params: Map) { @@ -256,6 +253,24 @@ class ThetaClientFlutterPlugin : FlutterPlugin, MethodCallHandler { stopShotCountSpecifiedIntervalCapture(result) } + "getCompositeIntervalCaptureBuilder" -> { + getCompositeIntervalCaptureBuilder(call, result) + } + + "buildCompositeIntervalCapture" -> { + scope.launch { + buildCompositeIntervalCapture(call, result) + } + } + + "startCompositeIntervalCapture" -> { + startCompositeIntervalCapture(result) + } + + "stopCompositeIntervalCapture" -> { + stopCompositeIntervalCapture(result) + } + "getOptions" -> { scope.launch { getOptions(call, result) @@ -427,6 +442,9 @@ class ThetaClientFlutterPlugin : FlutterPlugin, MethodCallHandler { shotCountSpecifiedIntervalCaptureBuilder = null shotCountSpecifiedIntervalCapture = null shotCountSpecifiedIntervalCapturing = null + compositeIntervalCaptureBuilder = null + compositeIntervalCapture = null + compositeIntervalCapturing = null try { endpoint = call.argument("endpoint")!! @@ -826,6 +844,79 @@ class ThetaClientFlutterPlugin : FlutterPlugin, MethodCallHandler { result.success(null) } + fun getCompositeIntervalCaptureBuilder(call: MethodCall, result: Result) { + val theta = thetaRepository + if (theta == null) { + result.error(errorCode, messageNotInit, null) + return + } + (call.arguments as? Int)?.let { + compositeIntervalCaptureBuilder = theta.getCompositeIntervalCaptureBuilder(it) + } + result.success(null) + } + + suspend fun buildCompositeIntervalCapture(call: MethodCall, result: Result) { + val theta = thetaRepository + val compositeIntervalCaptureBuilder = compositeIntervalCaptureBuilder + if (theta == null || compositeIntervalCaptureBuilder == null) { + result.error(errorCode, messageNotInit, null) + return + } + setCaptureBuilderParams(call, compositeIntervalCaptureBuilder) + setCompositeIntervalCaptureBuilderParams(call, compositeIntervalCaptureBuilder) + try { + compositeIntervalCapture = compositeIntervalCaptureBuilder.build() + result.success(null) + } catch (e: Exception) { + result.error(e.javaClass.simpleName, e.message, null) + } + } + + fun startCompositeIntervalCapture(result: Result) { + val theta = thetaRepository + val compositeIntervalCapture = compositeIntervalCapture + if (theta == null || compositeIntervalCapture == null) { + result.error(errorCode, messageNotInit, null) + return + } + compositeIntervalCapturing = + compositeIntervalCapture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + result.error(exception.javaClass.simpleName, exception.message, null) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + sendNotifyEvent( + notifyIdCompositeIntervalCaptureStopError, + toMessageNotifyParam(exception.message ?: exception.toString()) + ) + } + + override fun onProgress(completion: Float) { + sendNotifyEvent( + notifyIdCompositeIntervalCaptureProgress, + toCaptureProgressNotifyParam(completion) + ) + } + + override fun onCaptureCompleted(fileUrls: List?) { + result.success(fileUrls) + } + }) + } + + fun stopCompositeIntervalCapture(result: Result) { + val theta = thetaRepository + val compositeIntervalCapturing = compositeIntervalCapturing + if (theta == null || compositeIntervalCapturing == null) { + result.error(errorCode, messageNotInit, null) + return + } + compositeIntervalCapturing.stopCapture() + result.success(null) + } + suspend fun listFiles(call: MethodCall, result: Result) { if (thetaRepository == null) { result.error(errorCode, messageNotInit, null) diff --git a/flutter/ios/Classes/ConvertUtil.swift b/flutter/ios/Classes/ConvertUtil.swift index 527c09f465..f5b95c5e70 100644 --- a/flutter/ios/Classes/ConvertUtil.swift +++ b/flutter/ios/Classes/ConvertUtil.swift @@ -264,6 +264,17 @@ func setShotCountSpecifiedIntervalCaptureBuilderParams(params: [String: Any], bu } } +func setCompositeIntervalCaptureBuilderParams(params: [String: Any], builder: CompositeIntervalCapture.Builder) { + if let interval = params["_capture_interval"] as? Int, + interval >= 0 + { + builder.setCheckStatusCommandInterval(timeMillis: Int64(interval)) + } + if let value = params[ThetaRepository.OptionNameEnum.compositeshootingoutputinterval.name] as? Int32 { + builder.setCompositeShootingOutputInterval(sec: value) + } +} + func toBitrate(value: Any) -> ThetaRepositoryBitrate? { if value is NSNumber, let intVal = value as? Int32 { return ThetaRepository.BitrateNumber(value: intVal) diff --git a/flutter/ios/Classes/SwiftThetaClientFlutterPlugin.swift b/flutter/ios/Classes/SwiftThetaClientFlutterPlugin.swift index d46257055c..174dd4ab90 100644 --- a/flutter/ios/Classes/SwiftThetaClientFlutterPlugin.swift +++ b/flutter/ios/Classes/SwiftThetaClientFlutterPlugin.swift @@ -10,6 +10,8 @@ let NOTIFY_VIDEO_CAPTURE_STOP_ERROR = 10003 let NOTIFY_LIMITLESS_INTERVAL_CAPTURE_STOP_ERROR = 10004 let NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_CAPTURE_PROGRESS = 10021 let NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_CAPTURE_STOP_ERROR = 10022 +let NOTIFY_COMPOSITE_INTERVAL_PROGRESS = 10031 +let NOTIFY_COMPOSITE_INTERVAL_STOP_ERROR = 10032 public class SwiftThetaClientFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { public func onListen(withArguments _: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { @@ -45,7 +47,10 @@ public class SwiftThetaClientFlutterPlugin: NSObject, FlutterPlugin, FlutterStre var shotCountSpecifiedIntervalCaptureBuilder: ShotCountSpecifiedIntervalCapture.Builder? = nil var shotCountSpecifiedIntervalCapture: ShotCountSpecifiedIntervalCapture? = nil var shotCountSpecifiedIntervalCapturing: ShotCountSpecifiedIntervalCapturing? = nil - + var compositeIntervalCaptureBuilder: CompositeIntervalCapture.Builder? = nil + var compositeIntervalCapture: CompositeIntervalCapture? = nil + var compositeIntervalCapturing: CompositeIntervalCapturing? = nil + public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "theta_client_flutter", binaryMessenger: registrar.messenger()) let instance = SwiftThetaClientFlutterPlugin() @@ -131,6 +136,14 @@ public class SwiftThetaClientFlutterPlugin: NSObject, FlutterPlugin, FlutterStre startShotCountSpecifiedIntervalCapture(result: result) case "stopShotCountSpecifiedIntervalCapture": stopShotCountSpecifiedIntervalCapture(result: result) + case "getCompositeIntervalCaptureBuilder": + getCompositeIntervalCaptureBuilder(call: call, result: result) + case "buildCompositeIntervalCapture": + buildCompositeIntervalCapture(call: call, result: result) + case "startCompositeIntervalCapture": + startCompositeIntervalCapture(result: result) + case "stopCompositeIntervalCapture": + stopCompositeIntervalCapture(result: result) case "getOptions": getOptions(call: call, result: result) case "setOptions": @@ -200,6 +213,9 @@ public class SwiftThetaClientFlutterPlugin: NSObject, FlutterPlugin, FlutterStre shotCountSpecifiedIntervalCaptureBuilder = nil shotCountSpecifiedIntervalCapture = nil shotCountSpecifiedIntervalCapturing = nil + compositeIntervalCaptureBuilder = nil + compositeIntervalCapture = nil + compositeIntervalCapturing = nil previewing = false thetaRepository = try await withCheckedThrowingContinuation { continuation in @@ -834,6 +850,94 @@ public class SwiftThetaClientFlutterPlugin: NSObject, FlutterPlugin, FlutterStre capturing.stopCapture() result(nil) } + + func getCompositeIntervalCaptureBuilder(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let thetaRepository else { + let flutterError = FlutterError(code: SwiftThetaClientFlutterPlugin.errorCode, message: SwiftThetaClientFlutterPlugin.messageNotInit, details: nil) + result(flutterError) + return + } + if let shootingTimeSec = call.arguments as? Int32 { + compositeIntervalCaptureBuilder = thetaRepository.getCompositeIntervalCaptureBuilder(shootingTimeSec: shootingTimeSec) + } + result(nil) + } + + func buildCompositeIntervalCapture(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let _ = thetaRepository, let builder = compositeIntervalCaptureBuilder else { + let flutterError = FlutterError(code: SwiftThetaClientFlutterPlugin.errorCode, message: SwiftThetaClientFlutterPlugin.messageNotInit, details: nil) + result(flutterError) + return + } + if let arguments = call.arguments as? [String: Any] { + setCaptureBuilderParams(params: arguments, builder: builder) + setCompositeIntervalCaptureBuilderParams(params: arguments, builder: builder) + } + builder.build(completionHandler: { capture, error in + if let thetaError = error { + let flutterError = FlutterError(code: SwiftThetaClientFlutterPlugin.errorCode, message: thetaError.localizedDescription, details: nil) + result(flutterError) + } else { + self.compositeIntervalCapture = capture + result(nil) + } + }) + } + + func startCompositeIntervalCapture(result: @escaping FlutterResult) { + guard let _ = thetaRepository, let capture = compositeIntervalCapture else { + let flutterError = FlutterError(code: SwiftThetaClientFlutterPlugin.errorCode, message: SwiftThetaClientFlutterPlugin.messageNotInit, details: nil) + result(flutterError) + return + } + class Callback: CompositeIntervalCaptureStartCaptureCallback { + let callback: (_ urls: [String]?, _ error: Error?) -> Void + weak var plugin: SwiftThetaClientFlutterPlugin? + init(_ callback: @escaping (_ urls: [String]?, _ error: Error?) -> Void, plugin: SwiftThetaClientFlutterPlugin) { + self.callback = callback + self.plugin = plugin + } + + func onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + callback(nil, exception.asError()) + } + + func onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + let error = exception.asError() + plugin?.sendNotifyEvent(id: NOTIFY_COMPOSITE_INTERVAL_STOP_ERROR, params: toMessageNotifyParam(message: error.localizedDescription)) + } + + func onProgress(completion: Float) { + plugin?.sendNotifyEvent(id: NOTIFY_COMPOSITE_INTERVAL_PROGRESS, params: toCaptureProgressNotifyParam(value: completion)) + } + + func onCaptureCompleted(fileUrls: [String]?) { + callback(fileUrls, nil) + } + } + + compositeIntervalCapturing = capture.startCapture( + callback: Callback({ fileUrl, error in + if let thetaError = error { + let flutterError = FlutterError(code: SwiftThetaClientFlutterPlugin.errorCode, message: thetaError.localizedDescription, details: nil) + result(flutterError) + } else { + result(fileUrl) + } + }, + plugin: self) + ) + } + + func stopCompositeIntervalCapture(result: @escaping FlutterResult) { + guard let _ = thetaRepository, let capturing = compositeIntervalCapturing else { + let flutterError = FlutterError(code: SwiftThetaClientFlutterPlugin.errorCode, message: SwiftThetaClientFlutterPlugin.messageNotInit, details: nil) + result(flutterError) + return + } + capturing.stopCapture() + result(nil) + } func getOptions(call: FlutterMethodCall, result: @escaping FlutterResult) { if thetaRepository == nil { diff --git a/flutter/lib/capture/capture.dart b/flutter/lib/capture/capture.dart index 251da609d4..039ba4514d 100644 --- a/flutter/lib/capture/capture.dart +++ b/flutter/lib/capture/capture.dart @@ -186,3 +186,35 @@ class ShotCountSpecifiedIntervalCapture extends Capture { return ShotCountSpecifiedIntervalCapturing(); } } + +/// Capture of interval composite shooting +class CompositeIntervalCapture extends Capture { + final int _interval; + + CompositeIntervalCapture(super.options, this._interval); + + int getCheckStatusCommandInterval() { + return _interval; + } + + /// Get In-progress save interval for interval composite shooting (sec). + int? getCompositeShootingOutputInterval() => + _options[OptionNameEnum.compositeShootingOutputInterval.rawValue]; + + /// Get Shooting time for interval composite shooting (sec). + int? getCompositeShootingTime() => + _options[OptionNameEnum.compositeShootingTime.rawValue]; + + /// Starts interval composite shooting. + CompositeIntervalCapturing startCapture( + void Function(List? fileUrls) onSuccess, + void Function(double completion) onProgress, + void Function(Exception exception) onCaptureFailed, + {void Function(Exception exception)? onStopFailed}) { + ThetaClientFlutterPlatform.instance + .startCompositeIntervalCapture(onProgress, onStopFailed) + .then((value) => onSuccess(value)) + .onError((error, stackTrace) => onCaptureFailed(error as Exception)); + return CompositeIntervalCapturing(); + } +} diff --git a/flutter/lib/capture/capture_builder.dart b/flutter/lib/capture/capture_builder.dart index be529ee787..87e89f6b02 100644 --- a/flutter/lib/capture/capture_builder.dart +++ b/flutter/lib/capture/capture_builder.dart @@ -249,6 +249,37 @@ class ShotCountSpecifiedIntervalCaptureBuilder } } +/// Builder of CompositeIntervalCapture +class CompositeIntervalCaptureBuilder + extends CaptureBuilder { + int _interval = -1; + + CompositeIntervalCaptureBuilder setCheckStatusCommandInterval( + int timeMillis) { + _interval = timeMillis; + return this; + } + + /// Set In-progress save interval for interval composite shooting (sec). + CompositeIntervalCaptureBuilder setCompositeShootingOutputInterval(int sec) { + _options[OptionNameEnum.compositeShootingOutputInterval.rawValue] = sec; + return this; + } + + /// Builds an instance of a CompositeIntervalCapture that has all the combined parameters of the Options that have been added to the Builder. + Future build() async { + var completer = Completer(); + try { + await ThetaClientFlutterPlatform.instance + .buildCompositeIntervalCapture(_options, _interval); + completer.complete(CompositeIntervalCapture(_options, _interval)); + } catch (e) { + completer.completeError(e); + } + return completer.future; + } +} + /// Photo image format used in PhotoCapture. enum PhotoFileFormatEnum { /// Image File format. diff --git a/flutter/lib/capture/capturing.dart b/flutter/lib/capture/capturing.dart index 6fbbf6d39d..fd15d585c0 100644 --- a/flutter/lib/capture/capturing.dart +++ b/flutter/lib/capture/capturing.dart @@ -9,7 +9,7 @@ abstract class Capturing { /// TimeShiftCapturing class TimeShiftCapturing extends Capturing { /// Stops TimeShift capture. - /// When call stopCapture() then call property callback. + /// When call stopCapture() then call property callback. @override void stopCapture() { ThetaClientFlutterPlatform.instance.stopTimeShiftCapture(); @@ -19,7 +19,7 @@ class TimeShiftCapturing extends Capturing { /// VideoCapturing class VideoCapturing extends Capturing { /// Stops video capture. - /// When call stopCapture() then call property callback. + /// When call stopCapture() then call property callback. @override void stopCapture() { ThetaClientFlutterPlatform.instance.stopVideoCapture(); @@ -29,7 +29,7 @@ class VideoCapturing extends Capturing { /// LimitlessIntervalCapturing class LimitlessIntervalCapturing extends Capturing { /// Stops limitless interval capture. - /// When call stopCapture() then call property callback. + /// When call stopCapture() then call property callback. @override void stopCapture() { ThetaClientFlutterPlatform.instance.stopLimitlessIntervalCapture(); @@ -39,9 +39,19 @@ class LimitlessIntervalCapturing extends Capturing { /// ShotCountSpecifiedIntervalCapturing class ShotCountSpecifiedIntervalCapturing extends Capturing { /// Stops interval shooting with the shot count specified. - /// When call stopCapture() then call property callback. + /// When call stopCapture() then call property callback. @override void stopCapture() { ThetaClientFlutterPlatform.instance.stopShotCountSpecifiedIntervalCapture(); } } + +/// CompositeIntervalCapturing +class CompositeIntervalCapturing extends Capturing { + /// Stops interval composite shooting. + /// When call stopCapture() then call property callback. + @override + void stopCapture() { + ThetaClientFlutterPlatform.instance.stopCompositeIntervalCapture(); + } +} diff --git a/flutter/lib/theta_client_flutter.dart b/flutter/lib/theta_client_flutter.dart index e2b066af0a..2b36566989 100644 --- a/flutter/lib/theta_client_flutter.dart +++ b/flutter/lib/theta_client_flutter.dart @@ -154,6 +154,14 @@ class ThetaClientFlutter { return ShotCountSpecifiedIntervalCaptureBuilder(); } + /// Get getCompositeIntervalCapture.Builder for capture interval composite shooting. + CompositeIntervalCaptureBuilder + getCompositeIntervalCaptureBuilder(int shootingTimeSec) { + ThetaClientFlutterPlatform.instance + .getCompositeIntervalCaptureBuilder(shootingTimeSec); + return CompositeIntervalCaptureBuilder(); + } + /// Acquires the properties and property support specifications for shooting, the camera, etc. /// /// Refer to the [options category](https://github.com/ricohapi/theta-api-specs/blob/main/theta-web-api-v2.1/options.md) diff --git a/flutter/lib/theta_client_flutter_method_channel.dart b/flutter/lib/theta_client_flutter_method_channel.dart index 12f81876af..d664033618 100644 --- a/flutter/lib/theta_client_flutter_method_channel.dart +++ b/flutter/lib/theta_client_flutter_method_channel.dart @@ -14,6 +14,8 @@ const notifyIdVideoCaptureStopError = 10003; const notifyIdLimitlessIntervalCaptureStopError = 10004; const notifyIdShotCountSpecifiedIntervalCaptureProgress = 10021; const notifyIdShotCountSpecifiedIntervalCaptureStopError = 10022; +const notifyIdCompositeIntervalCaptureProgress = 10031; +const notifyIdCompositeIntervalCaptureStopError = 10032; /// An implementation of [ThetaClientFlutterPlatform] that uses method channels. class MethodChannelThetaClientFlutter extends ThetaClientFlutterPlatform { @@ -428,6 +430,67 @@ class MethodChannelThetaClientFlutter extends ThetaClientFlutterPlatform { .invokeMethod('stopShotCountSpecifiedIntervalCapture'); } + @override + Future getCompositeIntervalCaptureBuilder( + int shootingTimeSec) async { + return methodChannel.invokeMethod( + 'getCompositeIntervalCaptureBuilder', shootingTimeSec); + } + + @override + Future buildCompositeIntervalCapture( + Map options, int interval) async { + final params = ConvertUtils.convertCaptureParams(options); + params['_capture_interval'] = interval; + return methodChannel.invokeMethod( + 'buildCompositeIntervalCapture', params); + } + + @override + Future?> startCompositeIntervalCapture( + void Function(double)? onProgress, + void Function(Exception exception)? onStopFailed) async { + var completer = Completer?>(); + try { + enableNotifyEventReceiver(); + if (onProgress != null) { + addNotify(notifyIdCompositeIntervalCaptureProgress, (params) { + final completion = params?['completion'] as double?; + if (completion != null) { + onProgress(completion); + } + }); + } + if (onStopFailed != null) { + addNotify(notifyIdCompositeIntervalCaptureStopError, (params) { + final message = params?['message'] as String?; + if (message != null) { + onStopFailed(Exception(message)); + } + }); + } + final fileUrls = await methodChannel.invokeMethod?>('startCompositeIntervalCapture'); + removeNotify(notifyIdCompositeIntervalCaptureProgress); + removeNotify(notifyIdCompositeIntervalCaptureStopError); + if (fileUrls == null) { + completer.complete(null); + } else { + completer.complete(ConvertUtils.convertStringList(fileUrls)); + } + } catch (e) { + removeNotify(notifyIdCompositeIntervalCaptureProgress); + removeNotify(notifyIdCompositeIntervalCaptureStopError); + completer.completeError(e); + } + return completer.future; + } + + @override + Future stopCompositeIntervalCapture() async { + return methodChannel + .invokeMethod('stopCompositeIntervalCapture'); + } + @override Future getOptions(List optionNames) async { var completer = Completer(); diff --git a/flutter/lib/theta_client_flutter_platform_interface.dart b/flutter/lib/theta_client_flutter_platform_interface.dart index bc02a1fd6a..c268bf8ec9 100644 --- a/flutter/lib/theta_client_flutter_platform_interface.dart +++ b/flutter/lib/theta_client_flutter_platform_interface.dart @@ -180,6 +180,29 @@ abstract class ThetaClientFlutterPlatform extends PlatformInterface { 'stopShotCountSpecifiedIntervalCapture() has not been implemented.'); } + Future getCompositeIntervalCaptureBuilder(int shootingTimeSec) { + throw UnimplementedError( + 'getCompositeIntervalCaptureBuilder() has not been implemented.'); + } + + Future buildCompositeIntervalCapture( + Map options, int interval) { + throw UnimplementedError( + 'buildCompositeIntervalCapture() has not been implemented.'); + } + + Future?> startCompositeIntervalCapture( + void Function(double)? onProgress, + void Function(Exception exception)? onStopFailed) { + throw UnimplementedError( + 'startCompositeIntervalCapture() has not been implemented.'); + } + + Future stopCompositeIntervalCapture() { + throw UnimplementedError( + 'stopCompositeIntervalCapture() has not been implemented.'); + } + Future getOptions(List optionNames) { throw UnimplementedError('getOptions() has not been implemented.'); } diff --git a/flutter/test/capture/composite_interval_capture_method_channel_test.dart b/flutter/test/capture/composite_interval_capture_method_channel_test.dart new file mode 100644 index 0000000000..cfcc770591 --- /dev/null +++ b/flutter/test/capture/composite_interval_capture_method_channel_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:theta_client_flutter/theta_client_flutter.dart'; +import 'package:theta_client_flutter/theta_client_flutter_method_channel.dart'; + +void main() { + MethodChannelThetaClientFlutter platform = MethodChannelThetaClientFlutter(); + const MethodChannel channel = MethodChannel('theta_client_flutter'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + platform = MethodChannelThetaClientFlutter(); + channel.setMockMethodCallHandler(null); + }); + + tearDown(() { + channel.setMockMethodCallHandler(null); + }); + + test('buildCompositeIntervalCapture', () async { + Map gpsInfoMap = { + 'latitude': 1.0, + 'longitude': 2.0, + 'altitude': 3.0, + 'dateTimeZone': '2022:01:01 00:01:00+09:00' + }; + List> data = [ + ['Aperture', ApertureEnum.aperture_2_0, 'APERTURE_2_0'], + ['ColorTemperature', 2, 2], + ['ExposureCompensation', ExposureCompensationEnum.m0_3, 'M0_3'], + ['ExposureDelay', ExposureDelayEnum.delay1, 'DELAY_1'], + [ + 'ExposureProgram', + ExposureProgramEnum.aperturePriority, + 'APERTURE_PRIORITY' + ], + [ + 'GpsInfo', + GpsInfo(1.0, 2.0, 3.0, '2022:01:01 00:01:00+09:00'), + gpsInfoMap + ], + ['GpsTagRecording', GpsTagRecordingEnum.on, 'ON'], + ['Iso', IsoEnum.iso50, 'ISO_50'], + ['IsoAutoHighLimit', IsoAutoHighLimitEnum.iso200, 'ISO_200'], + ['WhiteBalance', WhiteBalanceEnum.bulbFluorescent, 'BULB_FLUORESCENT'], + ]; + + Map options = {}; + for (int i = 0; i < data.length; i++) { + options[data[i][0]] = data[i][1]; + } + + channel.setMockMethodCallHandler((MethodCall methodCall) async { + var arguments = methodCall.arguments as Map; + expect(arguments['_capture_interval'], 1); + for (int i = 0; i < data.length; i++) { + expect(arguments[data[i][0]], data[i][2], reason: data[i][0]); + } + return Future.value(); + }); + await platform.buildCompositeIntervalCapture(options, 1); + }); + + test('startCompositeIntervalCapture', () async { + const fileUrls = [ + 'http://192.168.1.1/files/150100524436344d4201375fda9dc400/100RICOH/R0013336.MP4' + ]; + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return fileUrls; + }); + expect(await platform.startCompositeIntervalCapture(null, null), fileUrls); + }); + + test('startCompositeIntervalCapture no file', () async { + const fileUrls = null; + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return fileUrls; + }); + expect(await platform.startCompositeIntervalCapture(null, null), null); + }); + + test('startCompositeIntervalCapture exception', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + throw Exception('test error'); + }); + try { + await platform.startCompositeIntervalCapture(null, null); + expect(true, false, reason: 'not exception'); + } catch (error) { + expect(error.toString().contains('test error'), true); + } + }); + + test('progress of capture', () async { + const fileUrls = [ + 'http://192.168.1.1/files/150100524436344d4201375fda9dc400/100RICOH/R0013336.MP4' + ]; + channel.setMockMethodCallHandler((MethodCall methodCall) async { + expect(platform.notifyList.containsKey(10031), true, + reason: 'add notify progress'); + + // native event + platform.onNotify({ + 'id': 10031, + 'params': { + 'completion': 0.1, + }, + }); + await Future.delayed(const Duration(milliseconds: 10)); + platform.onNotify({ + 'id': 10031, + 'params': { + 'completion': 0.2, + }, + }); + await Future.delayed(const Duration(milliseconds: 10)); + + return fileUrls; + }); + + int progressCount = 0; + var resultCapture = platform.startCompositeIntervalCapture((completion) { + progressCount++; + }, null); + var result = await resultCapture.timeout(const Duration(seconds: 5)); + expect(result, fileUrls); + expect(progressCount, 2); + expect(platform.notifyList.containsKey(10031), false, + reason: 'remove notify progress'); + }); + + test('call onStopFailed', () async { + const fileUrls = [ + 'http://192.168.1.1/files/150100524436344d4201375fda9dc400/100RICOH/R0013336.MP4' + ]; + channel.setMockMethodCallHandler((MethodCall methodCall) async { + expect(platform.notifyList.containsKey(10032), true, + reason: 'add notify stop error'); + + await Future.delayed(const Duration(milliseconds: 1)); + // native event + platform.onNotify({ + 'id': 10032, + 'params': { + 'message': "stop error", + }, + }); + return fileUrls; + }); + + var isOnStopFailed = false; + var resultCapture = + platform.startCompositeIntervalCapture(null, (exception) { + isOnStopFailed = true; + }); + var result = await resultCapture.timeout(const Duration(seconds: 5)); + expect(result, fileUrls); + expect(platform.notifyList.containsKey(10032), false, + reason: 'remove notify stop error'); + expect(isOnStopFailed, true); + }); +} diff --git a/flutter/test/capture/composite_interval_capture_test.dart b/flutter/test/capture/composite_interval_capture_test.dart new file mode 100644 index 0000000000..196aedbd67 --- /dev/null +++ b/flutter/test/capture/composite_interval_capture_test.dart @@ -0,0 +1,277 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:theta_client_flutter/theta_client_flutter.dart'; +import 'package:theta_client_flutter/theta_client_flutter_platform_interface.dart'; + +import '../theta_client_flutter_test.dart'; + +void main() { + setUp(() {}); + + tearDown(() { + onCallGetCompositeIntervalCaptureBuilder = Future.value; + onCallBuildCompositeIntervalCapture = (options, interval) => Future.value(); + }); + + test('getCompositeIntervalCaptureBuilder', () async { + ThetaClientFlutter thetaClientPlugin = ThetaClientFlutter(); + MockThetaClientFlutterPlatform fakePlatform = + MockThetaClientFlutterPlatform(); + ThetaClientFlutterPlatform.instance = fakePlatform; + + const shootingTimeSec = 600; + var builder = + thetaClientPlugin.getCompositeIntervalCaptureBuilder(shootingTimeSec); + expect(builder, isNotNull); + }); + + test('buildCompositeIntervalCapture', () async { + ThetaClientFlutter thetaClientPlugin = ThetaClientFlutter(); + MockThetaClientFlutterPlatform fakePlatform = + MockThetaClientFlutterPlatform(); + ThetaClientFlutterPlatform.instance = fakePlatform; + + const shootingTimeSec = 600; + const compositeShootingOutputInterval = 60; + + const aperture = [ApertureEnum.aperture_2_0, 'Aperture']; + const colorTemperature = [2, 'ColorTemperature']; + const exposureCompensation = [ + ExposureCompensationEnum.m0_3, + 'ExposureCompensation' + ]; + const exposureDelay = [ExposureDelayEnum.delay1, 'ExposureDelay']; + const exposureProgram = [ + ExposureProgramEnum.aperturePriority, + 'ExposureProgram' + ]; + final gpsInfo = [ + GpsInfo(1.0, 2.0, 3.0, '2022:01:01 00:01:00+09:00'), + 'GpsInfo' + ]; + const gpsTagRecording = [GpsTagRecordingEnum.on, 'GpsTagRecording']; + const iso = [IsoEnum.iso100, 'Iso']; + const isoAutoHighLimit = [IsoAutoHighLimitEnum.iso125, 'IsoAutoHighLimit']; + const whiteBalance = [WhiteBalanceEnum.auto, 'WhiteBalance']; + + onCallBuildCompositeIntervalCapture = (options, interval) { + expect(options[aperture[1]], aperture[0]); + expect(options[colorTemperature[1]], colorTemperature[0]); + expect(options[exposureCompensation[1]], exposureCompensation[0]); + expect(options[exposureDelay[1]], exposureDelay[0]); + expect(options[exposureProgram[1]], exposureProgram[0]); + expect(options[gpsInfo[1]], gpsInfo[0]); + expect(options[gpsTagRecording[1]], gpsTagRecording[0]); + expect(options[iso[1]], iso[0]); + expect(options[isoAutoHighLimit[1]], isoAutoHighLimit[0]); + expect(options[whiteBalance[1]], whiteBalance[0]); + return Future.value(null); + }; + + final builder = + thetaClientPlugin.getCompositeIntervalCaptureBuilder(shootingTimeSec); + builder.setCompositeShootingOutputInterval(compositeShootingOutputInterval); + builder.setAperture(aperture[0] as ApertureEnum); + builder.setColorTemperature(colorTemperature[0] as int); + builder.setExposureCompensation( + exposureCompensation[0] as ExposureCompensationEnum); + builder.setExposureDelay(exposureDelay[0] as ExposureDelayEnum); + builder.setExposureProgram(exposureProgram[0] as ExposureProgramEnum); + builder.setGpsInfo(gpsInfo[0] as GpsInfo); + builder.setGpsTagRecording(gpsTagRecording[0] as GpsTagRecordingEnum); + builder.setIso(iso[0] as IsoEnum); + builder.setIsoAutoHighLimit(isoAutoHighLimit[0] as IsoAutoHighLimitEnum); + builder.setWhiteBalance(whiteBalance[0] as WhiteBalanceEnum); + + var capture = await builder.build(); + expect(capture, isNotNull); + expect(capture.getAperture(), aperture[0]); + expect(capture.getColorTemperature(), colorTemperature[0]); + expect(capture.getExposureCompensation(), exposureCompensation[0]); + expect(capture.getExposureDelay(), exposureDelay[0]); + expect(capture.getExposureProgram(), exposureProgram[0]); + expect(capture.getGpsInfo(), gpsInfo[0]); + expect(capture.getGpsTagRecording(), gpsTagRecording[0]); + expect(capture.getIso(), iso[0]); + expect(capture.getIsoAutoHighLimit(), isoAutoHighLimit[0]); + expect(capture.getWhiteBalance(), whiteBalance[0]); + expect(capture.getCompositeShootingOutputInterval(), + compositeShootingOutputInterval); + }); + + test('startCompositeIntervalCapture', () async { + ThetaClientFlutter thetaClientPlugin = ThetaClientFlutter(); + MockThetaClientFlutterPlatform fakePlatform = + MockThetaClientFlutterPlatform(); + ThetaClientFlutterPlatform.instance = fakePlatform; + + const shootingTimeSec = 600; + const imageUrls = ['http://test.jpeg']; + + onCallStartCompositeIntervalCapture = (onProgress, onStopFailed) { + return Future.value(imageUrls); + }; + + var builder = + thetaClientPlugin.getCompositeIntervalCaptureBuilder(shootingTimeSec); + var capture = await builder.build(); + List? fileUrls; + capture.startCapture( + (value) { + expect(value, imageUrls); + fileUrls = value; + }, + (completion) {}, + (exception) { + expect(false, isTrue, reason: 'Error. startCapture'); + }); + + await Future.delayed(const Duration(milliseconds: 10), () {}); + expect(fileUrls, imageUrls); + expect(capture.getAperture(), isNull); + }); + + test('startCompositeIntervalCapture Exception', () async { + ThetaClientFlutter thetaClientPlugin = ThetaClientFlutter(); + MockThetaClientFlutterPlatform fakePlatform = + MockThetaClientFlutterPlatform(); + ThetaClientFlutterPlatform.instance = fakePlatform; + + var completer = Completer>(); + onCallStartCompositeIntervalCapture = (onProgress, onStopFailed) { + return completer.future; + }; + onCallStopCompositeIntervalCapture = () { + completer + .completeError(Exception('Error. startCompositeIntervalCapture')); + return Future.value(); + }; + + const shootingTimeSec = 600; + var builder = + thetaClientPlugin.getCompositeIntervalCaptureBuilder(shootingTimeSec); + var capture = await builder.build(); + var capturing = capture.startCapture( + (fileUrl) { + expect(false, isTrue, reason: 'startCapture'); + }, + (completion) {}, + (exception) { + expect(exception, isNotNull, reason: 'Error. startCapture'); + }); + + capturing.stopCapture(); + await Future.delayed(const Duration(milliseconds: 10), () {}); + }); + + test('stopCompositeIntervalCapture', () async { + ThetaClientFlutter thetaClientPlugin = ThetaClientFlutter(); + MockThetaClientFlutterPlatform fakePlatform = + MockThetaClientFlutterPlatform(); + ThetaClientFlutterPlatform.instance = fakePlatform; + + const shootingTimeSec = 600; + const imageUrls = ['http://test.jpeg']; + + var completer = Completer>(); + onCallStartCompositeIntervalCapture = (onProgress, onStopFailed) { + return completer.future; + }; + onCallStopCompositeIntervalCapture = () { + completer.complete(imageUrls); + return Future.value(); + }; + + var builder = + thetaClientPlugin.getCompositeIntervalCaptureBuilder(shootingTimeSec); + var capture = await builder.build(); + List? fileUrls; + var capturing = capture.startCapture( + (value) { + expect(value, imageUrls); + fileUrls = value; + }, + (completion) {}, + (exception) { + expect(false, isTrue, reason: 'Error. startCapture'); + }); + + capturing.stopCapture(); + await Future.delayed(const Duration(milliseconds: 10), () {}); + expect(fileUrls, imageUrls); + }); + + test('call onStopFailed', () async { + ThetaClientFlutter thetaClientPlugin = ThetaClientFlutter(); + MockThetaClientFlutterPlatform fakePlatform = + MockThetaClientFlutterPlatform(); + ThetaClientFlutterPlatform.instance = fakePlatform; + + var completer = Completer(); + + void Function(Exception exception)? paramStopFailed; + + onCallStartCompositeIntervalCapture = (onProgress, onStopFailed) { + paramStopFailed = onStopFailed; + return Completer>().future; + }; + onCallStopCompositeIntervalCapture = () { + paramStopFailed?.call(Exception("on stop error.")); + return Future.value(); + }; + + const shootingTimeSec = 600; + var builder = + thetaClientPlugin.getCompositeIntervalCaptureBuilder(shootingTimeSec); + var capture = await builder.build(); + var isOnStopFailed = false; + var capturing = capture.startCapture( + (fileUrl) { + expect(false, isTrue, reason: 'startCapture'); + }, + (completion) {}, + (exception) {}, + onStopFailed: (exception) { + expect(exception, isNotNull, reason: 'Error. stopCapture'); + isOnStopFailed = true; + completer.complete(null); + }); + + capturing.stopCapture(); + await completer.future.timeout(const Duration(milliseconds: 10)); + expect(isOnStopFailed, true); + }); + + test('call onProgress', () async { + ThetaClientFlutter thetaClientPlugin = ThetaClientFlutter(); + MockThetaClientFlutterPlatform fakePlatform = + MockThetaClientFlutterPlatform(); + ThetaClientFlutterPlatform.instance = fakePlatform; + + var completer = Completer(); + + void Function(double completion)? paramOnProgress; + + onCallStartCompositeIntervalCapture = (onProgress, onStopFailed) { + paramOnProgress = onProgress; + return Completer>().future; + }; + + const shootingTimeSec = 600; + var builder = + thetaClientPlugin.getCompositeIntervalCaptureBuilder(shootingTimeSec); + var capture = await builder.build(); + var isOnProgress = false; + capture.startCapture((fileUrl) { + expect(false, isTrue, reason: 'startCapture'); + }, (completion) { + isOnProgress = true; + completer.complete(null); + }, (exception) {}); + + paramOnProgress?.call(0.1); + await completer.future.timeout(const Duration(milliseconds: 10)); + expect(isOnProgress, true); + }); +} diff --git a/flutter/test/theta_client_flutter_test.dart b/flutter/test/theta_client_flutter_test.dart index d411ba915f..9476009f80 100644 --- a/flutter/test/theta_client_flutter_test.dart +++ b/flutter/test/theta_client_flutter_test.dart @@ -152,6 +152,29 @@ class MockThetaClientFlutterPlatform return onCallStopShotCountSpecifiedIntervalCapture(); } + @override + Future buildCompositeIntervalCapture( + Map options, int interval) { + return onCallBuildCompositeIntervalCapture(options, interval); + } + + @override + Future getCompositeIntervalCaptureBuilder(int shootingTimeSec) { + return onCallGetCompositeIntervalCaptureBuilder(shootingTimeSec); + } + + @override + Future?> startCompositeIntervalCapture( + void Function(double)? onProgress, + void Function(Exception exception)? onStopFailed) { + return onCallStartCompositeIntervalCapture(onProgress, onStopFailed); + } + + @override + Future stopCompositeIntervalCapture() { + return onCallStopCompositeIntervalCapture(); + } + @override Future getOptions(List optionNames) { return onCallGetOptions(optionNames); @@ -354,6 +377,15 @@ Future?> Function(void Function(double)? onProgress, (onProgress, onStopFailed) => Future.value(); Future Function() onCallStopShotCountSpecifiedIntervalCapture = Future.value; +Future Function(int shootingTimeSec) + onCallGetCompositeIntervalCaptureBuilder = Future.value; +Future Function(Map options, int interval) + onCallBuildCompositeIntervalCapture = (options, interval) => Future.value(); +Future?> Function(void Function(double)? onProgress, + void Function(Exception exception)? onStopFailed) + onCallStartCompositeIntervalCapture = + (onProgress, onStopFailed) => Future.value(); +Future Function() onCallStopCompositeIntervalCapture = Future.value; Future Function(List optionNames) onCallGetOptions = (optionNames) => Future.value(Options()); Future Function(Options options) onCallSetOptions = Future.value; diff --git a/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/ThetaRepository.kt b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/ThetaRepository.kt index c5146e743a..4e9721cd9f 100644 --- a/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/ThetaRepository.kt +++ b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/ThetaRepository.kt @@ -1,10 +1,6 @@ package com.ricoh360.thetaclient -import com.ricoh360.thetaclient.capture.LimitlessIntervalCapture -import com.ricoh360.thetaclient.capture.PhotoCapture -import com.ricoh360.thetaclient.capture.ShotCountSpecifiedIntervalCapture -import com.ricoh360.thetaclient.capture.TimeShiftCapture -import com.ricoh360.thetaclient.capture.VideoCapture +import com.ricoh360.thetaclient.capture.* import com.ricoh360.thetaclient.transferred.* import io.ktor.client.call.* import io.ktor.client.plugins.* @@ -5593,9 +5589,9 @@ class ThetaRepository internal constructor(val endpoint: String, config: Config? } /** - * Get PhotoCapture.Builder for capture video. + * Get VideoCapture.Builder for capture video. * - * @return PhotoCapture.Builder + * @return VideoCapture.Builder */ fun getVideoCaptureBuilder(): VideoCapture.Builder { return VideoCapture.Builder(endpoint) @@ -5613,7 +5609,7 @@ class ThetaRepository internal constructor(val endpoint: String, config: Config? /** * Get LimitlessIntervalCapture.Builder for capture video. * - * @return PhotoCapture.Builder + * @return LimitlessIntervalCapture.Builder */ fun getLimitlessIntervalCaptureBuilder(): LimitlessIntervalCapture.Builder { return LimitlessIntervalCapture.Builder(endpoint, cameraModel) @@ -5628,6 +5624,15 @@ class ThetaRepository internal constructor(val endpoint: String, config: Config? return ShotCountSpecifiedIntervalCapture.Builder(shotCount, endpoint, cameraModel) } + /** + * Get CompositeIntervalCapture.Builder for interval composite shooting. + * + * @return CompositeIntervalCapture.Builder + */ + fun getCompositeIntervalCaptureBuilder(shootingTimeSec: Int): CompositeIntervalCapture.Builder { + return CompositeIntervalCapture.Builder(shootingTimeSec, endpoint) + } + /** * Base exception of ThetaRepository */ diff --git a/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCapture.kt b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCapture.kt new file mode 100644 index 0000000000..396fe1a0af --- /dev/null +++ b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCapture.kt @@ -0,0 +1,211 @@ +package com.ricoh360.thetaclient.capture + +import com.ricoh360.thetaclient.CHECK_COMMAND_STATUS_INTERVAL +import com.ricoh360.thetaclient.ThetaApi +import com.ricoh360.thetaclient.ThetaRepository +import com.ricoh360.thetaclient.transferred.* +import io.ktor.client.plugins.* +import io.ktor.serialization.* +import kotlinx.coroutines.* + +/* + * CompositeIntervalCapture + * + * @property endpoint URL of Theta web API endpoint + * @property options option of interval composite shooting + * @property checkCommandStatusInterval the interval for executing commands/status API when state "inProgress" + */ +class CompositeIntervalCapture private constructor( + private val endpoint: String, + options: Options, + private val checkStatusCommandInterval: Long +) : Capture(options) { + + private val scope = CoroutineScope(Dispatchers.Default) + + fun getCheckStatusCommandInterval(): Long { + return checkStatusCommandInterval + } + + /** + * Get In-progress save interval for interval composite shooting (sec). + */ + fun getCompositeShootingOutputInterval() = options._compositeShootingOutputInterval + + /** + * Get Shooting time for interval composite shooting (sec). + */ + fun getCompositeShootingTime() = options._compositeShootingTime + + // TODO: Add get photo option property + + /** + * Callback of startCapture + */ + interface StartCaptureCallback { + /** + * Called when state "inProgress". + * + * @param completion Progress rate of command executed + */ + fun onProgress(completion: Float) + + /** + * Called when stopCapture error occurs. + * + * @param exception Exception of error occurs + */ + fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) + + /** + * Called when error occurs. + * + * @param exception Exception of error occurs + */ + fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) + + /** + * Called when successful. + * + * @param fileUrls URLs of the limitless interval capture + */ + fun onCaptureCompleted(fileUrls: List?) + } + + private suspend fun monitorCommandStatus(id: String, callback: StartCaptureCallback) { + try { + var response: CommandApiResponse? = null + var state = CommandState.IN_PROGRESS + while (state == CommandState.IN_PROGRESS) { + delay(timeMillis = checkStatusCommandInterval) + response = ThetaApi.callStatusApi( + endpoint = endpoint, + params = StatusApiParams(id = id) + ) + callback.onProgress(completion = response.progress?.completion ?: 0f) + state = response.state + } + + if (response?.state == CommandState.DONE) { + val captureResponse = response as StartCaptureResponse + callback.onCaptureCompleted(fileUrls = captureResponse.results?.fileUrls ?: listOf()) + return + } + + response?.error?.let { error -> + if (error.isCanceledShootingCode()) { + callback.onCaptureCompleted(fileUrls = null) // canceled + } else { + callback.onCaptureFailed(exception = ThetaRepository.ThetaWebApiException(message = error.message)) + } + } ?: run { + callback.onCaptureCompleted(fileUrls = null) // canceled + } + } catch (e: JsonConvertException) { + callback.onCaptureFailed(exception = ThetaRepository.ThetaWebApiException(message = e.message ?: e.toString())) + } catch (e: ResponseException) { + callback.onCaptureFailed(exception = ThetaRepository.ThetaWebApiException.create(exception = e)) + } catch (e: Exception) { + callback.onCaptureFailed(exception = ThetaRepository.NotConnectedException(message = e.message ?: e.toString())) + } + } + + /** + * Starts interval composite shooting. + * + * @param callback Success or failure of the call + * @return CompositeIntervalCapturing instance + */ + fun startCapture(callback: StartCaptureCallback): CompositeIntervalCapturing { + scope.launch { + lateinit var startCaptureResponse: StartCaptureResponse + try { + val params = StartCaptureParams(_mode = ShootingMode.INTERVAL_COMPOSITE_SHOOTING) + + startCaptureResponse = ThetaApi.callStartCaptureCommand( + endpoint = endpoint, + params = params + ) + startCaptureResponse.error?.let { error -> + callback.onCaptureFailed(exception = ThetaRepository.ThetaWebApiException(message = error.message)) + return@launch + } + + delay(timeMillis = checkStatusCommandInterval) + startCaptureResponse.id?.let { + monitorCommandStatus(it, callback) + } + } catch (e: JsonConvertException) { + callback.onCaptureFailed(exception = ThetaRepository.ThetaWebApiException(message = e.message ?: e.toString())) + } catch (e: ResponseException) { + callback.onCaptureFailed(exception = ThetaRepository.ThetaWebApiException.create(exception = e)) + } catch (e: Exception) { + callback.onCaptureFailed(exception = ThetaRepository.NotConnectedException(message = e.message ?: e.toString())) + } + } + return CompositeIntervalCapturing(endpoint = endpoint, callback = callback) + } + + /* + * Builder of CompositeIntervalCapture + * + * @property shootingTimeSec Shooting time for interval composite shooting (sec) + * @property endpoint URL of Theta web API endpoint + */ + class Builder internal constructor(private val shootingTimeSec: Int, private val endpoint: String) : Capture.Builder() { + private var interval: Long? = null + + /** + * Builds an instance of a CompositeIntervalCapture that has all the combined parameters of the Options that have been added to the Builder. + * + * @return CompositeIntervalCapture + */ + @Throws(Throwable::class) + suspend fun build(): CompositeIntervalCapture { + try { + ThetaApi.callSetOptionsCommand( + endpoint = endpoint, + params = SetOptionsParams(options = Options(captureMode = CaptureMode.IMAGE)) + ).error?.let { + throw ThetaRepository.ThetaWebApiException(message = it.message) + } + + options._compositeShootingTime = shootingTimeSec + + ThetaApi.callSetOptionsCommand(endpoint = endpoint, params = SetOptionsParams(options)).error?.let { + throw ThetaRepository.ThetaWebApiException(message = it.message) + } + } catch (e: JsonConvertException) { + throw ThetaRepository.ThetaWebApiException(message = e.message ?: e.toString()) + } catch (e: ResponseException) { + throw ThetaRepository.ThetaWebApiException.create(exception = e) + } catch (e: ThetaRepository.ThetaWebApiException) { + throw e + } catch (e: Exception) { + throw ThetaRepository.NotConnectedException(message = e.message ?: e.toString()) + } + return CompositeIntervalCapture( + endpoint = endpoint, + options = options, + checkStatusCommandInterval = interval ?: CHECK_COMMAND_STATUS_INTERVAL + ) + } + + fun setCheckStatusCommandInterval(timeMillis: Long): Builder { + this.interval = timeMillis + return this + } + + /** + * Set In-progress save interval for interval composite shooting (sec). + * @param sec sec + * @return Builder + */ + fun setCompositeShootingOutputInterval(sec: Int): Builder { + options._compositeShootingOutputInterval = sec + return this + } + + // TODO: Add set photo option property + } +} diff --git a/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCapturing.kt b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCapturing.kt new file mode 100644 index 0000000000..a1b06f4f9b --- /dev/null +++ b/kotlin-multiplatform/src/commonMain/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCapturing.kt @@ -0,0 +1,54 @@ +package com.ricoh360.thetaclient.capture + +import com.ricoh360.thetaclient.ThetaApi +import com.ricoh360.thetaclient.ThetaRepository +import com.ricoh360.thetaclient.transferred.StopCaptureResponse +import io.ktor.client.plugins.ResponseException +import io.ktor.serialization.JsonConvertException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/* + * CompositeIntervalCapturing + * + * @property endpoint URL of Theta web API endpoint + * @property callback Success or failure of the call + */ +class CompositeIntervalCapturing internal constructor( + private val endpoint: String, + private val callback: CompositeIntervalCapture.StartCaptureCallback +) : Capturing() { + + private val scope = CoroutineScope(Dispatchers.Default) + + fun cancelCapture() { + stopCapture() + } + + /** + * Stops interval shooting with the shot count specified + * When call stopCapture() then call property callback. + */ + override fun stopCapture() { + scope.launch { + lateinit var response: StopCaptureResponse + try { + response = ThetaApi.callStopCaptureCommand(endpoint = endpoint) + response.error?.let { + callback.onStopFailed(exception = ThetaRepository.ThetaWebApiException(message = it.message)) + return@launch + } + } catch (e: JsonConvertException) { + callback.onStopFailed(exception = ThetaRepository.ThetaWebApiException(message = e.message ?: e.toString())) + return@launch + } catch (e: ResponseException) { + callback.onStopFailed(exception = ThetaRepository.ThetaWebApiException.create(exception = e)) + return@launch + } catch (e: Exception) { + callback.onStopFailed(exception = ThetaRepository.NotConnectedException(message = e.message ?: e.toString())) + return@launch + } + } + } +} diff --git a/kotlin-multiplatform/src/commonTest/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCaptureTest.kt b/kotlin-multiplatform/src/commonTest/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCaptureTest.kt new file mode 100644 index 0000000000..a5066097d6 --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/kotlin/com/ricoh360/thetaclient/capture/CompositeIntervalCaptureTest.kt @@ -0,0 +1,817 @@ +package com.ricoh360.thetaclient.capture + +import com.goncalossilva.resources.Resource +import com.ricoh360.thetaclient.CheckRequest +import com.ricoh360.thetaclient.MockApiClient +import com.ricoh360.thetaclient.ThetaRepository +import com.ricoh360.thetaclient.transferred.CaptureMode +import io.ktor.client.network.sockets.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.utils.io.* +import kotlinx.coroutines.* +import kotlinx.coroutines.test.runTest +import kotlin.test.* + +@OptIn(ExperimentalCoroutinesApi::class) +class CompositeIntervalCaptureTest { + private val endpoint = "http://192.168.1.1:80/" + + @BeforeTest + fun setup() { + MockApiClient.status = HttpStatusCode.OK + } + + @AfterTest + fun teardown() { + MockApiClient.status = HttpStatusCode.OK + } + + /** + * call startCapture. + */ + @Test + fun startCaptureTest() = runTest { + // setup + val responseArray = arrayOf( + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/start_capture_done.json").readText(), + ) + var counter = 0 + MockApiClient.onRequest = { request -> + val index = counter++ + + // check request + when (index) { + 0 -> { + CheckRequest.checkSetOptions(request = request, captureMode = CaptureMode.IMAGE) + } + + 1 -> { + CheckRequest.checkSetOptions(request = request, compositeShootingTime = 600) + } + + 2 -> { + CheckRequest.checkCommandName(request, "camera.startCapture") + } + } + + ByteReadChannel(responseArray[index]) + } + val deferred = CompletableDeferred() + + // execute + val thetaRepository = ThetaRepository(endpoint) + thetaRepository.cameraModel = ThetaRepository.ThetaModel.THETA_X + val capture = thetaRepository.getCompositeIntervalCaptureBuilder(600).build() + + var files: List? = null + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + files = fileUrls + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + assertTrue(completion >= 0f, "onProgress") + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "error start interval composite shooting") + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + withTimeout(30_000) { + deferred.await() + } + } + + // check result + assertTrue( + files?.firstOrNull()?.startsWith("http://") ?: false, + "start capture interval composite shooting" + ) + } + + /** + * call cancelCapture test + */ + @Test + fun cancelCaptureTest() = runTest { + // setup + var isStop = false + MockApiClient.onRequest = { request -> + val path = if (request.body.toString().contains("camera.stopCapture")) { + isStop = true + "src/commonTest/resources/CompositeIntervalCapture/stop_capture_done.json" + } else if (request.body.toString().contains("camera.setOptions")) { + "src/commonTest/resources/setOptions/set_options_done.json" + } else { + if (isStop) + "src/commonTest/resources/CompositeIntervalCapture/start_capture_done_empty.json" + else + "src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json" + } + + ByteReadChannel(Resource(path).readText()) + } + + val deferred = CompletableDeferred() + + // execute + val thetaRepository = ThetaRepository(endpoint) + thetaRepository.cameraModel = ThetaRepository.ThetaModel.THETA_X + val capture = thetaRepository.getCompositeIntervalCaptureBuilder(600).build() + + var files: List? = null + val capturing = + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + files = fileUrls + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + assertEquals(completion, 0f, "onProgress") + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "error start interval composite shooting") + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + delay(1000) + } + + capturing.cancelCapture() + + runBlocking { + withTimeout(7000) { + deferred.await() + } + } + + // check result + assertTrue( + files?.isEmpty() == true || files == null, + "cancel interval composite shooting" + ) + } + + /** + * Cancel shooting. + */ + @Test + fun cancelShootingTest() = runTest { + // setup + val responseArray = arrayOf( + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/start_capture_cancel.json").readText(), + ) + var counter = 0 + MockApiClient.onRequest = { _ -> + val index = counter++ + ByteReadChannel(responseArray[index]) + } + val deferred = CompletableDeferred() + + // execute + val thetaRepository = ThetaRepository(endpoint) + thetaRepository.cameraModel = ThetaRepository.ThetaModel.THETA_X + val capture = thetaRepository.getCompositeIntervalCaptureBuilder(600).build() + + var files: List? = listOf() + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + files = fileUrls + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + assertTrue(completion >= 0f, "onProgress") + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "error start interval composite shooting") + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + withTimeout(30_000) { + deferred.await() + } + } + + // check result + assertNull(files, "shooting is canceled") + } + + /** + * Setting CheckStatusCommandInterval. + */ + @Test + fun settingCheckStatusCommandIntervalTest() = runTest { + val timeMillis = 1500L + + MockApiClient.onRequest = { + ByteReadChannel(Resource("src/commonTest/resources/setOptions/set_options_done.json").readText()) + } + + // execute + val thetaRepository = ThetaRepository(endpoint) + thetaRepository.cameraModel = ThetaRepository.ThetaModel.THETA_X + val capture = thetaRepository.getCompositeIntervalCaptureBuilder(600) + .setCheckStatusCommandInterval(timeMillis) + .build() + + // check result + assertEquals( + capture.getCheckStatusCommandInterval(), + timeMillis, + "set CheckStatusCommandInterval $timeMillis" + ) + } + + /** + * Setting captureInterval. + */ + @Test + fun settingCaptureIntervalTest() = runTest { + val interval = 60 + + // setup + val responseArray = arrayOf( + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText() + ) + var counter = 0 + + MockApiClient.onRequest = { request -> + val index = counter++ + + // check request + when (index) { + 0 -> { + CheckRequest.checkSetOptions(request = request, captureMode = CaptureMode.IMAGE) + } + + 1 -> { + CheckRequest.checkSetOptions( + request = request, + compositeShootingOutputInterval = interval + ) + } + } + + ByteReadChannel(responseArray[index]) + } + + // execute + val thetaRepository = ThetaRepository(endpoint) + val capture = thetaRepository.getCompositeIntervalCaptureBuilder(600) + .setCompositeShootingOutputInterval(interval) + .build() + + // check result + assertEquals(capture.getCompositeShootingOutputInterval(), interval, "set option _compositeShootingOutputInterval $interval") + } + + /** + * Error response to build call. + */ + @Test + fun buildErrorResponseTest() = runTest { + // setup + val responseArray = arrayOf( + Resource("src/commonTest/resources/setOptions/set_options_error.json").readText(), // set captureMode error + "Not json" // json error + ) + var counter = 0 + + MockApiClient.onRequest = { _ -> + val index = counter++ + ByteReadChannel(responseArray[index]) + } + + // execute + val thetaRepository = ThetaRepository(endpoint) + thetaRepository.cameraModel = ThetaRepository.ThetaModel.THETA_X + + var exceptionSetCaptureMode = false + try { + thetaRepository.getCompositeIntervalCaptureBuilder(600).build() + } catch (e: ThetaRepository.ThetaWebApiException) { + assertTrue((e.message?.indexOf("UnitTest", 0, true) ?: -1) >= 0, "") + exceptionSetCaptureMode = true + } + assertTrue(exceptionSetCaptureMode, "setOptions captureMode error response") + + // execute not json response + var exceptionNotJson = false + try { + thetaRepository.getCompositeIntervalCaptureBuilder(600).build() + } catch (e: ThetaRepository.ThetaWebApiException) { + assertTrue( + (e.message?.indexOf("json", 0, true) ?: -1) >= 0 + || (e.message?.indexOf("Illegal", 0, true) ?: -1) >= 0, + "setOptions option not json error response" + ) + exceptionNotJson = true + } + assertTrue(exceptionNotJson, "setOptions option error response") + } + + /** + * Error exception to build call. + */ + @Test + fun buildExceptionTest() = runTest { + // setup + val responseArray = arrayOf( + Resource("src/commonTest/resources/setOptions/set_options_error.json").readText(), // status error & error json + "timeout UnitTest" // timeout + ) + var counter = 0 + + MockApiClient.onRequest = { _ -> + val index = counter++ + when (index) { + 0 -> MockApiClient.status = HttpStatusCode.ServiceUnavailable + 1 -> throw ConnectTimeoutException("timeout") + } + ByteReadChannel(responseArray[index]) + } + + // execute + val thetaRepository = ThetaRepository(endpoint) + thetaRepository.cameraModel = ThetaRepository.ThetaModel.THETA_X + + // execute status error and json response + var exceptionStatusJson = false + try { + thetaRepository.getCompositeIntervalCaptureBuilder(600).build() + } catch (e: ThetaRepository.ThetaWebApiException) { + assertTrue( + (e.message?.indexOf("UnitTest", 0, true) ?: -1) >= 0, + "status error and json response" + ) + exceptionStatusJson = true + } + assertTrue(exceptionStatusJson, "status error and json response") + + // execute timeout exception + var exceptionOther = false + try { + thetaRepository.getCompositeIntervalCaptureBuilder(600).build() + } catch (e: ThetaRepository.NotConnectedException) { + assertTrue((e.message?.indexOf("time", 0, true) ?: -1) >= 0, "timeout exception") + exceptionOther = true + } + assertTrue(exceptionOther, "other exception") + } + + /** + * Error response to startCapture call + */ + @Test + fun startCaptureErrorResponseTest() = runTest { + // setup + val responseArray = arrayOf( + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/start_capture_error.json").readText(), // startCapture error + "Not json" // json error + ) + var counter = 0 + MockApiClient.onRequest = { _ -> + val index = counter++ + ByteReadChannel(responseArray[index]) + } + + val thetaRepository = ThetaRepository(endpoint) + thetaRepository.cameraModel = ThetaRepository.ThetaModel.THETA_X + val capture = thetaRepository.getCompositeIntervalCaptureBuilder(600).build() + + // execute error response + var deferred = CompletableDeferred() + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue( + exception.message!!.indexOf("UnitTest", 0, true) >= 0, + "capture interval composite shooting error response" + ) + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + withTimeout(500) { + deferred.await() + } + } + + // execute json error response + deferred = CompletableDeferred() + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue( + exception.message!!.length >= 0, + "capture interval composite shooting json error response" + ) + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + withTimeout(2000) { + deferred.await() + } + } + } + + /** + * Error exception to startCapture call + */ + @Test + fun startCaptureExceptionTest() = runTest { + // setup + val responseArray = arrayOf( + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/start_capture_error.json").readText(), // startCapture error + "Status error UnitTest", // status error not json + "timeout UnitTest" // timeout + ) + var counter = 0 + MockApiClient.onRequest = { _ -> + val index = counter++ + when (index) { + 0 -> MockApiClient.status = HttpStatusCode.OK + 1 -> MockApiClient.status = HttpStatusCode.OK + 2 -> MockApiClient.status = HttpStatusCode.ServiceUnavailable + 3 -> MockApiClient.status = HttpStatusCode.ServiceUnavailable + 4 -> throw ConnectTimeoutException("timeout") + } + ByteReadChannel(responseArray[index]) + } + + val thetaRepository = ThetaRepository(endpoint) + val capture = thetaRepository.getCompositeIntervalCaptureBuilder(600) + .build() + + // execute status error and json response + var deferred = CompletableDeferred() + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue( + (exception.message?.indexOf("UnitTest", 0, true) ?: -1) >= 0, + "status error and json response" + ) + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + withTimeout(1000) { + deferred.await() + } + } + + // execute status error and not json response + deferred = CompletableDeferred() + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue((exception.message?.indexOf("503", 0, true) ?: -1) >= 0, "status error") + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + withTimeout(1000) { + deferred.await() + } + } + + // execute timeout exception + deferred = CompletableDeferred() + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue( + (exception.message?.indexOf("time", 0, true) ?: -1) >= 0, + "timeout exception" + ) + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + withTimeout(1000) { + deferred.await() + } + } + } + + /** + * Error response to stopCapture call + */ + @Test + fun stopCaptureErrorResponseTest() = runTest { + // setup + val responseArray = arrayOf( + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/setOptions/set_options_done.json").readText(), + Resource("src/commonTest/resources/CompositeIntervalCapture/stop_capture_error.json").readText(), // stopCapture error + "Not json" // json error + ) + var counter = 0 + MockApiClient.onRequest = { _ -> + val index = counter++ + ByteReadChannel(responseArray[index]) + } + var deferred = CompletableDeferred() + + // execute + val thetaRepository = ThetaRepository(endpoint) + thetaRepository.cameraModel = ThetaRepository.ThetaModel.THETA_X + val capture = thetaRepository.getCompositeIntervalCaptureBuilder(600).build() + + var capturing = + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue( + (exception.message?.indexOf("UnitTest", 0, true) ?: -1) >= 0, + "stop capture error response" + ) + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + delay(100) + } + + capturing.cancelCapture() + + runBlocking { + withTimeout(1000) { + deferred.await() + } + } + + deferred = CompletableDeferred() + capturing = + capture.startCapture(object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue( + (exception.message?.length ?: -1) >= 0, + "stop capture json error response" + ) + deferred.complete(Unit) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onStopFailed") + } + }) + + runBlocking { + delay(100) + } + + capturing.cancelCapture() + + runBlocking { + withTimeout(1000) { + deferred.await() + } + } + } + + /** + * Error exception to stopCapture call + */ + @Test + fun stopCaptureExceptionTest() = runTest { + // setup + val responseArray = arrayOf( + Resource("src/commonTest/resources/CompositeIntervalCapture/stop_capture_error.json").readText(), // status error & error json + "Status error UnitTest", // status error not json + "timeout UnitTest" // timeout + ) + var counter = 0 + MockApiClient.onRequest = { _ -> + val index = counter++ + when (index) { + 1 -> MockApiClient.status = HttpStatusCode.ServiceUnavailable + 2 -> MockApiClient.status = HttpStatusCode.ServiceUnavailable + 3 -> throw ConnectTimeoutException("timeout") + } + ByteReadChannel(responseArray[index]) + } + + var deferred = CompletableDeferred() + + var capturing = CompositeIntervalCapturing( + endpoint, + object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onCaptureFailed") + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue( + (exception.message?.indexOf("UnitTest", 0, true) ?: -1) >= 0, + "status error and json response" + ) + deferred.complete(Unit) + } + }) + + capturing.cancelCapture() + + runBlocking { + withTimeout(1000) { + deferred.await() + } + } + + deferred = CompletableDeferred() + capturing = CompositeIntervalCapturing( + endpoint, + object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onCaptureFailed") + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue( + (exception.message?.indexOf("503", 0, true) ?: -1) >= 0, + "status error" + ) + deferred.complete(Unit) + } + }) + + capturing.cancelCapture() + + runBlocking { + withTimeout(1000) { + deferred.await() + } + } + + deferred = CompletableDeferred() + capturing = CompositeIntervalCapturing( + endpoint, + object : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + assertTrue(false, "capture interval composite shooting") + deferred.complete(Unit) + } + + override fun onProgress(completion: Float) { + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue(false, "onCaptureFailed") + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + assertTrue( + (exception.message?.indexOf("time", 0, true) ?: -1) >= 0, + "timeout exception" + ) + deferred.complete(Unit) + } + }) + + capturing.cancelCapture() + + runBlocking { + withTimeout(5000) { + deferred.await() + } + } + } +} diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_cancel.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_cancel.json new file mode 100644 index 0000000000..77e77d98bd --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_cancel.json @@ -0,0 +1,8 @@ +{ + "error": { + "code": "canceledShooting", + "message": "shooting is canceled." + }, + "name": "camera.startCapture", + "state": "error" +} diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_done.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_done.json new file mode 100644 index 0000000000..31387e2444 --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_done.json @@ -0,0 +1 @@ +{"results":{"fileUrls":["http://192.168.1.1/files/100RICOH/R0010026.JPG"]},"name":"camera.startCapture","state":"done"} \ No newline at end of file diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_done_empty.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_done_empty.json new file mode 100644 index 0000000000..d8c34e3908 --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_done_empty.json @@ -0,0 +1 @@ +{"results":{"fileUrls":[]},"name":"camera.startCapture","state":"done"} \ No newline at end of file diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_error.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_error.json new file mode 100644 index 0000000000..177c574330 --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_error.json @@ -0,0 +1,8 @@ +{ + "error": { + "code": "UnitTest", + "message": "UnitTest error" + }, + "name": "camera.startCapture", + "state": "error" +} diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json new file mode 100644 index 0000000000..8818ce4691 --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/start_capture_progress.json @@ -0,0 +1 @@ +{"id":"2792","name":"camera.startCapture","progress":{"completion":0.00},"state":"inProgress"} diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_idle.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_idle.json new file mode 100644 index 0000000000..e9c43eff77 --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_idle.json @@ -0,0 +1,22 @@ +{ + "fingerprint": "FIG_0008", + "state": { + "_apiVersion": 2, + "batteryLevel": 0.81, + "_batteryState": "disconnect", + "_cameraError": [ + "COMPASS_CALIBRATION" + ], + "_captureStatus": "idle", + "_capturedPictures": 0, + "_compositeShootingElapsedTime": 0, + "_function": "selfTimer", + "_latestFileUrl": "http://192.168.1.1/files/150100524436344d4201375fda9dc400/100RICOH/R0013331.JPG", + "_mySettingChanged": false, + "_pluginRunning": false, + "_pluginWebServer": true, + "_recordableTime": 0, + "_recordedTime": 0, + "storageUri": "http://192.168.1.1/files/150100524436344d4201375fda9dc400/" + } +} diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_self_timer.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_self_timer.json new file mode 100644 index 0000000000..e34db1a80d --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_self_timer.json @@ -0,0 +1 @@ +{"fingerprint":"FIG_0003","state":{"batteryLevel":0.8,"storageUri":"http://192.168.1.1/files/thetasc26c21a247daf35838792bad9e","_apiVersion":2,"_batteryState":"charging","_cameraError":[],"_captureStatus":"idle","_capturedPictures":0,"_latestFileUrl":"http://192.168.1.1/files/thetasc26c21a247daf35838792bad9e/100RICOH/R0012313.JPG","_recordableTime":0,"_recordedTime":0,"_function":"selfTimer"}} diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_shooting.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_shooting.json new file mode 100644 index 0000000000..b6aecc5163 --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/state_shooting.json @@ -0,0 +1,22 @@ +{ + "fingerprint": "FIG_0008", + "state": { + "_apiVersion": 2, + "batteryLevel": 0.81, + "_batteryState": "disconnect", + "_cameraError": [ + "COMPASS_CALIBRATION" + ], + "_captureStatus": "shooting", + "_capturedPictures": 0, + "_compositeShootingElapsedTime": 0, + "_function": "selfTimer", + "_latestFileUrl": "http://192.168.1.1/files/150100524436344d4201375fda9dc400/100RICOH/R0013331.JPG", + "_mySettingChanged": false, + "_pluginRunning": false, + "_pluginWebServer": true, + "_recordableTime": 0, + "_recordedTime": 0, + "storageUri": "http://192.168.1.1/files/150100524436344d4201375fda9dc400/" + } +} diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/stop_capture_done.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/stop_capture_done.json new file mode 100644 index 0000000000..53f4b47b1c --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/stop_capture_done.json @@ -0,0 +1 @@ +{"name":"camera.stopCapture","state":"done"} \ No newline at end of file diff --git a/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/stop_capture_error.json b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/stop_capture_error.json new file mode 100644 index 0000000000..0fa6f9358c --- /dev/null +++ b/kotlin-multiplatform/src/commonTest/resources/CompositeIntervalCapture/stop_capture_error.json @@ -0,0 +1,8 @@ +{ + "error": { + "code": "UnitTest", + "message": "UnitTest error" + }, + "name": "camera.stopCapture", + "state": "error" +} diff --git a/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/Converter.kt b/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/Converter.kt index df85d31b69..d346f1a0f6 100644 --- a/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/Converter.kt +++ b/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/Converter.kt @@ -8,12 +8,7 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableArray import com.ricoh360.thetaclient.DigestAuth import com.ricoh360.thetaclient.ThetaRepository.* -import com.ricoh360.thetaclient.capture.Capture -import com.ricoh360.thetaclient.capture.PhotoCapture -import com.ricoh360.thetaclient.capture.TimeShiftCapture -import com.ricoh360.thetaclient.capture.VideoCapture -import com.ricoh360.thetaclient.capture.LimitlessIntervalCapture -import com.ricoh360.thetaclient.capture.ShotCountSpecifiedIntervalCapture +import com.ricoh360.thetaclient.capture.* const val KEY_NOTIFY_NAME = "name" const val KEY_NOTIFY_PARAMS = "params" @@ -205,6 +200,18 @@ fun setShotCountSpecifiedIntervalCaptureBuilderParams(optionMap: ReadableMap, bu } } +fun setCompositeIntervalCaptureBuilderParams(optionMap: ReadableMap, builder: CompositeIntervalCapture.Builder) { + val interval = if (optionMap.hasKey("_capture_interval")) optionMap.getInt("_capture_interval") else null + interval?.let { + if (it >= 0) { + builder.setCheckStatusCommandInterval(it.toLong()) + } + } + if (optionMap.hasKey("compositeShootingOutputInterval")) { + builder.setCompositeShootingOutputInterval(optionMap.getInt("compositeShootingOutputInterval")) + } +} + fun toGetOptionsParam(optionNames: ReadableArray): MutableList { val optionNameList = mutableListOf() for (index in 0..(optionNames.size() - 1)) { diff --git a/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/ThetaClientSdkModule.kt b/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/ThetaClientSdkModule.kt index 60e067bc1b..9b5a690e1c 100644 --- a/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/ThetaClientSdkModule.kt +++ b/react-native/android/src/main/java/com/ricoh360/thetaclientreactnative/ThetaClientSdkModule.kt @@ -34,6 +34,9 @@ class ThetaClientReactNativeModule( var shotCountSpecifiedIntervalCaptureBuilder: ShotCountSpecifiedIntervalCapture.Builder? = null var shotCountSpecifiedIntervalCapture: ShotCountSpecifiedIntervalCapture? = null var shotCountSpecifiedIntervalCapturing: ShotCountSpecifiedIntervalCapturing? = null + var compositeIntervalCaptureBuilder: CompositeIntervalCapture.Builder? = null + var compositeIntervalCapture: CompositeIntervalCapture? = null + var compositeIntervalCapturing: CompositeIntervalCapturing? = null var theta: ThetaRepository? = null var listenerCount: Int = 0 @@ -96,6 +99,9 @@ class ThetaClientReactNativeModule( shotCountSpecifiedIntervalCaptureBuilder = null shotCountSpecifiedIntervalCapture = null shotCountSpecifiedIntervalCapturing = null + compositeIntervalCaptureBuilder = null + compositeIntervalCapture = null + compositeIntervalCapturing = null theta = ThetaRepository.newInstance( endpoint, @@ -946,6 +952,119 @@ class ThetaClientReactNativeModule( promise.resolve(true) } + /** + * getCompositeIntervalCaptureBuilder - get interval composite shooting builder from repository + * @param shootingTimeSec Shooting time for interval composite shooting (sec) + * @param promise Promise for getCompositeIntervalCaptureBuilder + */ + @ReactMethod + fun getCompositeIntervalCaptureBuilder(shootingTimeSec: Int, promise: Promise) { + val theta = theta + if (theta == null) { + promise.reject(Exception(messageNotInit)) + return + } + compositeIntervalCaptureBuilder = theta.getCompositeIntervalCaptureBuilder(shootingTimeSec) + promise.resolve(true) + } + + /** + * buildCompositeIntervalCapture - build interval composite shooting + * @param options option to execute interval shooting with the shot count specified + * @param promise Promise for buildCompositeIntervalCapture + */ + @ReactMethod + fun buildCompositeIntervalCapture(options: ReadableMap, promise: Promise) { + if (theta == null) { + promise.reject(Exception(messageNotInit)) + return + } + if (compositeIntervalCaptureBuilder == null) { + promise.reject(Exception("no compositeIntervalCaptureBuilder")) + return + } + launch { + try { + compositeIntervalCaptureBuilder?.let { + setCaptureBuilderParams(optionMap = options, builder = it) + setCompositeIntervalCaptureBuilderParams(optionMap = options, builder = it) + compositeIntervalCapture = it.build() + } + promise.resolve(true) + compositeIntervalCaptureBuilder = null + } catch (t: Throwable) { + promise.reject(t) + compositeIntervalCaptureBuilder = null + } + } + } + + /** + * startCompositeIntervalCapture - start interval composite shooting + * @param promise promise for startCompositeIntervalCapture + */ + @ReactMethod + fun startCompositeIntervalCapture(promise: Promise) { + if (theta == null) { + promise.reject(Exception(messageNotInit)) + return + } + if (compositeIntervalCapture == null) { + promise.reject(Exception("no compositeIntervalCapture")) + return + } + class StartCaptureCallback : CompositeIntervalCapture.StartCaptureCallback { + override fun onCaptureCompleted(fileUrls: List?) { + promise.resolve(fileUrls?.let { + val resultList = Arguments.createArray() + it.forEach { + resultList.pushString(it) + } + resultList + }) + compositeIntervalCapture = null + } + + override fun onProgress(completion: Float) { + sendNotifyEvent( + toNotify( + NOTIFY_COMPOSITE_INTERVAL_PROGRESS, + toCaptureProgressNotifyParam(value = completion) + ) + ) + } + + override fun onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + sendNotifyEvent( + toNotify( + NOTIFY_COMPOSITE_INTERVAL_STOP_ERROR, + toMessageNotifyParam(exception.message ?: exception.toString()) + ) + ) + } + + override fun onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + promise.reject(exception) + compositeIntervalCapture = null + } + } + compositeIntervalCapturing = compositeIntervalCapture?.startCapture(StartCaptureCallback()) + } + + /** + * cancelCompositeIntervalCapture - stop interval composite shooting + * @param promise promise for cancelCompositeIntervalCapture + */ + @ReactMethod + fun cancelCompositeIntervalCapture(promise: Promise) { + if (theta == null) { + promise.reject(Exception(messageNotInit)) + return + } + compositeIntervalCapturing?.cancelCapture() + promise.resolve(true) + } + /** * getMetadata - retrieve meta data from THETA via repository * @param promise promise to set result @@ -1559,5 +1678,7 @@ class ThetaClientReactNativeModule( const val NOTIFY_LIMITLESS_INTERVAL_CAPTURE_STOP_ERROR = "LIMITLESS-INTERVAL-CAPTURE-STOP-ERROR" const val NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_PROGRESS = "SHOT-COUNT-SPECIFIED-INTERVAL-PROGRESS" const val NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_STOP_ERROR = "SHOT-COUNT-SPECIFIED-INTERVAL-STOP-ERROR" + const val NOTIFY_COMPOSITE_INTERVAL_PROGRESS = "COMPOSITE-INTERVAL-PROGRESS" + const val NOTIFY_COMPOSITE_INTERVAL_STOP_ERROR = "COMPOSITE-INTERVAL-STOP-ERROR" } } diff --git a/react-native/ios/ConvertUtil.swift b/react-native/ios/ConvertUtil.swift index d280840854..4c8a6fc89b 100644 --- a/react-native/ios/ConvertUtil.swift +++ b/react-native/ios/ConvertUtil.swift @@ -19,6 +19,7 @@ let KEY_THETA_MODEL = "thetaModel" let KEY_TIMESHIFT = "timeShift" let KEY_APERTURE = "aperture" let KEY_CAPTURE_INTERVAL = "captureInterval" +let KEY_COMPOSITE_SHOOTING_OUTPUT_INTERVAL = "compositeShootingOutputInterval" let KEY_COLOR_TEMPERATURE = "colorTemperature" let KEY_EXPOSURE_COMPENSATION = "exposureCompensation" let KEY_EXPOSURE_DELAY = "exposureDelay" @@ -558,6 +559,17 @@ func setShotCountSpecifiedIntervalCaptureBuilderParams(params: [String: Any], bu } } +func setCompositeIntervalCaptureBuilderParams(params: [String: Any], builder: CompositeIntervalCapture.Builder) { + if let interval = params[KEY_TIMESHIFT_CAPTURE_INTERVAL] as? Int, + interval >= 0 + { + builder.setCheckStatusCommandInterval(timeMillis: Int64(interval)) + } + if let value = params[KEY_COMPOSITE_SHOOTING_OUTPUT_INTERVAL] as? Int32 { + builder.setCompositeShootingOutputInterval(sec: value) + } +} + // MARK: - Utility func getEnumValue>(values: KotlinArray, name: String) -> E? { diff --git a/react-native/ios/ThetaClientReactNative.m b/react-native/ios/ThetaClientReactNative.m index c3be4fc8bf..2201b46c02 100644 --- a/react-native/ios/ThetaClientReactNative.m +++ b/react-native/ios/ThetaClientReactNative.m @@ -121,6 +121,20 @@ @interface RCT_EXTERN_MODULE(ThetaClientReactNative, NSObject) RCT_EXTERN_METHOD(cancelShotCountSpecifiedIntervalCapture:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(getCompositeIntervalCaptureBuilder:(int)shootingTimeSec + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(buildCompositeIntervalCapture:(NSDictionary*)options + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(startCompositeIntervalCapture:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(cancelCompositeIntervalCapture:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(getMetadata:(NSString*)fileUrl withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) diff --git a/react-native/ios/ThetaClientReactNative.swift b/react-native/ios/ThetaClientReactNative.swift index 09edc7aaa4..372ce707dd 100644 --- a/react-native/ios/ThetaClientReactNative.swift +++ b/react-native/ios/ThetaClientReactNative.swift @@ -18,6 +18,9 @@ let MESSAGE_NO_LIMITLESS_INTERVAL_CAPTURING = "no limitlessIntervalCapturing." let MESSAGE_NO_SHOT_COUNT_SPECIFIED_INTERVAL_CAPTURE = "No shotCountSpecifiedIntervalCapture." let MESSAGE_NO_SHOT_COUNT_SPECIFIED_INTERVAL_CAPTURE_BUILDER = "no shotCountSpecifiedIntervalCaptureBuilder." let MESSAGE_NO_SHOT_COUNT_SPECIFIED_INTERVAL_CAPTURING = "no shotCountSpecifiedIntervalCapturing." +let MESSAGE_NO_COMPOSITE_INTERVAL_CAPTURE = "No compositeIntervalCapture." +let MESSAGE_NO_COMPOSITE_INTERVAL_CAPTURE_BUILDER = "no compositeIntervalCaptureBuilder." +let MESSAGE_NO_COMPOSITE_INTERVAL_CAPTURING = "no compositeIntervalCapturing." @objc(ThetaClientReactNative) class ThetaClientReactNative: RCTEventEmitter { @@ -37,6 +40,9 @@ class ThetaClientReactNative: RCTEventEmitter { var shotCountSpecifiedIntervalCaptureBuilder: ShotCountSpecifiedIntervalCapture.Builder? var shotCountSpecifiedIntervalCapture: ShotCountSpecifiedIntervalCapture? var shotCountSpecifiedIntervalCapturing: ShotCountSpecifiedIntervalCapturing? + var compositeIntervalCaptureBuilder: CompositeIntervalCapture.Builder? + var compositeIntervalCapture: CompositeIntervalCapture? + var compositeIntervalCapturing: CompositeIntervalCapturing? static let EVENT_FRAME = "ThetaFrameEvent" static let EVENT_NOTIFY = "ThetaNotify" @@ -47,7 +53,9 @@ class ThetaClientReactNative: RCTEventEmitter { static let NOTIFY_SHOT_COUNT_SPECIFIED_INTERVAL_STOP_ERROR = "SHOT-COUNT-SPECIFIED-INTERVAL-STOP-ERROR" static let NOTIFY_VIDEO_CAPTURE_STOP_ERROR = "VIDEO-CAPTURE-STOP-ERROR" static let NOTIFY_LIMITLESS_INTERVAL_CAPTURE_STOP_ERROR = "LIMITLESS-INTERVAL-CAPTURE-STOP-ERROR" - + static let NOTIFY_COMPOSITE_INTERVAL_PROGRESS = "COMPOSITE-INTERVAL-PROGRESS" + static let NOTIFY_COMPOSITE_INTERVAL_STOP_ERROR = "COMPOSITE-INTERVAL-STOP-ERROR" + @objc override func supportedEvents() -> [String]! { return [ThetaClientReactNative.EVENT_FRAME, ThetaClientReactNative.EVENT_NOTIFY] @@ -88,6 +96,9 @@ class ThetaClientReactNative: RCTEventEmitter { shotCountSpecifiedIntervalCaptureBuilder = nil shotCountSpecifiedIntervalCapture = nil shotCountSpecifiedIntervalCapturing = nil + compositeIntervalCaptureBuilder = nil + compositeIntervalCapture = nil + compositeIntervalCapturing = nil previewing = false Task { @@ -1012,6 +1023,136 @@ class ThetaClientReactNative: RCTEventEmitter { shotCountSpecifiedIntervalCapturing.cancelCapture() resolve(nil) } + + @objc(getCompositeIntervalCaptureBuilder:withResolver:withRejecter:) + func getCompositeIntervalCaptureBuilder( + shootingTimeSec: Int, + resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + guard let thetaRepository else { + reject(ERROR_CODE_ERROR, MESSAGE_NOT_INIT, nil) + return + } + compositeIntervalCaptureBuilder = thetaRepository.getCompositeIntervalCaptureBuilder(shootingTimeSec: Int32(shootingTimeSec)) + resolve(nil) + } + + @objc(buildCompositeIntervalCapture:withResolver:withRejecter:) + func buildCompositeIntervalCapture( + options: [AnyHashable: Any]?, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard let _ = thetaRepository else { + reject(ERROR_CODE_ERROR, MESSAGE_NOT_INIT, nil) + return + } + guard let compositeIntervalCaptureBuilder else { + reject(ERROR_CODE_ERROR, MESSAGE_NO_COMPOSITE_INTERVAL_CAPTURE_BUILDER, nil) + return + } + + if let options = options as? [String: Any] { + setCaptureBuilderParams(params: options, builder: compositeIntervalCaptureBuilder) + setCompositeIntervalCaptureBuilderParams(params: options, builder: compositeIntervalCaptureBuilder) + } + compositeIntervalCaptureBuilder.build { capture, error in + if let error { + reject(ERROR_CODE_ERROR, error.localizedDescription, error) + } else if let capture { + self.compositeIntervalCapture = capture + self.compositeIntervalCaptureBuilder = nil + resolve(true) + } else { + reject(ERROR_CODE_ERROR, MESSAGE_NO_COMPOSITE_INTERVAL_CAPTURE, nil) + } + } + } + + @objc(startCompositeIntervalCapture:withRejecter:) + func startCompositeIntervalCapture( + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard let _ = thetaRepository else { + reject(ERROR_CODE_ERROR, MESSAGE_NOT_INIT, nil) + return + } + guard let compositeIntervalCapture else { + reject(ERROR_CODE_ERROR, MESSAGE_NO_COMPOSITE_INTERVAL_CAPTURE, nil) + return + } + + class Callback: CompositeIntervalCaptureStartCaptureCallback { + let callback: (_ urls: [String]?, _ error: Error?) -> Void + weak var client: ThetaClientReactNative? + init( + _ callback: @escaping (_ urls: [String]?, _ error: Error?) -> Void, + client: ThetaClientReactNative + ) { + self.callback = callback + self.client = client + } + + func onCaptureFailed(exception: ThetaRepository.ThetaRepositoryException) { + callback(nil, exception.asError()) + } + + func onProgress(completion: Float) { + client?.sendEvent( + withName: ThetaClientReactNative.EVENT_NOTIFY, + body: toNotify( + name: ThetaClientReactNative.NOTIFY_COMPOSITE_INTERVAL_PROGRESS, + params: toCaptureProgressNotifyParam(value: completion) + ) + ) + } + + func onCaptureCompleted(fileUrls: [String]?) { + callback(fileUrls, nil) + } + + func onStopFailed(exception: ThetaRepository.ThetaRepositoryException) { + let error = exception.asError() + client?.sendEvent( + withName: ThetaClientReactNative.EVENT_NOTIFY, + body: toNotify( + name: ThetaClientReactNative.NOTIFY_COMPOSITE_INTERVAL_STOP_ERROR, + params: toMessageNotifyParam(value: error.localizedDescription) + ) + ) + } + } + + compositeIntervalCapturing = compositeIntervalCapture.startCapture( + callback: Callback( + { url, error in + if let error { + reject(ERROR_CODE_ERROR, error.localizedDescription, error) + } else { + resolve(url) + } + }, client: self + )) + } + + @objc(cancelCompositeIntervalCapture:withRejecter:) + func cancelCompositeIntervalCapture( + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard let _ = thetaRepository else { + reject(ERROR_CODE_ERROR, MESSAGE_NOT_INIT, nil) + return + } + guard let compositeIntervalCapturing else { + reject(ERROR_CODE_ERROR, MESSAGE_NO_COMPOSITE_INTERVAL_CAPTURING, nil) + return + } + compositeIntervalCapturing.cancelCapture() + resolve(nil) + } @objc(getMetadata:withResolver:withRejecter:) func getMetadata( diff --git a/react-native/src/__mocks__/react-native.ts b/react-native/src/__mocks__/react-native.ts index 75ea20e162..744ad4beb9 100644 --- a/react-native/src/__mocks__/react-native.ts +++ b/react-native/src/__mocks__/react-native.ts @@ -22,6 +22,10 @@ export const NativeModules = { buildShotCountSpecifiedIntervalCapture: jest.fn(), startShotCountSpecifiedIntervalCapture: jest.fn(), cancelShotCountSpecifiedIntervalCapture: jest.fn(), + getCompositeIntervalCaptureBuilder: jest.fn(), + buildCompositeIntervalCapture: jest.fn(), + startCompositeIntervalCapture: jest.fn(), + cancelCompositeIntervalCapture: jest.fn(), }, }; diff --git a/react-native/src/__tests__/capture/composite-interval-capture.test.ts b/react-native/src/__tests__/capture/composite-interval-capture.test.ts new file mode 100644 index 0000000000..264b3f246e --- /dev/null +++ b/react-native/src/__tests__/capture/composite-interval-capture.test.ts @@ -0,0 +1,288 @@ +import { NativeModules } from 'react-native'; +import { + getCompositeIntervalCaptureBuilder, + initialize, +} from '../../theta-repository'; +import { + BaseNotify, + NotifyController, +} from '../../theta-repository/notify-controller'; +import { NativeEventEmitter_addListener } from '../../__mocks__/react-native'; + +describe('interval composite shooting', () => { + const thetaClient = NativeModules.ThetaClientReactNative; + + beforeEach(() => { + jest.clearAllMocks(); + NotifyController.instance.release(); + }); + + afterEach(() => { + thetaClient.initialize = jest.fn(); + thetaClient.buildCompositeIntervalCapture = jest.fn(); + thetaClient.startCompositeIntervalCapture = jest.fn(); + thetaClient.cancelCompositeIntervalCapture = jest.fn(); + NotifyController.instance.release(); + }); + + test('getCompositeIntervalCaptureBuilder', async () => { + jest.mocked(NativeEventEmitter_addListener).mockImplementation( + jest.fn(() => { + return { + remove: jest.fn(), + }; + }) + ); + const builder = getCompositeIntervalCaptureBuilder(600); + expect(builder.interval).toBeUndefined(); + + builder.setCheckStatusCommandInterval(1); + builder.setCompositeShootingOutputInterval(60); + + expect(builder.interval).toBe(1); + expect(builder.options.compositeShootingOutputInterval).toBe(60); + + let isCallBuild = false; + jest.mocked(thetaClient.buildCompositeIntervalCapture).mockImplementation( + jest.fn(async (options) => { + expect(options._capture_interval).toBe(1); + expect(options.compositeShootingOutputInterval).toBe(60); + isCallBuild = true; + }) + ); + + const capture = await builder.build(); + expect(capture).toBeDefined(); + expect(capture.notify).toBeDefined(); + expect(isCallBuild).toBeTruthy(); + + expect(thetaClient.getCompositeIntervalCaptureBuilder).toHaveBeenCalledWith( + 600 + ); + }); + + test('build no interval', async () => { + jest.mocked(NativeEventEmitter_addListener).mockImplementation( + jest.fn(() => { + return { + remove: jest.fn(), + }; + }) + ); + const builder = getCompositeIntervalCaptureBuilder(600); + expect(builder.interval).toBeUndefined(); + + jest.mocked(thetaClient.buildCompositeIntervalCapture).mockImplementation( + jest.fn(async (options) => { + expect(options._capture_interval).toBe(-1); + }) + ); + + const capture = await builder.build(); + expect(capture).toBeDefined(); + }); + + test('startCapture', async () => { + jest.mocked(NativeEventEmitter_addListener).mockImplementation( + jest.fn(() => { + return { + remove: jest.fn(), + }; + }) + ); + await initialize(); + const builder = getCompositeIntervalCaptureBuilder(600); + jest + .mocked(thetaClient.buildCompositeIntervalCapture) + .mockImplementation(jest.fn(async () => {})); + const testUrls = ['http://192.168.1.1/files/100RICOH/R100.JPG']; + jest.mocked(thetaClient.startCompositeIntervalCapture).mockImplementation( + jest.fn(async () => { + return testUrls; + }) + ); + + const capture = await builder.build(); + const fileUrls = await capture.startCapture(); + expect(fileUrls).toBe(testUrls); + expect(NotifyController.instance.notifyList.size).toBe(0); + }); + + test('cancelCapture', (done) => { + jest.mocked(NativeEventEmitter_addListener).mockImplementation( + jest.fn(() => { + return { + remove: jest.fn(), + }; + }) + ); + const builder = getCompositeIntervalCaptureBuilder(600); + jest + .mocked(thetaClient.buildCompositeIntervalCapture) + .mockImplementation(jest.fn(async () => {})); + jest.mocked(thetaClient.startCompositeIntervalCapture).mockImplementation( + jest.fn(async () => { + return null; + }) + ); + + builder.build().then((capture) => { + capture.startCapture().then((value) => { + expect(value).toBeUndefined(); + done(); + }); + capture.cancelCapture(); + expect(thetaClient.cancelCompositeIntervalCapture).toHaveBeenCalled(); + }); + }); + + test('exception', (done) => { + jest.mocked(NativeEventEmitter_addListener).mockImplementation( + jest.fn(() => { + return { + remove: jest.fn(), + }; + }) + ); + const builder = getCompositeIntervalCaptureBuilder(600); + jest + .mocked(thetaClient.buildCompositeIntervalCapture) + .mockImplementation(jest.fn(async () => {})); + jest.mocked(thetaClient.startCompositeIntervalCapture).mockImplementation( + jest.fn(async () => { + throw 'error'; + }) + ); + + builder.build().then((capture) => { + capture + .startCapture() + .then(() => { + expect(true).toBeFalsy(); + }) + .catch((error) => { + expect(error).toBe('error'); + done(); + }); + }); + }); + + test('progress events', async () => { + let notifyCallback: (notify: BaseNotify) => void = () => { + expect(true).toBeFalsy(); + }; + jest.mocked(NativeEventEmitter_addListener).mockImplementation( + jest.fn((_, callback) => { + notifyCallback = callback; + return { + remove: jest.fn(), + }; + }) + ); + + await initialize(); + const builder = getCompositeIntervalCaptureBuilder(600); + jest + .mocked(thetaClient.buildCompositeIntervalCapture) + .mockImplementation(jest.fn(async () => {})); + const testUrl = 'http://192.168.1.1/files/100RICOH/R100.JPG'; + + const sendProgress = (progress: number) => { + notifyCallback({ + name: 'COMPOSITE-INTERVAL-PROGRESS', + params: { + completion: progress, + }, + }); + }; + + jest.mocked(thetaClient.startCompositeIntervalCapture).mockImplementation( + jest.fn(async () => { + sendProgress(0.5); + return testUrl; + }) + ); + + const capture = await builder.build(); + let isOnProgress = false; + const fileUrl = await capture.startCapture((completion) => { + expect(completion).toBe(0.5); + isOnProgress = true; + }); + expect(fileUrl).toBe(testUrl); + + let done: (value: unknown) => void; + const promise = new Promise((resolve) => { + done = resolve; + }); + + setTimeout(() => { + expect(NotifyController.instance.notifyList.size).toBe(0); + expect(isOnProgress).toBeTruthy(); + done(0); + }, 1); + + return promise; + }); + + test('stop error events', async () => { + let notifyCallback: (notify: BaseNotify) => void = () => { + expect(true).toBeFalsy(); + }; + jest.mocked(NativeEventEmitter_addListener).mockImplementation( + jest.fn((_, callback) => { + notifyCallback = callback; + return { + remove: jest.fn(), + }; + }) + ); + + await initialize(); + const builder = getCompositeIntervalCaptureBuilder(600); + jest + .mocked(thetaClient.buildCompositeIntervalCapture) + .mockImplementation(jest.fn(async () => {})); + const testUrl = 'http://192.168.1.1/files/100RICOH/R100.JPG'; + + const sendStopError = (message: string) => { + notifyCallback({ + name: 'COMPOSITE-INTERVAL-STOP-ERROR', + params: { + message, + }, + }); + }; + + jest.mocked(thetaClient.startCompositeIntervalCapture).mockImplementation( + jest.fn(async () => { + sendStopError('stop error'); + return testUrl; + }) + ); + + const capture = await builder.build(); + let isOnStopError = false; + const fileUrl = await capture.startCapture( + () => {}, + (error) => { + expect(error.message).toBe('stop error'); + isOnStopError = true; + } + ); + expect(fileUrl).toBe(testUrl); + + let done: (value: unknown) => void; + const promise = new Promise((resolve) => { + done = resolve; + }); + + setTimeout(() => { + expect(NotifyController.instance.notifyList.size).toBe(0); + expect(isOnStopError).toBeTruthy(); + done(0); + }, 1); + + return promise; + }); +}); diff --git a/react-native/src/capture/composite-interval-capture.ts b/react-native/src/capture/composite-interval-capture.ts new file mode 100644 index 0000000000..9838b7ef1b --- /dev/null +++ b/react-native/src/capture/composite-interval-capture.ts @@ -0,0 +1,140 @@ +import { CaptureBuilder } from './capture'; +import { NativeModules } from 'react-native'; +import { + BaseNotify, + NotifyController, +} from '../theta-repository/notify-controller'; +const ThetaClientReactNative = NativeModules.ThetaClientReactNative; + +const NOTIFY_PROGRESS = 'COMPOSITE-INTERVAL-PROGRESS'; +const NOTIFY_STOP_ERROR = 'COMPOSITE-INTERVAL-STOP-ERROR'; + +interface CaptureProgressNotify extends BaseNotify { + params?: { + completion: number; + }; +} + +interface CaptureStopErrorNotify extends BaseNotify { + params?: { + message: string; + }; +} + +/** + * CompositeIntervalCapture class + */ +export class CompositeIntervalCapture { + notify: NotifyController; + constructor(notify: NotifyController) { + this.notify = notify; + } + /** + * start interval composite shooting + * @param onProgress the block for interval composite shooting onProgress + * @return promise of captured file url + */ + async startCapture( + onProgress?: (completion?: number) => void, + onStopFailed?: (error: any) => void + ): Promise { + if (onProgress) { + this.notify.addNotify(NOTIFY_PROGRESS, (event: CaptureProgressNotify) => { + onProgress(event.params?.completion); + }); + } + if (onStopFailed) { + this.notify.addNotify( + NOTIFY_STOP_ERROR, + (event: CaptureStopErrorNotify) => { + onStopFailed(event.params); + } + ); + } + + return new Promise(async (resolve, reject) => { + await ThetaClientReactNative.startCompositeIntervalCapture() + .then((result?: string[]) => { + resolve(result ?? undefined); + }) + .catch((error: any) => { + reject(error); + }) + .finally(() => { + this.notify.removeNotify(NOTIFY_PROGRESS); + this.notify.removeNotify(NOTIFY_STOP_ERROR); + }); + }); + } + + /** + * cancel interval composite shooting + */ + async cancelCapture(): Promise { + return await ThetaClientReactNative.cancelCompositeIntervalCapture(); + } +} + +/** + * CompositeIntervalCaptureBuilder class + */ +export class CompositeIntervalCaptureBuilder extends CaptureBuilder { + interval?: number; + readonly shootingTimeSec: number; + + /** construct CompositeIntervalCaptureBuilder instance */ + constructor(shootingTimeSec: number) { + super(); + + this.interval = undefined; + this.shootingTimeSec = shootingTimeSec; + } + + /** + * set interval of checking interval composite shooting status command + * @param timeMillis interval + * @returns CompositeIntervalCaptureBuilder + */ + setCheckStatusCommandInterval( + timeMillis: number + ): CompositeIntervalCaptureBuilder { + this.interval = timeMillis; + return this; + } + + /** + * Set In-progress save interval for interval composite shooting (sec). + * @param {interval} sec sec. + * @return CompositeIntervalCaptureBuilder + */ + setCompositeShootingOutputInterval( + sec: number + ): CompositeIntervalCaptureBuilder { + this.options.compositeShootingOutputInterval = sec; + return this; + } + + /** + * Builds an instance of a CompositeIntervalCapture that has all the combined + * parameters of the Options that have been added to the Builder. + * @return promise of CompositeIntervalCapture instance + */ + build(): Promise { + let params = { + ...this.options, + // Cannot pass negative values in IOS, use objects + _capture_interval: this.interval ?? -1, + }; + return new Promise(async (resolve, reject) => { + try { + await ThetaClientReactNative.getCompositeIntervalCaptureBuilder( + this.shootingTimeSec + ); + await ThetaClientReactNative.buildCompositeIntervalCapture(params); + resolve(new CompositeIntervalCapture(NotifyController.instance)); + } catch (error) { + reject(error); + } + }); + } +} diff --git a/react-native/src/capture/index.ts b/react-native/src/capture/index.ts index 0a59aae274..a5f88412ea 100644 --- a/react-native/src/capture/index.ts +++ b/react-native/src/capture/index.ts @@ -4,3 +4,4 @@ export * from './video-capture'; export * from './time-shift-capture'; export * from './limitless-interval-capture'; export * from './shot-count-specified-interval-capture'; +export * from './composite-interval-capture'; diff --git a/react-native/src/theta-repository/theta-repository.ts b/react-native/src/theta-repository/theta-repository.ts index 5f7b0ce192..720b3a43e3 100644 --- a/react-native/src/theta-repository/theta-repository.ts +++ b/react-native/src/theta-repository/theta-repository.ts @@ -18,6 +18,7 @@ import { TimeShiftCaptureBuilder, LimitlessIntervalCaptureBuilder, ShotCountSpecifiedIntervalCaptureBuilder, + CompositeIntervalCaptureBuilder, } from '../capture'; import type { ThetaConfig } from './theta-config'; import type { ThetaTimeout } from './theta-timeout'; @@ -117,6 +118,19 @@ export function getShotCountSpecifiedIntervalCaptureBuilder( return new ShotCountSpecifiedIntervalCaptureBuilder(shotCount); } +/** + * Get CompositeIntervalCapture.Builder for take interval composite shooting. + * + * @function getCompositeIntervalCaptureBuilder + * @param {shootingTimeSec} Shooting time for interval composite shooting (sec) + * @return created CompositeIntervalCaptureBuilder instance + */ +export function getCompositeIntervalCaptureBuilder( + shootingTimeSec: number +): CompositeIntervalCaptureBuilder { + return new CompositeIntervalCaptureBuilder(shootingTimeSec); +} + /** * Start live preview. * preview frame (JPEG DataURL) send THETA_EVENT_NAME event as diff --git a/react-native/verification-tool/src/App.tsx b/react-native/verification-tool/src/App.tsx index 533b51979a..e30275507a 100644 --- a/react-native/verification-tool/src/App.tsx +++ b/react-native/verification-tool/src/App.tsx @@ -19,6 +19,7 @@ import GetInfoScreen from './screen/get-info-screen/get-info-screen'; import TimeShiftCaptureScreen from './screen/time-shift-capture-screen/time-shift-capture-screen'; import LimitlessIntervalCaptureScreen from './screen/limitless-interval-capture-screen/limitless-interval-capture-screen'; import ShotCountSpecifiedIntervalCaptureScreen from './screen/shot-count-specified-interval-capture-screen/shot-count-specified-interval-capture-screen'; +import CompositeIntervalCaptureScreen from './screen/composite-interval-capture-screen/composite-interval-capture-screen'; const Stack = createNativeStackNavigator(); @@ -107,6 +108,11 @@ const App = () => { name="shotCountSpecifiedIntervalCapture" component={ShotCountSpecifiedIntervalCaptureScreen} /> + { + const [interval, setInterval] = React.useState(); + const [shootingTimeSec, setShootingTimeSec] = React.useState(600); + const [message, setMessage] = React.useState('progress = 0'); + const [captureOptions, setCaptureOptions] = React.useState(); + const [isTaking, setIsTaking] = React.useState(false); + const [capture, setCapture] = React.useState(); + + const onTake = async () => { + if (isTaking) { + return; + } + + const builder = getCompositeIntervalCaptureBuilder(shootingTimeSec); + if (interval != null) { + builder.setCheckStatusCommandInterval(interval); + } + + if (captureOptions?.compositeShootingOutputInterval != null) { + builder.setCompositeShootingOutputInterval( + captureOptions.compositeShootingOutputInterval + ); + } + captureOptions?.aperture && builder.setAperture(captureOptions.aperture); + if (captureOptions?.colorTemperature != null) { + builder.setColorTemperature(captureOptions.colorTemperature); + } + captureOptions?.exposureCompensation && + builder.setExposureCompensation(captureOptions.exposureCompensation); + captureOptions?.exposureDelay && + builder.setExposureDelay(captureOptions.exposureDelay); + captureOptions?.exposureProgram && + builder.setExposureProgram(captureOptions.exposureProgram); + captureOptions?.gpsInfo && builder.setGpsInfo(captureOptions.gpsInfo); + captureOptions?._gpsTagRecording && + builder.setGpsTagRecording(captureOptions._gpsTagRecording); + captureOptions?.iso && builder.setIso(captureOptions.iso); + captureOptions?.isoAutoHighLimit && + builder.setIsoAutoHighLimit(captureOptions.isoAutoHighLimit); + captureOptions?.whiteBalance && + builder.setWhiteBalance(captureOptions.whiteBalance); + + console.log('CompositeIntervalCapture interval: ' + interval); + console.log( + 'CompositeIntervalCapture options: ' + JSON.stringify(captureOptions) + ); + console.log('CompositeIntervalCapture builder: ' + JSON.stringify(builder)); + + try { + setCapture(await builder.build()); + setIsTaking(false); + } catch (error) { + setIsTaking(false); + Alert.alert( + 'CompositeIntervalCapture build error', + JSON.stringify(error), + [{ text: 'OK' }] + ); + } + }; + + const initCapture = () => { + setCapture(undefined); + setIsTaking(false); + }; + + const startCapture = async () => { + if (capture == null) { + initCapture(); + return; + } + try { + console.log('CompositeIntervalCapture startCapture'); + const urls = await capture.startCapture( + (completion) => { + if (isTaking) return; + setMessage(`progress = ${completion}`); + }, + (error) => { + Alert.alert('Cancel error', JSON.stringify(error), [{ text: 'OK' }]); + } + ); + initCapture(); + if (urls) { + Alert.alert(`file ${urls.length} urls : `, urls.join('\n'), [ + { text: 'OK' }, + ]); + } else { + Alert.alert('Capture', 'Capture cancel', [{ text: 'OK' }]); + } + } catch (error) { + initCapture(); + Alert.alert('startCapture error', JSON.stringify(error), [ + { text: 'OK' }, + ]); + } + }; + + const onCancel = async () => { + if (capture == null) { + return; + } + console.log('ready to cancel...'); + try { + capture.cancelCapture(); + } catch (error) { + Alert.alert('stopCapture error', JSON.stringify(error), [{ text: 'OK' }]); + } + }; + + const onStopSelfTimer = async () => { + try { + await stopSelfTimer(); + } catch (error) { + Alert.alert('stopSelfTimer error', JSON.stringify(error), [ + { text: 'OK' }, + ]); + } + }; + + React.useEffect(() => { + navigation.setOptions({ + title: 'interval composite shooting', + }); + }, [navigation]); + + React.useEffect(() => { + if (capture != null && !isTaking) { + setIsTaking(true); + startCapture(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [capture]); + + return ( + + + {message} + +