diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..18299da5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: type:bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots or screen recordings to help explain your problem. If they are too private for this public space, feel free to [send them by email](mailto:gallery.aves@gmail.com). + +**System information and logs:** +In the app, there are instructions in the `About` page > `Bug Report` section. After following them, paste here your system information and attach your logs. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..9ffcbad8b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: type:feature +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d3c5755b3..c13f83cb7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -15,7 +15,7 @@ jobs: - uses: subosito/flutter-action@v1 with: channel: stable - flutter-version: '2.5.1' + flutter-version: '2.5.3' - name: Clone the repository. uses: actions/checkout@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfe1a81fc..f3b846a16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: - uses: subosito/flutter-action@v1 with: channel: stable - flutter-version: '2.5.1' + flutter-version: '2.5.3' # Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1): # https://issuetracker.google.com/issues/144111441 @@ -50,8 +50,9 @@ jobs: echo "${{ secrets.KEY_JKS }}" > release.keystore.asc gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE rm release.keystore.asc - flutter build apk --bundle-sksl-path shaders_2.5.1.sksl.json - flutter build appbundle --bundle-sksl-path shaders_2.5.1.sksl.json + flutter build appbundle --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json + flutter build apk --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json + flutter build apk --flavor byAbi --split-per-abi --bundle-sksl-path shaders_2.5.3.sksl.json rm $AVES_STORE_FILE env: AVES_STORE_FILE: ${{ github.workspace }}/key.jks @@ -63,14 +64,14 @@ jobs: - name: Create a release with the APK and App Bundle. uses: ncipollo/release-action@v1 with: - artifacts: "build/app/outputs/apk/release/*.apk,build/app/outputs/bundle/release/*.aab" + artifacts: "build/app/outputs/bundle/universalRelease/*.aab,build/app/outputs/apk/universal/release/*.apk,build/app/outputs/apk/byAbi/release/*.apk" token: ${{ secrets.GITHUB_TOKEN }} - name: Upload app bundle uses: actions/upload-artifact@v2 with: name: appbundle - path: build/app/outputs/bundle/release/app-release.aab + path: build/app/outputs/bundle/universalRelease/app-universal-release.aab release: name: Create beta release on Play Store. @@ -89,7 +90,7 @@ jobs: with: serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }} packageName: deckers.thibault.aves - releaseFiles: app-release.aab + releaseFiles: app-universal-release.aab track: beta status: completed whatsNewDirectory: whatsnew diff --git a/CHANGELOG.md b/CHANGELOG.md index a84cf7c6d..08cf0d672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,50 +1,88 @@ # Changelog + All notable changes to this project will be documented in this file. ## [Unreleased] -## [v1.5.3] - 2021-09-30 ### Added + +- Collection: use a foreground service when scanning many items +- Collection: ask to rename/replace/skip when moving items with name conflict +- Map: filter to view items from a specific region in the Collection page +- Viewer: option to show/hide overlay on opening +- Info: improved display for PNG text metadata, XMP and others +- Export: output format selection +- Search: added raw filter +- Support modifying files in the Download folder on Android 11+ + +### Changed + +- upgraded Flutter to stable v2.5.3 +- use build flavors to generate universal or split APKs + +### Fixed + +- hide root album of hidden path +- gesture & spacing handling for Android 10+ navigation gestures +- renaming was leaving behind obsolete items in some cases +- speeding up videos on Xiaomi devices + +## [v1.5.3] - 2021-09-30 + +### Added + - Map: show items for bounds, open items in viewer, tap gesture to toggle fullscreen - Info: remove metadata (Exif, XMP, etc.) - Accessibility: support "time to take action" and "remove animations" settings ### Changed + - upgraded Flutter to stable v2.5.1 - faster collection loading when launching the app - Collection: changed color & scale of thumbnail icons to match text - Albums / Countries / Tags: changed layout, with label below cover ### Fixed + - album bookmarks & pins were reset when rescanning items ## [v1.5.2] - 2021-09-29 [YANKED] ## [v1.5.1] - 2021-09-08 + ### Added + - About: bug reporting instructions ### Changed + - Collection: improved video date detection ### Fixed + - fixed hanging app when loading thumbnails for some video formats on some devices ## [v1.5.0] - 2021-09-02 + ### Added + - Info: edit Exif dates (setting, shifting, deleting) - Collection: custom quick actions for item selection - Collection: video date detection for more formats ### Changed + - faster collection loading when launching the app ### Fixed + - app launching on some devices - corrupting motion photo exif editing (e.g. rotation) ## [v1.4.9] - 2021-08-20 + ### Added + - Map & Stats from selection - Map: item browsing, rotation control - Navigation menu customization @@ -52,19 +90,24 @@ All notable changes to this project will be documented in this file. - support Android 12/S (API 31) ## [v1.4.8] - 2021-08-08 + ### Added + - Map - Viewer: action to copy to clipboard - integration with Android global search (Samsung Finder etc.) ### Fixed + - auto album identification and naming - opening HEIC images from downloads content URI on Android R+ ## [v1.4.7] - 2021-08-06 [YANKED] ## [v1.4.6] - 2021-07-22 + ### Added + - Albums / Countries / Tags: multiple selection - Albums: action to create empty albums - Collection: burst shot grouping (Samsung naming pattern) @@ -74,18 +117,23 @@ All notable changes to this project will be documented in this file. - Settings: option to exclude cutout area in viewer ### Changed + - Video: restored overlay hiding when pressing play button ### Fixed + - Viewer: fixed manual screen rotation to follow sensor ## [v1.4.5] - 2021-07-08 + ### Added + - Video: added OGV/Theora/Vorbis support - Viewer: action to rotate screen when device has locked rotation - Settings: import/export ### Changed + - improved SVG support with a different rendering engine - changed logo - upgraded Flutter to stable v2.2.3 @@ -93,76 +141,97 @@ All notable changes to this project will be documented in this file. - viewer: parallax effect when scrolling ### Removed + - Analytics: removed Firebase Analytics (kept Firebase Crashlytics) ## [v1.4.4] - 2021-06-25 + ### Added + - Video: speed control, track selection, frame capture - Video: embedded subtitle support - Settings: custom video quick actions - Settings: subtitle theme ### Changed + - upgraded Flutter to stable v2.2.2 ### Fixed + - fixed opening SVGs from other apps - stop video playback when leaving the app in some cases - fixed crash when ACCESS_MEDIA_LOCATION permission is revoked ## [v1.4.3] - 2021-06-12 + ### Added + - Collection: snack bar action to show moved/copied/exported entries - Collection / Albums / Countries / Tags: when switching device orientation, keep items in view - Collection: when leaving entry from Viewer, make entry visible in collection - Viewer: fixed layout & minimap for videos with non-square pixels ### Changed + - upgraded Flutter to stable v2.2.1 - migrated to unsound null safety - Collection / Viewer: improved performance, memory usage - Collection: thumbnail layout change ### Removed + - no support for Android KitKat (API 19), unsupported by Google Maps package ### Fixed + - fixed opening files shared via content URI with incorrect MIME type - refresh collection when entries modified in Viewer no longer match collection filters ## [v1.4.2] - 2021-06-10 [YANKED] ## [v1.4.1] - 2021-04-29 + ### Added + - Motion photo support - Viewer: play videos in multi-track HEIC - Handle share intent ### Changed + - Upgraded Flutter to beta v2.2.0-10.1.pre ### Fixed + - fixed crash when cataloguing large MP4/PSD - prevent videos playing in the background when quickly switching entries ## [v1.4.0] - 2021-04-16 + ### Added + - Viewer: support for videos with EAC3/FLAC/OPUS audio - Info: more consistent and comprehensive info for videos and streams - Settings: more video options (auto play, loop, hardware acceleration) ### Changed + - Info: present video cover like XMP embedded images ### Removed + - locale name package (-3 MB) ### Fixed + - Albums: auto naming for folders on SD card - Viewer: display of videos with unusual SAR ## [v1.3.7] - 2021-04-02 + ### Added + - Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb - Albums: localized common album names - Collection: select shortcut icon image @@ -170,165 +239,209 @@ All notable changes to this project will be documented in this file. - Settings: option to hide videos from collection ### Changed + - Upgraded Flutter to beta v2.1.0-12.2.pre ### Fixed + - opening media shared by other apps as file media content - navigation stack when opening media shared by other apps ## [v1.3.6] - 2021-03-18 + ### Added + - Korean translation - cover selection for albums / countries / tags ### Changed + - Upgraded Flutter to dev v2.1.0-12.1.pre ### Fixed + - various TIFF decoding fixes ## [v1.3.5] - 2021-02-26 + ### Added + - support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23) - quick country reverse geocoding without Play Services - menu option to hide any filter - menu option to navigate to the album / country / tag page from filter ### Changed + - analytics are opt-in ### Removed + - removed custom font used in titles and info page ## [v1.3.4] - 2021-02-10 + ### Added + - hide album / country / tag from collection - new version check ### Changed + - Viewer: improved multipage item overlay and thumbnail loading - deactivate geocoding and Google maps when Play Services are unavailable ### Fixed + - refreshing items externally added/moved/removed - loading items at the root of volumes - loading items when opening a shortcut with a location filter - various thumbnail hero animation fixes ## [v1.3.3] - 2021-01-31 + ### Added + - Viewer: support for multi-track HEIF - Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP) - Info: show owner app (Android Q and up) - listen to Media Store changes ### Changed + - upgraded Flutter to stable v1.22.6 - check connectivity before using features that need it ### Fixed + - checkerboard background performance - deleting files that no longer exist but are still registered in the Media Store - insets handling on Android 11 ## [v1.3.2] - 2021-01-17 + ### Added -Collection: identify multipage TIFF & multitrack HEIC/HEIF -Viewer: support for multipage TIFF -Viewer: support for cropped panoramas -Albums: grouping options + +Collection: identify multipage TIFF & multitrack HEIC/HEIF Viewer: support for multipage TIFF +Viewer: support for cropped panoramas Albums: grouping options ### Changed + upgraded libtiff to 4.2.0 for TIFF decoding ### Fixed + - prevent scrolling when using Android Q style gesture navigation ## [v1.3.1] - 2021-01-04 + ### Added + - Collection: long press and move to select/deselect multiple items - Info: show Spherical Video V1 metadata - Info: metadata search ### Fixed + - Viewer: fixed panning inertia following double-tap scaling - Collection: fixed crash when loading TIFF files on Android 11 ## [v1.3.0] - 2020-12-26 + ### Added + - Viewer: quick scale (aka one finger zoom) - Viewer: optional checkered background for transparent images ### Changed + - Viewer: changed panning inertia ### Fixed + - Viewer: fixed scaling focus when zooming by double-tap or pinch - Viewer: fixed panning during scaling ## [v1.2.9] - 2020-12-12 + ### Added + - Collection: identify 360 photos/videos, GeoTIFF - Viewer: open panoramas (360 photos) - Info: open GImage/GAudio/GDepth media and thumbnails embedded in XMP - Info: SVG metadata ### Changed + - Upgraded Flutter to stable v1.22.5 - Viewer: TIFF subsampling & tiling - Info: improved XMP layout ### Fixed + - Fixed large TIFF handling ## [v1.2.8] - 2020-11-27 + ### Added + - Albums / Countries / Tags: pinch to change tile size - Album picker: added a field to filter by name - check free space before moving items - SVG source viewer ### Changed + - Navigation: changed page history handling - Info: improved layout, especially for XMP - About: improved layout - faster locating of new items ## [v1.2.7] - 2020-11-15 + ### Added + - Support for TIFF images (single page) - Viewer overlay: minimap (optional) ### Changed + - Upgraded Flutter to stable v1.22.4 - Viewer: use subsampling and tiling to display large images ### Fixed + - Fixed finding dimensions of items with incorrect EXIF ## [v1.2.6] - 2020-11-15 [YANKED] ## [v1.2.5] - 2020-11-01 + ### Added + - Search: show recently used filters (optional) - Search: show filter for items with no XMP tags - Search: show filter for items with no location information - Analytics: use Firebase Analytics (along Firebase Crashlytics) ### Changed + - Upgraded Flutter to stable v1.22.3 - Viewer overlay: showing shooting details is now optional ### Fixed + - Viewer: leave when the loaded item is deleted and it is the last one - Viewer: refresh the viewer overlay and info page when the loaded image is modified - Info: prevent reporting a "Media" section for images other than HEIC/HEIF - Fixed opening items shared via a "file" media content URI ### Removed + - Dependencies: removed Guava as a direct dependency in Android ## [v1.2.4] - 2020-11-01 [YANKED] ## [v1.2.3] - 2020-10-22 + ... \ No newline at end of file diff --git a/README.md b/README.md index d243dd9c2..0603e79f4 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,28 @@ Aves requires a few permissions to do its job: - **have network access**: necessary for the map view, and most likely for precise reverse geocoding too, - **view network connections**: checking for connection states allows Aves to gracefully degrade features that depend on internet. +## Contributing + +### Issues + +[Bug reports](https://github.com/deckerst/aves/issues/new?assignees=&labels=type%3Abug&template=bug_report.md&title=) and [feature requests](https://github.com/deckerst/aves/issues/new?assignees=&labels=type%3Afeature&template=feature_request.md&title=) are welcome. Questions too, though you could also ask them in [Discussions](https://github.com/deckerst/aves/discussions). + +### Code + +At this stage this project does *not* accept PRs, except for translations. + +### Translations + +If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French (soon™) are already handled. + +### Donations +Some users have expressed the wish to financially support the project. I haven't set up any sponsorship system, but you can send contributions [here](https://paypal.me/ThibaultDeckers). Thanks! ❤️ + ## Project Setup -Create a file named `/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `/android/key_template.properties` for the expected keys. +To build the project, create a file named `/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys. + +You can run the app with `flutter run --flavor universal`. [Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver [Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check diff --git a/android/app/build.gradle b/android/app/build.gradle index 53cf01d6c..8cf3179fb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -77,6 +77,21 @@ android { } } + // the "splitting" dimension and its flavors are only for building purposes: + // NDK ABI filters are not compatible with split APK generation + // but we want to generate both a universal APK without x86 libs, and split APKs + flavorDimensions "splitting" + + productFlavors { + universal { + dimension "splitting" + } + + byAbi { + dimension "splitting" + } + } + buildTypes { debug { applicationIdSuffix ".debug" @@ -87,19 +102,23 @@ android { resValue 'string', 'search_provider', "${appId}.profile.search_provider" } release { - // specify architectures, to specifically exclude native libs for x86, - // which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so" - // cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500 - ndk { - abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' - } - signingConfig signingConfigs.release - minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } + + def runTasks = gradle.startParameter.taskNames.toString().toLowerCase() + if (runTasks.contains("universal")) { + release { + // specify architectures, to specifically exclude native libs for x86, + // which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so" + // cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500 + ndk { + abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' + } + } + } } } @@ -120,10 +139,10 @@ dependencies { implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.drewnoakes:metadata-extractor:2.16.0' - // https://jitpack.io/p/deckerst/Android-TiffBitmapFactory - implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack - // https://jitpack.io/p/deckerst/pixymeta-android - implementation 'com.github.deckerst:pixymeta-android:0bea51ead2' // forked, built by JitPack + // forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory + implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' + // forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android + implementation 'com.github.deckerst:pixymeta-android:0bea51ead2' implementation 'com.github.bumptech.glide:glide:4.12.0' kapt 'androidx.annotation:annotation:1.2.0' diff --git a/android/app/src/debug/res/values-ko/strings.xml b/android/app/src/debug/res/values-ko/strings.xml index 4fff58c6e..07e7e9a8f 100644 --- a/android/app/src/debug/res/values-ko/strings.xml +++ b/android/app/src/debug/res/values-ko/strings.xml @@ -1,4 +1,4 @@  - 아베스 [Debug] + 아베스 [Debug] \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3cca27c39..0546ae2a0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ https://developer.android.com/preview/privacy/storage#media-files-raw-paths --> + + ImageByteStreamHandler(this, args) } + StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) } + // channel for service management + backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL).apply { + setMethodCallHandler(context) + } + + HandlerThread("Analysis service handler", Process.THREAD_PRIORITY_BACKGROUND).apply { + start() + serviceLooper = looper + serviceHandler = ServiceHandler(looper) + } + } + + override fun onDestroy() { + Log.i(LOG_TAG, "Destroy analysis service") + } + + override fun onBind(intent: Intent) = analysisServiceBinder + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + val channel = NotificationChannelCompat.Builder(CHANNEL_ANALYSIS, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(getText(R.string.analysis_channel_name)) + .setShowBadge(false) + .build() + NotificationManagerCompat.from(this).createNotificationChannel(channel) + startForeground(NOTIFICATION_ID, buildNotification()) + + val msgData = Bundle() + intent.extras?.let { + msgData.putAll(it) + } + serviceHandler?.obtainMessage()?.let { msg -> + msg.arg1 = startId + msg.data = msgData + serviceHandler?.sendMessage(msg) + } + + return START_NOT_STICKY + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "initialized" -> { + Log.d(LOG_TAG, "background channel is ready") + result.success(null) + } + "updateNotification" -> { + val title = call.argument("title") + val message = call.argument("message") + val notification = buildNotification(title, message) + NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification) + result.success(null) + } + "refreshApp" -> { + analysisServiceBinder.refreshApp() + result.success(null) + } + "stop" -> { + detachAndStop() + result.success(null) + } + else -> result.notImplemented() + } + } + + private fun detachAndStop() { + analysisServiceBinder.detach() + stopSelf() + } + + private fun buildNotification(title: String? = null, message: String? = null): Notification { + val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val stopServiceIntent = Intent(this, AnalysisService::class.java).let { + it.putExtra(KEY_COMMAND, COMMAND_STOP) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PendingIntent.getForegroundService(this, STOP_SERVICE_REQUEST, it, pendingIntentFlags) + } else { + PendingIntent.getService(this, STOP_SERVICE_REQUEST, it, pendingIntentFlags) + } + } + val openAppIntent = Intent(this, MainActivity::class.java).let { + PendingIntent.getActivity(this, OPEN_FROM_ANALYSIS_SERVICE, it, pendingIntentFlags) + } + val stopAction = NotificationCompat.Action.Builder( + R.drawable.ic_outline_stop_24, + getString(R.string.analysis_notification_action_stop), + stopServiceIntent + ).build() + return NotificationCompat.Builder(this, CHANNEL_ANALYSIS) + .setContentTitle(title ?: getText(R.string.analysis_notification_default_title)) + .setContentText(message) + .setBadgeIconType(NotificationCompat.BADGE_ICON_NONE) + .setSmallIcon(R.drawable.ic_notification) + .setContentIntent(openAppIntent) + .setPriority(NotificationCompat.PRIORITY_LOW) + .addAction(stopAction) + .build() + } + + private inner class ServiceHandler(looper: Looper) : Handler(looper) { + override fun handleMessage(msg: Message) { + val context = this@AnalysisService + val data = msg.data + when (data.getString(KEY_COMMAND)) { + COMMAND_START -> { + runBlocking { + context.runOnUiThread { + val contentIds = data.get(KEY_CONTENT_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() } + backgroundChannel?.invokeMethod( + "start", hashMapOf( + "contentIds" to contentIds, + "force" to data.getBoolean(KEY_FORCE), + ) + ) + } + } + } + COMMAND_STOP -> { + // unconditionally stop the service + runBlocking { + context.runOnUiThread { + backgroundChannel?.invokeMethod("stop", null) + } + } + detachAndStop() + } + else -> { + } + } + } + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background" + const val SHARED_PREFERENCES_KEY = "analysis_service" + const val CALLBACK_HANDLE_KEY = "callback_handle" + + const val NOTIFICATION_ID = 1 + const val STOP_SERVICE_REQUEST = 1 + const val CHANNEL_ANALYSIS = "analysis" + + const val KEY_COMMAND = "command" + const val COMMAND_START = "start" + const val COMMAND_STOP = "stop" + const val KEY_CONTENT_IDS = "content_ids" + const val KEY_FORCE = "force" + } +} + +class AnalysisServiceBinder : Binder() { + private val listeners = hashSetOf() + + fun startListening(listener: AnalysisServiceListener) = listeners.add(listener) + + fun stopListening(listener: AnalysisServiceListener) = listeners.remove(listener) + + fun refreshApp() { + val localListeners = listeners.toSet() + for (listener in localListeners) { + try { + listener.refreshApp() + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to notify listener=$listener", e) + } + } + } + + fun detach() { + val localListeners = listeners.toSet() + for (listener in localListeners) { + try { + listener.detachFromActivity() + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to detach listener=$listener", e) + } + } + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + } +} + +interface AnalysisServiceListener { + fun refreshApp() + fun detachFromActivity() +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 35c72a618..9dd4032e5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -15,19 +15,21 @@ import androidx.core.graphics.drawable.IconCompat import app.loup.streams_channel.StreamsChannel import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.streams.* -import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.utils.LogUtils import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap class MainActivity : FlutterActivity() { private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler private lateinit var intentStreamHandler: IntentStreamHandler + private lateinit var analysisStreamHandler: AnalysisStreamHandler private lateinit var intentDataMap: MutableMap + private lateinit var analysisHandler: AnalysisHandler override fun onCreate(savedInstanceState: Bundle?) { Log.i(LOG_TAG, "onCreate intent=$intent") @@ -52,24 +54,30 @@ class MainActivity : FlutterActivity() { val messenger = flutterEngine!!.dartExecutor.binaryMessenger // dart -> platform -> dart - MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this)) + // - need Context + analysisHandler = AnalysisHandler(this, ::onAnalysisCompleted) + MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler) MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler()) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this)) - MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) - MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this)) MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) + // - need Activity + MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this)) + MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this)) + MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this)) MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this)) // result streaming: dart -> platform ->->-> dart + // - need Context StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) } - StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) } StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) } + // - need Activity + StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) } StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) } // change monitoring: platform -> dart @@ -97,6 +105,11 @@ class MainActivity : FlutterActivity() { } } + // notification: platform -> dart + analysisStreamHandler = AnalysisStreamHandler().apply { + EventChannel(messenger, AnalysisStreamHandler.CHANNEL).setStreamHandler(this) + } + // notification: platform -> dart errorStreamHandler = ErrorStreamHandler().apply { EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this) @@ -107,7 +120,20 @@ class MainActivity : FlutterActivity() { } } + override fun onStart() { + Log.i(LOG_TAG, "onStart") + super.onStart() + analysisHandler.attachToActivity() + } + + override fun onStop() { + Log.i(LOG_TAG, "onStop") + analysisHandler.detachFromActivity() + super.onStop() + } + override fun onDestroy() { + Log.i(LOG_TAG, "onDestroy") mediaStoreChangeStreamHandler.dispose() settingsChangeStreamHandler.dispose() super.onDestroy() @@ -122,7 +148,8 @@ class MainActivity : FlutterActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(data, resultCode, requestCode) - DELETE_PERMISSION_REQUEST -> onDeletePermissionResult(resultCode) + DELETE_SINGLE_PERMISSION_REQUEST, + MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode) CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data) @@ -147,10 +174,9 @@ class MainActivity : FlutterActivity() { onStorageAccessResult(requestCode, treeUri) } - private fun onDeletePermissionResult(resultCode: Int) { - // delete permission may be requested on Android 10+ only + private fun onScopedStoragePermissionResult(resultCode: Int) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) + pendingScopedStoragePermissionCompleter?.complete(resultCode == RESULT_OK) } } @@ -252,19 +278,27 @@ class MainActivity : FlutterActivity() { ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search)) } + private fun onAnalysisCompleted() { + analysisStreamHandler.notifyCompletion() + } + companion object { private val LOG_TAG = LogUtils.createTag() const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" const val EXTRA_STRING_ARRAY_SEPARATOR = "###" const val DOCUMENT_TREE_ACCESS_REQUEST = 1 - const val DELETE_PERMISSION_REQUEST = 2 + const val OPEN_FROM_ANALYSIS_SERVICE = 2 const val CREATE_FILE_REQUEST = 3 const val OPEN_FILE_REQUEST = 4 const val SELECT_DIRECTORY_REQUEST = 5 + const val DELETE_SINGLE_PERMISSION_REQUEST = 6 + const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7 // request code to pending runnable val pendingStorageAccessResultHandlers = ConcurrentHashMap() + var pendingScopedStoragePermissionCompleter: CompletableFuture? = null + private fun onStorageAccessResult(requestCode: Int, uri: Uri?) { Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri") val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt index 4a949e8f8..e045954f9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt @@ -2,24 +2,21 @@ package deckers.thibault.aves import android.app.SearchManager import android.content.ContentProvider -import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.database.Cursor import android.database.MatrixCursor import android.net.Uri import android.os.Build -import android.os.Handler import android.util.Log import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.utils.ContextUtils.resourceUri +import deckers.thibault.aves.utils.ContextUtils.runOnUiThread +import deckers.thibault.aves.utils.FlutterUtils import deckers.thibault.aves.utils.LogUtils -import io.flutter.FlutterInjector import io.flutter.embedding.engine.FlutterEngine -import io.flutter.embedding.engine.dart.DartExecutor -import io.flutter.embedding.engine.loader.FlutterLoader import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import io.flutter.view.FlutterCallbackInformation import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -71,7 +68,9 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid private suspend fun getSuggestions(context: Context, query: String): List { if (backgroundFlutterEngine == null) { - initFlutterEngine(context) + FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) { + backgroundFlutterEngine = it + } } val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger @@ -86,7 +85,7 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid "locale" to Locale.getDefault().toString(), ), object : MethodChannel.Result { override fun success(result: Any?) { - @Suppress("UNCHECKED_CAST") + @Suppress("unchecked_cast") cont.resume(result as List) } @@ -133,60 +132,5 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid const val CALLBACK_HANDLE_KEY = "callback_handle" private var backgroundFlutterEngine: FlutterEngine? = null - - private suspend fun initFlutterEngine(context: Context) { - val callbackHandle = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE).getLong(CALLBACK_HANDLE_KEY, 0) - if (callbackHandle == 0L) { - Log.e(LOG_TAG, "failed to retrieve registered callback handle") - return - } - - lateinit var flutterLoader: FlutterLoader - context.runOnUiThread { - // initialization must happen on the main thread - flutterLoader = FlutterInjector.instance().flutterLoader().apply { - startInitialization(context) - ensureInitializationComplete(context, null) - } - } - - val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) - if (callbackInfo == null) { - Log.e(LOG_TAG, "failed to find callback information") - return - } - - val args = DartExecutor.DartCallback( - context.assets, - flutterLoader.findAppBundlePath(), - callbackInfo - ) - context.runOnUiThread { - // initialization must happen on the main thread - backgroundFlutterEngine = FlutterEngine(context).apply { - dartExecutor.executeDartCallback(args) - } - } - } - - // convenience methods - - private suspend fun Context.runOnUiThread(r: Runnable) { - suspendCoroutine { cont -> - Handler(mainLooper).post { - r.run() - cont.resume(true) - } - } - } - - private fun Context.resourceUri(resourceId: Int): Uri = with(resources) { - Uri.Builder() - .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority(getResourcePackageName(resourceId)) - .appendPath(getResourceTypeName(resourceId)) - .appendPath(getResourceEntryName(resourceId)) - .build() - } } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt index da70320c6..77b5e015b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt @@ -22,7 +22,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler { } } - private fun areAnimationsRemoved(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { var removed = false try { removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f @@ -32,7 +32,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler { result.success(removed) } - private fun hasRecommendedTimeouts(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt new file mode 100644 index 000000000..8f0a263b5 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt @@ -0,0 +1,115 @@ +package deckers.thibault.aves.channel.calls + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Build +import android.os.IBinder +import android.util.Log +import deckers.thibault.aves.AnalysisService +import deckers.thibault.aves.AnalysisServiceBinder +import deckers.thibault.aves.AnalysisServiceListener +import deckers.thibault.aves.utils.ContextUtils.isMyServiceRunning +import deckers.thibault.aves.utils.LogUtils +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class AnalysisHandler(private val activity: Activity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler, AnalysisServiceListener { + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "registerCallback" -> GlobalScope.launch(Dispatchers.IO) { Coresult.safe(call, result, ::registerCallback) } + "startService" -> Coresult.safe(call, result, ::startAnalysis) + else -> result.notImplemented() + } + } + + @SuppressLint("CommitPrefEdits") + private fun registerCallback(call: MethodCall, result: MethodChannel.Result) { + val callbackHandle = call.argument("callbackHandle")?.toLong() + if (callbackHandle == null) { + result.error("registerCallback-args", "failed because of missing arguments", null) + return + } + + activity.getSharedPreferences(AnalysisService.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) + .edit() + .putLong(AnalysisService.CALLBACK_HANDLE_KEY, callbackHandle) + .apply() + result.success(true) + } + + private fun startAnalysis(call: MethodCall, result: MethodChannel.Result) { + val force = call.argument("force") + if (force == null) { + result.error("startAnalysis-args", "failed because of missing arguments", null) + return + } + + // can be null or empty + val contentIds = call.argument>("contentIds"); + + if (!activity.isMyServiceRunning(AnalysisService::class.java)) { + val intent = Intent(activity, AnalysisService::class.java) + intent.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START) + intent.putExtra(AnalysisService.KEY_CONTENT_IDS, contentIds?.toIntArray()) + intent.putExtra(AnalysisService.KEY_FORCE, force) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + activity.startForegroundService(intent) + } else { + activity.startService(intent) + } + } + attachToActivity() + result.success(null) + } + + private var attached = false + + fun attachToActivity() { + if (activity.isMyServiceRunning(AnalysisService::class.java)) { + val intent = Intent(activity, AnalysisService::class.java) + activity.bindService(intent, connection, Context.BIND_AUTO_CREATE) + attached = true + } + } + + override fun detachFromActivity() { + if (attached) { + attached = false + activity.unbindService(connection) + } + } + + override fun refreshApp() { + if (attached) { + onAnalysisCompleted() + } + } + + private val connection = object : ServiceConnection { + var binder: AnalysisServiceBinder? = null + + override fun onServiceConnected(name: ComponentName, service: IBinder) { + Log.i(LOG_TAG, "Analysis service connected") + binder = service as AnalysisServiceBinder + binder?.startListening(this@AnalysisHandler) + } + + override fun onServiceDisconnected(name: ComponentName) { + Log.i(LOG_TAG, "Analysis service disconnected") + binder?.stopListening(this@AnalysisHandler) + binder = null + } + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + const val CHANNEL = "deckers.thibault/aves/analysis" + } +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index f2a161440..d818180d3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -53,7 +53,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { } } - private fun getPackages(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getPackages(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { val packages = HashMap() fun addPackageDetails(intent: Intent) { @@ -76,7 +76,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { // The following methods do not work: // - `resources.getConfiguration().setLocale(...)` // - getting a package manager from a custom context with `context.createConfigurationContext(config)` - @Suppress("DEPRECATION") + @Suppress("deprecation") resources.updateConfiguration(englishConfig, resources.displayMetrics) englishLabel = resources.getString(labelRes) } catch (e: Exception) { @@ -321,7 +321,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context) - private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(isPinSupported()) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 0cac74386..29cca22e4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -60,7 +60,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } } - private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getContextDirs(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { val dirs = hashMapOf( "cacheDir" to context.cacheDir, "filesDir" to context.filesDir, @@ -83,7 +83,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { result.success(dirs) } - private fun getEnv(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getEnv(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(System.getenv()) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt index 3f1dda8a1..4240743d0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt @@ -16,14 +16,17 @@ class DeviceHandler : MethodCallHandler { } } - private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(TimeZone.getDefault().id) } - private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getPerformanceClass(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS) - return + val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS + if (performanceClass > 0) { + result.success(performanceClass) + return + } } result.success(Build.VERSION.SDK_INT) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt index 770f6beac..48560f9a5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt @@ -1,11 +1,9 @@ package deckers.thibault.aves.channel.calls -import android.app.Activity +import android.annotation.SuppressLint import android.content.Context -import android.util.Log import deckers.thibault.aves.SearchSuggestionsProvider import deckers.thibault.aves.channel.calls.Coresult.Companion.safe -import deckers.thibault.aves.utils.LogUtils import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -13,7 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -class GlobalSearchHandler(private val context: Activity) : MethodCallHandler { +class GlobalSearchHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "registerCallback" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::registerCallback) } @@ -21,6 +19,7 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler { } } + @SuppressLint("CommitPrefEdits") private fun registerCallback(call: MethodCall, result: MethodChannel.Result) { val callbackHandle = call.argument("callbackHandle")?.toLong() if (callbackHandle == null) { @@ -36,7 +35,6 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler { } companion object { - private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/global_search" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt index 6ee746c10..f039131e1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt @@ -11,6 +11,7 @@ import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.utils.MimeTypes @@ -34,7 +35,6 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) } "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) } - "rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) } else -> result.notImplemented() } @@ -144,7 +144,8 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { val exifFields = call.argument("exif") ?: HashMap() val bytes = call.argument("bytes") var destinationDir = call.argument("destinationPath") - if (uri == null || desiredName == null || bytes == null || destinationDir == null) { + val nameConflictStrategy = NameConflictStrategy.get(call.argument("nameConflictStrategy")) + if (uri == null || desiredName == null || bytes == null || destinationDir == null || nameConflictStrategy == null) { result.error("captureFrame-args", "failed because of missing arguments", null) return } @@ -156,41 +157,13 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { } destinationDir = ensureTrailingSeparator(destinationDir) - provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback { + provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, nameConflictStrategy, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message) }) } - private suspend fun rename(call: MethodCall, result: MethodChannel.Result) { - val entryMap = call.argument("entry") - val newName = call.argument("newName") - if (entryMap == null || newName == null) { - result.error("rename-args", "failed because of missing arguments", null) - return - } - - val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } - val path = entryMap["path"] as String? - val mimeType = entryMap["mimeType"] as String? - if (uri == null || path == null || mimeType == null) { - result.error("rename-args", "failed because entry fields are missing", null) - return - } - - val provider = getProvider(uri) - if (provider == null) { - result.error("rename-provider", "failed to find provider for uri=$uri", null) - return - } - - provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message) - }) - } - - private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun clearSizedThumbnailDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { Glide.get(activity).clearDiskCache() result.success(null) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt index 8de140b8a..b20b6b802 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt @@ -1,6 +1,6 @@ package deckers.thibault.aves.channel.calls -import android.app.Activity +import android.content.Context import android.media.MediaScannerConnection import android.net.Uri import deckers.thibault.aves.channel.calls.Coresult.Companion.safe @@ -12,7 +12,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -class MediaStoreHandler(private val activity: Activity) : MethodCallHandler { +class MediaStoreHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) } @@ -28,7 +28,7 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler { result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null) return } - result.success(MediaStoreImageProvider().checkObsoleteContentIds(activity, knownContentIds)) + result.success(MediaStoreImageProvider().checkObsoleteContentIds(context, knownContentIds)) } private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) { @@ -37,13 +37,13 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler { result.error("checkObsoletePaths-args", "failed because of missing arguments", null) return } - result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById)) + result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById)) } private fun scanFile(call: MethodCall, result: MethodChannel.Result) { val path = call.argument("path") val mimeType = call.argument("mimeType") - MediaScannerConnection.scanFile(activity, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) } + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) } } companion object { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 21d4b4387..3fbbb707f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -12,6 +12,7 @@ import androidx.exifinterface.media.ExifInterface import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.drew.imaging.ImageMetadataReader +import com.drew.lang.KeyValuePair import com.drew.lang.Rational import com.drew.metadata.Tag import com.drew.metadata.avi.AviDirectory @@ -33,16 +34,20 @@ import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt +import deckers.thibault.aves.metadata.Metadata.DIR_PNG_TEXTUAL_DATA import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode +import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_ITXT_DIR_NAME import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME +import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff +import deckers.thibault.aves.metadata.MetadataExtractorHelper.isPngTextDir import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeInt import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText @@ -52,12 +57,12 @@ import deckers.thibault.aves.metadata.XMP.isPanorama import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.isHeic -import deckers.thibault.aves.utils.MimeTypes.isImage +import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor +import deckers.thibault.aves.utils.MimeTypes.isHeic +import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isVideo -import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.UriUtils.tryParseId import io.flutter.plugin.common.MethodCall @@ -66,6 +71,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import java.nio.charset.StandardCharsets import java.text.ParseException import java.util.* import kotlin.math.roundToLong @@ -104,113 +110,158 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) val uuidDirCount = HashMap() - for (dir in metadata.directories.filter { + val dirByName = metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory && it !is AviDirectory - }) { - // directory name - var dirName = dir.name - if (dir is Mp4UuidBoxDirectory) { - val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-') - dirName += " $uuid" - - val count = uuidDirCount[uuid] ?: 0 - uuidDirCount[uuid] = count + 1 - if (count > 0) { - dirName += " ($count)" - } - } + }.groupBy { dir -> dir.name } + for (dirEntry in dirByName) { + val baseDirName = dirEntry.key // exclude directories known to be redundant with info derived on the Dart side // they are excluded by name instead of runtime type because excluding `Mp4Directory` // would also exclude derived directories, such as `Mp4UuidBoxDirectory` - if (allMetadataRedundantDirNames.contains(dirName)) continue + if (allMetadataRedundantDirNames.contains(baseDirName)) continue - // optional parent to distinguish child directories of the same type - dir.parent?.name?.let { dirName = "$it/$dirName" } + val sameNameDirs = dirEntry.value + val sameNameDirCount = sameNameDirs.size + for (dirIndex in 0 until sameNameDirCount) { + val dir = sameNameDirs[dirIndex] - val dirMap = metadataMap[dirName] ?: HashMap() - metadataMap[dirName] = dirMap + // directory name + var thisDirName = baseDirName + if (dir is Mp4UuidBoxDirectory) { + val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-') + thisDirName += " $uuid" - // tags - val tags = dir.tags - if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { - fun tagMapper(it: Tag): Pair { - val name = if (it.hasTagName()) { - it.tagName + val count = uuidDirCount[uuid] ?: 0 + uuidDirCount[uuid] = count + 1 + if (count > 0) { + thisDirName += " ($count)" + } + } else if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) { + // optional count for multiple directories of the same type + thisDirName = "$thisDirName[${dirIndex + 1}]" + } + + // optional parent to distinguish child directories of the same type + dir.parent?.name?.let { thisDirName = "$it/$thisDirName" } + + var dirMap = metadataMap[thisDirName] ?: HashMap() + metadataMap[thisDirName] = dirMap + + // tags + val tags = dir.tags + if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { + fun tagMapper(it: Tag): Pair { + val name = if (it.hasTagName()) { + it.tagName + } else { + TiffTags.getTagName(it.tagType) ?: it.tagName + } + return Pair(name, it.description) + } + + if (dir is ExifIFD0Directory && dir.isGeoTiff()) { + // split GeoTIFF tags in their own directory + val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) } + metadataMap["GeoTIFF"] = HashMap().apply { + byGeoTiff[true]?.map { tagMapper(it) }?.let { putAll(it) } + } + byGeoTiff[false]?.map { tagMapper(it) }?.let { dirMap.putAll(it) } } else { - TiffTags.getTagName(it.tagType) ?: it.tagName + dirMap.putAll(tags.map { tagMapper(it) }) } - return Pair(name, it.description) - } + } else if (dir.isPngTextDir()) { + metadataMap.remove(thisDirName) + dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap() + metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap - if (dir is ExifIFD0Directory && dir.isGeoTiff()) { - // split GeoTIFF tags in their own directory - val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) } - metadataMap["GeoTIFF"] = HashMap().apply { - byGeoTiff[true]?.map { tagMapper(it) }?.let { putAll(it) } + for (tag in tags) { + val tagType = tag.tagType + if (tagType == PngDirectory.TAG_TEXTUAL_DATA) { + val pairs = dir.getObject(tagType) as List<*> + val textPairs = pairs.map { pair -> + val kv = pair as KeyValuePair + val key = kv.key + // `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1 + val charset = if (baseDirName == PNG_ITXT_DIR_NAME) StandardCharsets.UTF_8 else kv.value.charset + val valueString = String(kv.value.bytes, charset) + val dirs = extractPngProfile(key, valueString) + if (dirs?.any() == true) { + dirs.forEach { profileDir -> + val profileDirName = profileDir.name + val profileDirMap = metadataMap[profileDirName] ?: HashMap() + metadataMap[profileDirName] = profileDirMap + profileDirMap.putAll(profileDir.tags.map { Pair(it.tagName, it.description) }) + } + null + } else { + Pair(key, valueString) + } + } + dirMap.putAll(textPairs.filterNotNull()) + } else { + dirMap[tag.tagName] = tag.description + } } - byGeoTiff[false]?.map { tagMapper(it) }?.let { dirMap.putAll(it) } } else { - dirMap.putAll(tags.map { tagMapper(it) }) + dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) } - } else { - dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) - } - if (dir is XmpDirectory) { - try { - for (prop in dir.xmpMeta) { - if (prop is XMPPropertyInfo) { - val path = prop.path - if (path?.isNotEmpty() == true) { - val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value - if (value?.isNotEmpty() == true) { - dirMap[path] = value + if (dir is XmpDirectory) { + try { + for (prop in dir.xmpMeta) { + if (prop is XMPPropertyInfo) { + val path = prop.path + if (path?.isNotEmpty() == true) { + val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value + if (value?.isNotEmpty() == true) { + dirMap[path] = value + } } } } + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) } - } catch (e: XMPException) { - Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) + // remove this stat as it is not actual XMP data + dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT)) } - // remove this stat as it is not actual XMP data - dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT)) - } - if (dir is Mp4UuidBoxDirectory) { - when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) { - GSpherical.SPHERICAL_VIDEO_V1_UUID -> { - val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) - metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe()) - metadataMap.remove(dirName) - } - QuickTimeMetadata.PROF_UUID -> { - // redundant with info derived on the Dart side - metadataMap.remove(dirName) - } - QuickTimeMetadata.USMT_UUID -> { - val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) - val blocks = QuickTimeMetadata.parseUuidUsmt(bytes) - if (blocks.isNotEmpty()) { - metadataMap.remove(dirName) - dirName = "QuickTime User Media" - val usmt = metadataMap[dirName] ?: HashMap() - metadataMap[dirName] = usmt + if (dir is Mp4UuidBoxDirectory) { + when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) { + GSpherical.SPHERICAL_VIDEO_V1_UUID -> { + val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) + metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe()) + metadataMap.remove(thisDirName) + } + QuickTimeMetadata.PROF_UUID -> { + // redundant with info derived on the Dart side + metadataMap.remove(thisDirName) + } + QuickTimeMetadata.USMT_UUID -> { + val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) + val blocks = QuickTimeMetadata.parseUuidUsmt(bytes) + if (blocks.isNotEmpty()) { + metadataMap.remove(thisDirName) + thisDirName = "QuickTime User Media" + val usmt = metadataMap[thisDirName] ?: HashMap() + metadataMap[thisDirName] = usmt - blocks.forEach { - var key = it.type - var value = it.value - val language = it.language + blocks.forEach { + var key = it.type + var value = it.value + val language = it.language - var i = 0 - while (usmt.containsKey(key)) { - key = it.type + " (${++i})" + var i = 0 + while (usmt.containsKey(key)) { + key = it.type + " (${++i})" + } + if (language != "und") { + value += " ($language)" + } + usmt[key] = value } - if (language != "und") { - value += " ($language)" - } - usmt[key] = value } } } @@ -353,7 +404,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives), // in which case we trust the file extension // cf https://github.com/drewnoakes/metadata-extractor/issues/296 - if (path?.matches(tiffExtensionPattern) == true) { + if (path?.matches(TIFF_EXTENSION_PATTERN) == true) { metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF } else { dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { @@ -658,7 +709,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) - val fields = hashMapOf( + val fields: FieldMap = hashMapOf( "projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT, ) for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { @@ -767,6 +818,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { "QuickTime Sound", "QuickTime Video", ) + private val allMetadataMergeableDirNames = setOf( + "Exif SubIFD", + "GIF Control", + "GIF Image", + "HEIF", + "ICC Profile", + "IPTC", + "WebP", + "XMP", + ) // catalog metadata private const val KEY_MIME_TYPE = "mimeType" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index b501a568b..c8bb7a1db 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -6,6 +6,7 @@ import android.os.Environment import android.os.storage.StorageManager import androidx.core.os.EnvironmentCompat import deckers.thibault.aves.channel.calls.Coresult.Companion.safe +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath import deckers.thibault.aves.utils.StorageUtils.getVolumePaths @@ -28,11 +29,13 @@ class StorageHandler(private val context: Context) : MethodCallHandler { "getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) } "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) "deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) } + "canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess) + "canInsertMedia" -> safe(call, result, ::canInsertMedia) else -> result.notImplemented() } } - private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getStorageVolumes(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { val volumes = ArrayList>() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager @@ -100,7 +103,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { } } - private fun getGrantedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getGrantedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(ArrayList(PermissionManager.getGrantedDirs(context))) } @@ -114,7 +117,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths)) } - private fun getRestrictedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getRestrictedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(PermissionManager.getRestrictedDirectories(context)) } @@ -155,6 +158,20 @@ class StorageHandler(private val context: Context) : MethodCallHandler { result.success(deleted) } + private fun canRequestMediaFileBulkAccess(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + } + + private fun canInsertMedia(call: MethodCall, result: MethodChannel.Result) { + val directories = call.argument>("directories") + if (directories == null) { + result.error("canInsertMedia-args", "failed because of missing arguments", null) + return + } + + result.success(PermissionManager.canInsertByMediaStore(directories)) + } + companion object { const val CHANNEL = "deckers.thibault/aves/storage" } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt index bad2bf6eb..2494977f9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt @@ -40,7 +40,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler { result.success(null) } - private fun isRotationLocked(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun isRotationLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { var locked = false try { locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0 @@ -60,7 +60,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler { result.success(true) } - private fun canSetCutoutMode(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index aae2b6da4..5c9d40242 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -79,7 +79,7 @@ class ThumbnailFetcher internal constructor( } else { var errorDetails: String? = exception?.message if (errorDetails?.isNotEmpty() == true) { - errorDetails = errorDetails.split("\n".toRegex(), 2).first() + errorDetails = errorDetails.split(Regex("\n"), 2).first() } result.error("getThumbnail-null", "failed to get thumbnail for mimeType=$mimeType uri=$uri", errorDetails) } @@ -99,10 +99,10 @@ class ThumbnailFetcher internal constructor( val contentId = uri.tryParseId() ?: return null val resolver = context.contentResolver return if (isVideo(mimeType)) { - @Suppress("DEPRECATION") + @Suppress("deprecation") MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null) } else { - @Suppress("DEPRECATION") + @Suppress("deprecation") var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null) // from Android Q, returned thumbnail is already rotated according to EXIF orientation if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt new file mode 100644 index 000000000..2e199a30d --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt @@ -0,0 +1,25 @@ +package deckers.thibault.aves.channel.streams + +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink + +class AnalysisStreamHandler : EventChannel.StreamHandler { + // cannot use `lateinit` because we cannot guarantee + // its initialization in `onListen` at the right time + // e.g. when resuming the app after the activity got destroyed + private var eventSink: EventSink? = null + + override fun onListen(arguments: Any?, eventSink: EventSink) { + this.eventSink = eventSink + } + + override fun onCancel(arguments: Any?) {} + + fun notifyCompletion() { + eventSink?.success(true) + } + + companion object { + const val CHANNEL = "deckers.thibault/aves/analysis_events" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index 84742cb21..5ff445aaa 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -1,6 +1,6 @@ package deckers.thibault.aves.channel.streams -import android.app.Activity +import android.content.Context import android.net.Uri import android.os.Handler import android.os.Looper @@ -16,8 +16,8 @@ import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter +import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.StorageUtils @@ -26,10 +26,9 @@ import io.flutter.plugin.common.EventChannel.EventSink import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import java.io.IOException import java.io.InputStream -class ImageByteStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler { +class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler { private lateinit var eventSink: EventSink private lateinit var handler: Handler @@ -108,22 +107,22 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen private fun streamImageAsIs(uri: Uri, mimeType: String) { try { - StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) } - } catch (e: IOException) { + StorageUtils.openInputStream(context, uri)?.use { input -> streamBytes(input) } + } catch (e: Exception) { error("streamImage-image-read-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message) } } private suspend fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { val model: Any = if (isHeic(mimeType) && pageId != null) { - MultiTrackImage(activity, uri, pageId) + MultiTrackImage(context, uri, pageId) } else if (mimeType == MimeTypes.TIFF) { - TiffImage(activity, uri, pageId) + TiffImage(context, uri, pageId) } else { StorageUtils.getGlideSafeUri(uri, mimeType) } - val target = Glide.with(activity) + val target = Glide.with(context) .asBitmap() .apply(glideOptions) .load(model) @@ -132,7 +131,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen @Suppress("BlockingMethodInNonBlockingContext") var bitmap = target.get() if (needRotationAfterGlide(mimeType)) { - bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) + bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped) } if (bitmap != null) { success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)) @@ -142,15 +141,15 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } catch (e: Exception) { error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e)) } finally { - Glide.with(activity).clear(target) + Glide.with(context).clear(target) } } private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) { - val target = Glide.with(activity) + val target = Glide.with(context) .asBitmap() .apply(glideOptions) - .load(VideoThumbnail(activity, uri)) + .load(VideoThumbnail(context, uri)) .submit() try { @Suppress("BlockingMethodInNonBlockingContext") @@ -163,14 +162,14 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } catch (e: Exception) { error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message) } finally { - Glide.with(activity).clear(target) + Glide.with(context).clear(target) } } private fun toErrorDetails(e: Exception): String? { val errorDetails = e.message return if (errorDetails?.isNotEmpty() == true) { - errorDetails.split("\n".toRegex(), 2).first() + errorDetails.split(Regex("\n"), 2).first() } else { errorDetails } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index 9786cd59e..d75c7b44f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -7,6 +7,7 @@ import android.os.Looper import android.util.Log import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.utils.LogUtils @@ -28,7 +29,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments init { if (arguments is Map<*, *>) { op = arguments["op"] as String? - @Suppress("UNCHECKED_CAST") + @Suppress("unchecked_cast") val rawEntries = arguments["entries"] as List? if (rawEntries != null) { entryMapList.addAll(rawEntries) @@ -44,6 +45,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments "delete" -> GlobalScope.launch(Dispatchers.IO) { delete() } "export" -> GlobalScope.launch(Dispatchers.IO) { export() } "move" -> GlobalScope.launch(Dispatchers.IO) { move() } + "rename" -> GlobalScope.launch(Dispatchers.IO) { rename() } else -> endOfStream() } } @@ -98,12 +100,13 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments for (entryMap in entryMapList) { val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val path = entryMap["path"] as String? - if (uri != null) { - val result = hashMapOf( + val mimeType = entryMap["mimeType"] as String? + if (uri != null && mimeType != null) { + val result: FieldMap = hashMapOf( "uri" to uri.toString(), ) try { - provider.delete(activity, uri, path) + provider.delete(activity, uri, path, mimeType) result["success"] = true } catch (e: Exception) { Log.w(LOG_TAG, "failed to delete entry with path=$path", e) @@ -123,7 +126,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments var destinationDir = arguments["destinationPath"] as String? val mimeType = arguments["mimeType"] as String? - if (destinationDir == null || mimeType == null) { + val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?) + if (destinationDir == null || mimeType == null || nameConflictStrategy == null) { error("export-args", "failed because of missing arguments", null) return } @@ -138,7 +142,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback { + provider.exportMultiple(activity, mimeType, destinationDir, entries, nameConflictStrategy, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) }) @@ -153,7 +157,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments val copy = arguments["copy"] as Boolean? var destinationDir = arguments["destinationPath"] as String? - if (copy == null || destinationDir == null) { + val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?) + if (copy == null || destinationDir == null || nameConflictStrategy == null) { error("move-args", "failed because of missing arguments", null) return } @@ -168,13 +173,41 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback { + provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) }) endOfStream() } + private suspend fun rename() { + if (arguments !is Map<*, *> || entryMapList.isEmpty()) { + endOfStream() + return + } + + val newName = arguments["newName"] as String? + if (newName == null) { + error("rename-args", "failed because of missing arguments", null) + return + } + + // assume same provider for all entries + val firstEntry = entryMapList.first() + val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) } + if (provider == null) { + error("rename-provider", "failed to find provider for entry=$firstEntry", null) + return + } + + val entries = entryMapList.map(::AvesEntry) + provider.renameMultiple(activity, newName, entries, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = success(fields) + override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message) + }) + endOfStream() + } + companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/media_op_stream" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt index 027f26506..f90e5971b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -21,7 +21,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E init { if (arguments is Map<*, *>) { - @Suppress("UNCHECKED_CAST") + @Suppress("unchecked_cast") knownEntries = arguments["knownEntries"] as Map? } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 58f2729cf..4a63445bf 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.streams import android.app.Activity import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper @@ -39,7 +40,8 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? handler = Handler(Looper.getMainLooper()) when (op) { - "requestVolumeAccess" -> GlobalScope.launch(Dispatchers.IO) { requestVolumeAccess() } + "requestDirectoryAccess" -> GlobalScope.launch(Dispatchers.IO) { requestDirectoryAccess() } + "requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() } "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } "selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() } @@ -47,19 +49,19 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? } } - private fun requestVolumeAccess() { + private fun requestDirectoryAccess() { val path = args["path"] as String? if (path == null) { - error("requestVolumeAccess-args", "failed because of missing arguments", null) + error("requestDirectoryAccess-args", "failed because of missing arguments", null) return } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - error("requestVolumeAccess-unsupported", "volume access is not allowed before Android Lollipop", null) + error("requestDirectoryAccess-unsupported", "directory access is not allowed before Android Lollipop", null) return } - PermissionManager.requestVolumeAccess(activity, path, { + PermissionManager.requestDirectoryAccess(activity, path, { success(true) endOfStream() }, { @@ -68,6 +70,28 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? }) } + private fun requestMediaFileAccess() { + val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null } + val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null } + if (uris == null || uris.isEmpty() || mimeTypes == null || mimeTypes.size != uris.size) { + error("requestMediaFileAccess-args", "failed because of missing arguments", null) + return + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + error("requestMediaFileAccess-unsupported", "media file bulk access is not allowed before Android R", null) + return + } + + try { + val granted = PermissionManager.requestMediaFileAccess(activity, uris, mimeTypes) + success(granted) + } catch (e: Exception) { + error("requestMediaFileAccess-request", "failed to request access to uris=$uris", e.message) + } + endOfStream() + } + private fun createFile() { val name = args["name"] as String? val mimeType = args["mimeType"] as String? diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 022b16cd5..31d6adf4c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -2,9 +2,7 @@ package deckers.thibault.aves.metadata import android.content.Context import android.net.Uri -import android.util.Log import androidx.exifinterface.media.ExifInterface -import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils import java.io.File @@ -15,7 +13,7 @@ import java.util.* import java.util.regex.Pattern object Metadata { - private val LOG_TAG = LogUtils.createTag() + const val IPTC_MARKER_BYTE: Byte = 0x1c // Pattern to extract latitude & longitude from a video location tag (cf ISO 6709) // Examples: @@ -31,6 +29,7 @@ object Metadata { const val DIR_XMP = "XMP" // from metadata-extractor const val DIR_MEDIA = "Media" // custom const val DIR_COVER_ART = "Cover" // custom + const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom // types of metadata const val TYPE_EXIF = "exif" @@ -135,7 +134,6 @@ object Metadata { } else { // make a preview from the beginning of the file, // hoping the metadata is accessible in the copied chunk - Log.d(LOG_TAG, "use a preview for uri=$uri mimeType=$mimeType size=$sizeBytes") var previewFile = previewFiles[uri] if (previewFile == null) { previewFile = createPreviewFile(context, uri) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 7295af93e..5c84e153b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -1,15 +1,27 @@ package deckers.thibault.aves.metadata import com.drew.lang.Rational +import com.drew.lang.SequentialByteArrayReader import com.drew.metadata.Directory import com.drew.metadata.exif.ExifIFD0Directory +import com.drew.metadata.iptc.IptcReader +import com.drew.metadata.png.PngDirectory import java.text.SimpleDateFormat import java.util.* object MetadataExtractorHelper { + const val PNG_ITXT_DIR_NAME = "PNG-iTXt" + private const val PNG_TEXT_DIR_NAME = "PNG-tEXt" const val PNG_TIME_DIR_NAME = "PNG-tIME" + private const val PNG_ZTXT_DIR_NAME = "PNG-zTXt" + val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT) + // Pattern to extract profile name, length, and text data + // of raw profiles (EXIF, IPTC, etc.) in PNG `zTXt` chunks + // e.g. "iptc [...] 114 [...] 3842494d040400[...]" + private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL) + // extensions fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) { @@ -59,4 +71,45 @@ object MetadataExtractorHelper { return true } + + // PNG + + fun Directory.isPngTextDir(): Boolean = this is PngDirectory && setOf(PNG_ITXT_DIR_NAME, PNG_TEXT_DIR_NAME, PNG_ZTXT_DIR_NAME).contains(this.name) + + fun extractPngProfile(key: String, valueString: String): Iterable? { + when (key) { + "Raw profile type iptc" -> { + val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString) + if (match != null) { + val dataString = match.groupValues[3] + val hexString = dataString.replace(Regex("[\\r\\n]"), "") + val dataBytes = hexStringToByteArray(hexString) + if (dataBytes != null) { + val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE) + if (start != -1) { + val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size - start) + val metadata = com.drew.metadata.Metadata() + IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong()) + return metadata.directories + } + } + } + } + } + return null + } + + // convenience methods + + private fun hexStringToByteArray(hexString: String): ByteArray? { + if (hexString.length % 2 != 0) return null + + val dataBytes = ByteArray(hexString.length / 2) + var i = 0 + while (i < hexString.length) { + dataBytes[i / 2] = hexString.substring(i, i + 2).toByte(16) + i += 2 + } + return dataBytes + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index 96bcbbbaa..588927a9a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -46,7 +46,7 @@ object MultiPage { val format = extractor.getTrackFormat(i) format.getString(MediaFormat.KEY_MIME)?.let { mime -> val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime - val track = hashMapOf( + val track: FieldMap = hashMapOf( KEY_PAGE to i, KEY_MIME_TYPE to trackMime, ) @@ -106,7 +106,7 @@ object MultiPage { val format = extractor.getTrackFormat(i) format.getString(MediaFormat.KEY_MIME)?.let { mime -> if (MimeTypes.isVideo(mime)) { - val track = hashMapOf( + val track: FieldMap = hashMapOf( KEY_PAGE to trackCount++, KEY_MIME_TYPE to MimeTypes.MP4, KEY_IS_DEFAULT to false, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt new file mode 100644 index 000000000..a6c3bb5a7 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt @@ -0,0 +1,12 @@ +package deckers.thibault.aves.model + +enum class NameConflictStrategy { + RENAME, REPLACE, SKIP; + + companion object { + fun get(name: String?): NameConflictStrategy? { + name ?: return null + return valueOf(name.uppercase()) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt index 1f1829db5..f780e0c2a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt @@ -9,6 +9,7 @@ import com.drew.imaging.ImageMetadataReader import com.drew.metadata.file.FileTypeDirectory import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes @@ -47,16 +48,16 @@ internal class ContentImageProvider : ImageProvider() { return } - val map = hashMapOf( + val fields: FieldMap = hashMapOf( "uri" to uri.toString(), "sourceMimeType" to mimeType, ) try { val cursor = context.contentResolver.query(uri, projection, null, null, null) if (cursor != null && cursor.moveToFirst()) { - cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) } - cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) } - cursor.getColumnIndex(PATH).let { if (it != -1) map["path"] = cursor.getString(it) } + cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) } + cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) } + cursor.getColumnIndex(PATH).let { if (it != -1) fields["path"] = cursor.getString(it) } cursor.close() } } catch (e: Exception) { @@ -64,7 +65,7 @@ internal class ContentImageProvider : ImageProvider() { return } - val entry = SourceEntry(map).fillPreCatalogMetadata(context) + val entry = SourceEntry(fields).fillPreCatalogMetadata(context) if (entry.isSized || entry.isSvg || entry.isVideo) { callback.onSuccess(entry.toMap()) } else { @@ -75,7 +76,7 @@ internal class ContentImageProvider : ImageProvider() { companion object { private val LOG_TAG = LogUtils.createTag() - @Suppress("DEPRECATION") + @Suppress("deprecation") const val PATH = MediaStore.MediaColumns.DATA private val projection = arrayOf( diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index ed1a66634..d129b187c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -2,10 +2,14 @@ package deckers.thibault.aves.model.provider import android.app.Activity import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.graphics.Bitmap import android.net.Uri +import android.os.Binder import android.os.Build import android.util.Log +import androidx.annotation.RequiresApi import androidx.exifinterface.media.ExifInterface import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat @@ -21,33 +25,36 @@ import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.MimeTypes.canEditExif import deckers.thibault.aves.utils.MimeTypes.canEditXmp import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isVideo -import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent -import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import java.io.ByteArrayInputStream import java.io.File -import java.io.FileNotFoundException import java.io.IOException import java.util.* +import kotlin.collections.HashMap abstract class ImageProvider { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider")) } - open suspend fun delete(activity: Activity, uri: Uri, path: String?) { + open suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) { throw UnsupportedOperationException("`delete` is not supported by this image provider") } - open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { + open suspend fun moveMultiple(activity: Activity, copy: Boolean, targetDir: String, nameConflictStrategy: NameConflictStrategy, entries: List, callback: ImageOpCallback) { callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider")) } + open suspend fun renameMultiple(activity: Activity, newFileName: String, entries: List, callback: ImageOpCallback) { + callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider")) + } + open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider") } @@ -57,19 +64,26 @@ abstract class ImageProvider { } suspend fun exportMultiple( - context: Context, + activity: Activity, imageExportMimeType: String, - destinationDir: String, + targetDir: String, entries: List, + nameConflictStrategy: NameConflictStrategy, callback: ImageOpCallback, ) { if (!supportedExportMimeTypes.contains(imageExportMimeType)) { - throw Exception("unsupported export MIME type=$imageExportMimeType") + callback.onFailure(Exception("unsupported export MIME type=$imageExportMimeType")) } - val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) - if (destinationDirDocFile == null) { - callback.onFailure(Exception("failed to create directory at path=$destinationDir")) + val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) + if (!File(targetDir).exists()) { + callback.onFailure(Exception("failed to create directory at path=$targetDir")) + return + } + + // TODO TLAD [storage] allow inserting by Media Store + if (targetDirDocFile == null) { + callback.onFailure(Exception("failed to get tree doc for directory at path=$targetDir")) return } @@ -78,7 +92,7 @@ abstract class ImageProvider { val sourcePath = entry.path val pageId = entry.pageId - val result = hashMapOf( + val result: FieldMap = hashMapOf( "uri" to sourceUri.toString(), "pageId" to pageId, "success" to false, @@ -88,16 +102,17 @@ abstract class ImageProvider { val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType try { val newFields = exportSingleByTreeDocAndScan( - context = context, + activity = activity, sourceEntry = entry, - destinationDir = destinationDir, - destinationDirDocFile = destinationDirDocFile, + targetDir = targetDir, + targetDirDocFile = targetDirDocFile, + nameConflictStrategy = nameConflictStrategy, exportMimeType = exportMimeType, ) result["newFields"] = newFields result["success"] = true } catch (e: Exception) { - Log.w(LOG_TAG, "failed to export to destinationDir=$destinationDir entry with sourcePath=$sourcePath pageId=$pageId", e) + Log.w(LOG_TAG, "failed to export to targetDir=$targetDir entry with sourcePath=$sourcePath pageId=$pageId", e) } callback.onSuccess(result) } @@ -105,10 +120,11 @@ abstract class ImageProvider { @Suppress("BlockingMethodInNonBlockingContext") private suspend fun exportSingleByTreeDocAndScan( - context: Context, + activity: Activity, sourceEntry: AvesEntry, - destinationDir: String, - destinationDirDocFile: DocumentFileCompat, + targetDir: String, + targetDirDocFile: DocumentFileCompat, + nameConflictStrategy: NameConflictStrategy, exportMimeType: String, ): FieldMap { val sourceMimeType = sourceEntry.mimeType @@ -117,7 +133,7 @@ abstract class ImageProvider { var desiredNameWithoutExtension = if (sourceEntry.path != null) { val sourceFileName = File(sourceEntry.path).name - sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") + sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "") } else { sourceUri.lastPathSegment!! } @@ -125,23 +141,29 @@ abstract class ImageProvider { val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" } - val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(exportMimeType)) + val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( + activity = activity, + dir = targetDir, + desiredNameWithoutExtension = desiredNameWithoutExtension, + mimeType = exportMimeType, + conflictStrategy = nameConflictStrategy, + ) ?: return skippedFieldMap // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile` // through a document URI, not a tree URI // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, availableNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) + val targetTreeFile = targetDirDocFile.createFile(exportMimeType, targetNameWithoutExtension) + val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri) if (isVideo(sourceMimeType)) { - val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri) - sourceDocFile.copyTo(destinationDocFile) + val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri) + sourceDocFile.copyTo(targetDocFile) } else { val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { - MultiTrackImage(context, sourceUri, pageId) + MultiTrackImage(activity, sourceUri, pageId) } else if (sourceMimeType == MimeTypes.TIFF) { - TiffImage(context, sourceUri, pageId) + TiffImage(activity, sourceUri, pageId) } else { StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType) } @@ -152,7 +174,7 @@ abstract class ImageProvider { .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(true) - val target = Glide.with(context) + val target = Glide.with(activity) .asBitmap() .apply(glideOptions) .load(model) @@ -160,11 +182,11 @@ abstract class ImageProvider { try { var bitmap = target.get() if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { - bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) + bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) } bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId") - destinationDocFile.openOutputStream().use { output -> + targetDocFile.openOutputStream().use { output -> if (exportMimeType == MimeTypes.BMP) { BmpWriter.writeRGB24(bitmap, output) } else { @@ -179,7 +201,7 @@ abstract class ImageProvider { Bitmap.CompressFormat.WEBP_LOSSY } } else { - @Suppress("DEPRECATION") + @Suppress("deprecation") Bitmap.CompressFormat.WEBP } else -> throw Exception("unsupported export MIME type=$exportMimeType") @@ -187,45 +209,75 @@ abstract class ImageProvider { bitmap.compress(format, quality, output) } } + } catch (e: Exception) { + // remove empty file + if (targetDocFile.exists()) { + targetDocFile.delete() + } + throw e } finally { - Glide.with(context).clear(target) + Glide.with(activity).clear(target) } } - val fileName = destinationDocFile.name - val destinationFullPath = destinationDir + fileName + val fileName = targetDocFile.name + val targetFullPath = targetDir + fileName - return MediaStoreImageProvider().scanNewPath(context, destinationFullPath, exportMimeType) + return MediaStoreImageProvider().scanNewPath(activity, targetFullPath, exportMimeType) } @Suppress("BlockingMethodInNonBlockingContext") suspend fun captureFrame( - context: Context, + activity: Activity, desiredNameWithoutExtension: String, exifFields: FieldMap, bytes: ByteArray, - destinationDir: String, + targetDir: String, + nameConflictStrategy: NameConflictStrategy, callback: ImageOpCallback, ) { - val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) - if (destinationDirDocFile == null) { - callback.onFailure(Exception("failed to create directory at path=$destinationDir")) + val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) + if (!File(targetDir).exists()) { + callback.onFailure(Exception("failed to create directory at path=$targetDir")) + return + } + + // TODO TLAD [storage] allow inserting by Media Store + if (targetDirDocFile == null) { + callback.onFailure(Exception("failed to get tree doc for directory at path=$targetDir")) return } val captureMimeType = MimeTypes.JPEG - val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(captureMimeType)) + val targetNameWithoutExtension = try { + resolveTargetFileNameWithoutExtension( + activity = activity, + dir = targetDir, + desiredNameWithoutExtension = desiredNameWithoutExtension, + mimeType = captureMimeType, + conflictStrategy = nameConflictStrategy, + ) + } catch (e: Exception) { + callback.onFailure(e) + return + } + + if (targetNameWithoutExtension == null) { + // skip it + callback.onSuccess(skippedFieldMap) + return + } // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile` // through a document URI, not a tree URI // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, availableNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) + val targetTreeFile = targetDirDocFile.createFile(captureMimeType, targetNameWithoutExtension) + val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri) try { if (exifFields.isEmpty()) { - destinationDocFile.openOutputStream().use { output -> + targetDocFile.openOutputStream().use { output -> output.write(bytes) } } else { @@ -284,55 +336,56 @@ abstract class ImageProvider { exif.saveAttributes() // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(editableFile).copyTo(destinationDocFile) + DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile) } - val fileName = destinationDocFile.name - val destinationFullPath = destinationDir + fileName - val newFields = MediaStoreImageProvider().scanNewPath(context, destinationFullPath, captureMimeType) + val fileName = targetDocFile.name + val targetFullPath = targetDir + fileName + val newFields = MediaStoreImageProvider().scanNewPath(activity, targetFullPath, captureMimeType) callback.onSuccess(newFields) } catch (e: Exception) { callback.onFailure(e) } } - private fun findAvailableFileNameWithoutExtension(dir: String, desiredNameWithoutExtension: String, extension: String?): String { - var nameWithoutExtension = desiredNameWithoutExtension - var i = 0 - while (File(dir, "$nameWithoutExtension$extension").exists()) { - i++ - nameWithoutExtension = "$desiredNameWithoutExtension ($i)" - } - return nameWithoutExtension - } - - suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { - val oldFile = File(oldPath) - val newFile = File(oldFile.parent, newFilename) - if (oldFile == newFile) { - Log.w(LOG_TAG, "new name and old name are the same, path=$oldPath") - callback.onSuccess(HashMap()) - return - } - - val df = getDocumentFile(context, oldPath, oldMediaUri) - try { - @Suppress("BlockingMethodInNonBlockingContext") - val renamed = df != null && df.renameTo(newFilename) - if (!renamed) { - callback.onFailure(Exception("failed to rename entry at path=$oldPath")) - return + // returns available name to use, or `null` to skip it + suspend fun resolveTargetFileNameWithoutExtension( + activity: Activity, + dir: String, + desiredNameWithoutExtension: String, + mimeType: String, + conflictStrategy: NameConflictStrategy, + ): String? { + val extension = extensionFor(mimeType) + val targetFile = File(dir, "$desiredNameWithoutExtension$extension") + return when (conflictStrategy) { + NameConflictStrategy.RENAME -> { + var nameWithoutExtension = desiredNameWithoutExtension + var i = 0 + while (File(dir, "$nameWithoutExtension$extension").exists()) { + i++ + nameWithoutExtension = "$desiredNameWithoutExtension ($i)" + } + nameWithoutExtension + } + NameConflictStrategy.REPLACE -> { + if (targetFile.exists()) { + val path = targetFile.path + MediaStoreImageProvider().apply { + val uri = getContentUriForPath(activity, path) + uri ?: throw Exception("failed to find content URI for path=$path") + delete(activity, uri, path, mimeType) + } + } + desiredNameWithoutExtension + } + NameConflictStrategy.SKIP -> { + if (targetFile.exists()) { + null + } else { + desiredNameWithoutExtension + } } - } catch (e: FileNotFoundException) { - callback.onFailure(e) - return - } - - scanObsoletePath(context, oldPath, mimeType) - try { - callback.onSuccess(MediaStoreImageProvider().scanNewPath(context, newFile.path, mimeType)) - } catch (e: Exception) { - callback.onFailure(e) } } @@ -350,12 +403,6 @@ abstract class ImageProvider { return false } - val originalDocumentFile = getDocumentFile(context, path, uri) - if (originalDocumentFile == null) { - callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) - return false - } - val originalFileSize = File(path).length() val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } var videoBytes: ByteArray? = null @@ -381,7 +428,7 @@ abstract class ImageProvider { } } else { // copy original file to a temporary file for editing - originalDocumentFile.openInputStream().use { imageInput -> + StorageUtils.openInputStream(context, uri)?.use { imageInput -> imageInput.copyTo(output) } } @@ -401,7 +448,7 @@ abstract class ImageProvider { } // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false @@ -428,18 +475,12 @@ abstract class ImageProvider { return false } - val originalDocumentFile = getDocumentFile(context, path, uri) - if (originalDocumentFile == null) { - callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) - return false - } - val originalFileSize = File(path).length() val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } val editableFile = File.createTempFile("aves", null).apply { deleteOnExit() try { - val xmp = originalDocumentFile.openInputStream().use { input -> PixyMetaHelper.getXmp(input) } + val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) } if (xmp == null) { callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri")) return false @@ -447,7 +488,7 @@ abstract class ImageProvider { outputStream().use { output -> // reopen input to read from start - originalDocumentFile.openInputStream().use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val editedXmpString = edit(xmp.xmpDocString()) val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString) @@ -461,7 +502,7 @@ abstract class ImageProvider { try { // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false @@ -476,7 +517,7 @@ abstract class ImageProvider { // A few bytes are sometimes appended when writing to a document output stream. // In that case, we need to adjust the trailer video offset accordingly and rewrite the file. - // return whether the file at `path` is fine + // returns whether the file at `path` is fine private fun checkTrailerOffset( context: Context, path: String, @@ -635,7 +676,7 @@ abstract class ImageProvider { } if (success) { - scanPostMetadataEdit(context, path, uri, mimeType, HashMap(), callback) + scanPostMetadataEdit(context, path, uri, mimeType, HashMap(), callback) } } @@ -652,12 +693,6 @@ abstract class ImageProvider { return } - val originalDocumentFile = getDocumentFile(context, path, uri) - if (originalDocumentFile == null) { - callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) - return - } - val originalFileSize = File(path).length() val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt() val editableFile = File.createTempFile("aves", null).apply { @@ -665,7 +700,7 @@ abstract class ImageProvider { try { outputStream().use { output -> // reopen input to read from start - originalDocumentFile.openInputStream().use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.removeMetadata(input, output, types) } } @@ -678,7 +713,7 @@ abstract class ImageProvider { try { // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return @@ -692,6 +727,22 @@ abstract class ImageProvider { scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) } + private fun copyTo( + context: Context, + mimeType: String, + sourceFile: File, + targetUri: Uri, + targetPath: String + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaUriPermissionGranted(context, targetUri, mimeType)) { + val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri") + DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream) + } else { + val targetDocumentFile = StorageUtils.getDocumentFile(context, targetPath, targetUri) ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri") + DocumentFileCompat.fromFile(sourceFile).copyTo(targetDocumentFile) + } + } + interface ImageOpCallback { fun onSuccess(fields: FieldMap) fun onFailure(throwable: Throwable) @@ -700,6 +751,21 @@ abstract class ImageProvider { companion object { private val LOG_TAG = LogUtils.createTag() + val FILE_EXTENSION_PATTERN = Regex("[.][^.]+$") + val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP) + + // used when skipping a move/creation op because the target file already exists + val skippedFieldMap: HashMap = hashMapOf("skipped" to true) + + @RequiresApi(Build.VERSION_CODES.Q) + fun isMediaUriPermissionGranted(context: Context, uri: Uri, mimeType: String): Boolean { + val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType) + + val pid = Binder.getCallingPid() + val uid = Binder.getCallingUid() + val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION + return context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED + } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 918a6990b..14bdb0260 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -4,25 +4,29 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.RecoverableSecurityException import android.content.ContentUris +import android.content.ContentValues import android.content.Context import android.media.MediaScannerConnection import android.net.Uri import android.os.Build +import android.os.Environment import android.provider.MediaStore import android.util.Log +import androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat -import deckers.thibault.aves.MainActivity.Companion.DELETE_PERMISSION_REQUEST +import deckers.thibault.aves.MainActivity +import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isVideo -import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent -import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator -import deckers.thibault.aves.utils.StorageUtils.getDocumentFile -import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission +import deckers.thibault.aves.utils.StorageUtils +import deckers.thibault.aves.utils.StorageUtils.PathSegments import deckers.thibault.aves.utils.UriUtils.tryParseId import java.io.File import java.util.* @@ -222,40 +226,64 @@ class MediaStoreImageProvider : ImageProvider() { return found } + private fun hasEntry(context: Context, contentUri: Uri): Boolean { + var found = false + val projection = arrayOf(MediaStore.MediaColumns._ID) + try { + val cursor = context.contentResolver.query(contentUri, projection, null, null, null) + if (cursor != null) { + while (cursor.moveToNext()) { + found = true + } + cursor.close() + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to get entry at contentUri=$contentUri", e) + } + return found + } + private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType // `uri` is a media URI, not a document URI - override suspend fun delete(activity: Activity, uri: Uri, path: String?) { - path ?: throw Exception("failed to delete file because path is null") + override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) { + if (!(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && isMediaUriPermissionGranted(activity, uri, mimeType)) + ) { + // if the file is on SD card, calling the content resolver `delete()` + // removes the entry from the Media Store but it doesn't delete the file, + // even when the app has the permission, so we manually delete the document file + path ?: throw Exception("failed to delete file because path is null") + if (File(path).exists() && StorageUtils.requireAccessPermission(activity, path)) { + Log.d(LOG_TAG, "delete document at uri=$uri path=$path") + val df = StorageUtils.getDocumentFile(activity, path, uri) - if (File(path).exists() && requireAccessPermission(activity, path)) { - // if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store - // but it doesn't delete the file, even if the app has the permission - val df = getDocumentFile(activity, path, uri) - - @Suppress("BlockingMethodInNonBlockingContext") - if (df != null && df.delete()) return - throw Exception("failed to delete file with df=$df") + @Suppress("BlockingMethodInNonBlockingContext") + if (df != null && df.delete()) return + throw Exception("failed to delete file with df=$df") + } } try { + Log.d(LOG_TAG, "delete content at uri=$uri") if (activity.contentResolver.delete(uri, null, null) > 0) return } catch (securityException: SecurityException) { // even if the app has access permission granted on the containing directory, // the delete request may yield a `RecoverableSecurityException` on Android 10+ // when the underlying file no longer exists and this is an orphaned entry in the Media Store if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Log.w(LOG_TAG, "caught a security exception when attempting to delete uri=$uri", securityException) val rse = securityException as? RecoverableSecurityException ?: throw securityException val intentSender = rse.userAction.actionIntent.intentSender // request user permission for this item - pendingDeleteCompleter = CompletableFuture() - activity.startIntentSenderForResult(intentSender, DELETE_PERMISSION_REQUEST, null, 0, 0, 0, null) - val granted = pendingDeleteCompleter!!.join() + MainActivity.pendingScopedStoragePermissionCompleter = CompletableFuture() + activity.startIntentSenderForResult(intentSender, DELETE_SINGLE_PERMISSION_REQUEST, null, 0, 0, 0, null) + val granted = MainActivity.pendingScopedStoragePermissionCompleter!!.join() - pendingDeleteCompleter = null + MainActivity.pendingScopedStoragePermissionCompleter = null if (granted) { - delete(activity, uri, path) + delete(activity, uri, path, mimeType) } else { throw Exception("failed to get delete permission") } @@ -269,13 +297,14 @@ class MediaStoreImageProvider : ImageProvider() { override suspend fun moveMultiple( activity: Activity, copy: Boolean, - destinationDir: String, + targetDir: String, + nameConflictStrategy: NameConflictStrategy, entries: List, callback: ImageOpCallback, ) { - val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) - if (destinationDirDocFile == null) { - callback.onFailure(Exception("failed to create directory at path=$destinationDir")) + val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) + if (!File(targetDir).exists()) { + callback.onFailure(Exception("failed to create directory at path=$targetDir")) return } @@ -284,7 +313,7 @@ class MediaStoreImageProvider : ImageProvider() { val sourcePath = entry.path val mimeType = entry.mimeType - val result = hashMapOf( + val result: FieldMap = hashMapOf( "uri" to sourceUri.toString(), "success" to false, ) @@ -305,77 +334,272 @@ class MediaStoreImageProvider : ImageProvider() { // - there is no documentation regarding support for usage with removable storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage try { - val newFields = moveSingleByTreeDocAndScan( - activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy, + val newFields = moveSingle( + activity = activity, + sourcePath = sourcePath, + sourceUri = sourceUri, + targetDir = targetDir, + targetDirDocFile = targetDirDocFile, + nameConflictStrategy = nameConflictStrategy, + mimeType = mimeType, + copy = copy, ) result["newFields"] = newFields result["success"] = true } catch (e: Exception) { - Log.w(LOG_TAG, "failed to move to destinationDir=$destinationDir entry with sourcePath=$sourcePath", e) + Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e) } } callback.onSuccess(result) } } - private suspend fun moveSingleByTreeDocAndScan( + private suspend fun moveSingle( activity: Activity, sourcePath: String, sourceUri: Uri, - destinationDir: String, - destinationDirDocFile: DocumentFileCompat, + targetDir: String, + targetDirDocFile: DocumentFileCompat?, + nameConflictStrategy: NameConflictStrategy, mimeType: String, copy: Boolean, ): FieldMap { val sourceFile = File(sourcePath) - val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) } - if (sourceDir == destinationDir) { - if (copy) throw Exception("file at path=$sourcePath is already in destination directory") - return HashMap() + val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) } + if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) { + // nothing to do unless it's a renamed copy + return skippedFieldMap } val sourceFileName = sourceFile.name - val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") + val desiredNameWithoutExtension = sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "") + val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( + activity = activity, + dir = targetDir, + desiredNameWithoutExtension = desiredNameWithoutExtension, + mimeType = mimeType, + conflictStrategy = nameConflictStrategy, + ) ?: return skippedFieldMap - if (File(destinationDir, sourceFileName).exists()) { - throw Exception("file with name=$sourceFileName already exists in destination directory") - } - - // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` - // but in order to open an output stream to it, we need to use a `SingleDocumentFile` - // through a document URI, not a tree URI - // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - @Suppress("BlockingMethodInNonBlockingContext") - val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri) + return moveSingleByTreeDoc( + activity = activity, + mimeType = mimeType, + sourceUri = sourceUri, + sourcePath = sourcePath, + targetDir = targetDir, + targetDirDocFile = targetDirDocFile, + targetNameWithoutExtension = targetNameWithoutExtension, + copy = copy + ) + } + private suspend fun moveSingleByTreeDoc( + activity: Activity, + mimeType: String, + sourceUri: Uri, + sourcePath: String, + targetDir: String, + targetDirDocFile: DocumentFileCompat?, + targetNameWithoutExtension: String, + copy: Boolean + ): FieldMap { // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" - // when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri` + // when used with entry URI as `sourceDocumentUri`, and targetDirDocFile URI as `targetParentDocumentUri` val source = DocumentFileCompat.fromSingleUri(activity, sourceUri) - @Suppress("BlockingMethodInNonBlockingContext") - source.copyTo(destinationDocFile) - // the source file name and the created document file name can be different when: - // - a file with the same name already exists, some implementations give a suffix like ` (1)`, some *do not* - // - the original extension does not match the extension added by the underlying provider - val fileName = destinationDocFile.name - val destinationFullPath = destinationDir + fileName + val targetPath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && isDownloadDir(activity, targetDir)) { + val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}" + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val resolver = activity.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + + uri?.let { + @Suppress("BlockingMethodInNonBlockingContext") + resolver.openOutputStream(uri)?.use { output -> + source.copyTo(output) + } + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } ?: throw Exception("MediaStore failed for some reason") + + File(targetDir, targetFileName).path + } else { + targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir") + + // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` + // but in order to open an output stream to it, we need to use a `SingleDocumentFile` + // through a document URI, not a tree URI + // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first + @Suppress("BlockingMethodInNonBlockingContext") + val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension) + val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri) + + @Suppress("BlockingMethodInNonBlockingContext") + source.copyTo(targetDocFile) + + // the source file name and the created document file name can be different when: + // - a file with the same name already exists, some implementations give a suffix like ` (1)`, some *do not* + // - the original extension does not match the extension added by the underlying provider + val fileName = targetDocFile.name + targetDir + fileName + } - var deletedSource = false if (!copy) { // delete original entry try { - delete(activity, sourceUri, sourcePath) - deletedSource = true + delete(activity, sourceUri, sourcePath, mimeType) } catch (e: Exception) { Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) } } - return scanNewPath(activity, destinationFullPath, mimeType).apply { - put("deletedSource", deletedSource) + return scanNewPath(activity, targetPath, mimeType) + } + + private fun isDownloadDir(context: Context, dirPath: String): Boolean { + var relativeDir = PathSegments(context, dirPath).relativeDir ?: "" + if (relativeDir.endsWith(File.separator)) { + relativeDir = relativeDir.substring(0, relativeDir.length - 1) } + return relativeDir == Environment.DIRECTORY_DOWNLOADS + } + + override suspend fun renameMultiple( + activity: Activity, + newFileName: String, + entries: List, + callback: ImageOpCallback, + ) { + for (entry in entries) { + val sourceUri = entry.uri + val sourcePath = entry.path + val mimeType = entry.mimeType + + val result: FieldMap = hashMapOf( + "uri" to sourceUri.toString(), + "success" to false, + ) + + if (sourcePath != null) { + try { + val newFields = renameSingle( + activity = activity, + mimeType = mimeType, + oldMediaUri = sourceUri, + oldPath = sourcePath, + newFileName = newFileName, + ) + result["newFields"] = newFields + result["success"] = true + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to rename to newFileName=$newFileName entry with sourcePath=$sourcePath", e) + } + } + callback.onSuccess(result) + } + } + + private suspend fun renameSingle( + activity: Activity, + mimeType: String, + oldMediaUri: Uri, + oldPath: String, + newFileName: String, + ): FieldMap { + val oldFile = File(oldPath) + val newFile = File(oldFile.parent, newFileName) + if (oldFile == newFile) { + // nothing to do + return skippedFieldMap + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && isMediaUriPermissionGranted(activity, oldMediaUri, mimeType) + ) { + renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile) + } else { + renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private suspend fun renameSingleByMediaStore( + activity: Activity, + mimeType: String, + mediaUri: Uri, + newFile: File + ): FieldMap { + val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType) + + // `IS_PENDING` is necessary for `TITLE`, not for `DISPLAY_NAME` + val tempValues = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 1) } + if (activity.contentResolver.update(uri, tempValues, null, null) == 0) { + throw Exception("failed to update fields for uri=$uri") + } + + val finalValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, newFile.name) + // scanning the new file will not automatically update `TITLE` + put(MediaStore.MediaColumns.TITLE, newFile.nameWithoutExtension) + put(MediaStore.MediaColumns.IS_PENDING, 0) + } + if (activity.contentResolver.update(uri, finalValues, null, null) == 0) { + throw Exception("failed to update fields for uri=$uri") + } + + // URI should not change + return scanNewPath(activity, newFile.path, mimeType) + } + + private suspend fun renameSingleByTreeDoc( + activity: Activity, + mimeType: String, + oldMediaUri: Uri, + oldPath: String, + newFile: File + ): FieldMap { + Log.d(LOG_TAG, "rename document at uri=$oldMediaUri path=$oldPath") + @Suppress("BlockingMethodInNonBlockingContext") + val renamed = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFile.name) ?: false + if (!renamed) { + throw Exception("failed to rename entry at path=$oldPath") + } + + // Renaming may be successful and the file at the old path no longer exists + // but, in some situations, scanning the old path does not clear the Media Store entry. + // For higher chance of accurate obsolete item check, keep this order: + // 1) scan obsolete item, + // 2) scan current item, + // 3) check obsolete item in Media Store + + scanObsoletePath(activity, oldPath, mimeType) + val newFields = scanNewPath(activity, newFile.path, mimeType) + + if (hasEntry(activity, oldMediaUri)) { + Log.w(LOG_TAG, "renaming item at uri=$oldMediaUri to newFile=$newFile did not clear the MediaStore entry for obsolete path=$oldPath") + + // On Android Q (emulator/Mi9TPro), the concept of owner package disrupts renaming and the Media Store keeps an obsolete entry, + // but we use legacy external storage, so at least we do not have to deal with a `RecoverableSecurityException` + // when deleting this obsolete entry which is not backed by a file anymore. + // On Android R (S10e), everything seems fine! + // On Android S (emulator), renaming always leaves an obsolete entry whatever the owner package, + // and we get a `RecoverableSecurityException` if we attempt to delete this obsolete entry, + // but the entry seems to be cleaned later automatically by the Media Store anyway. + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + try { + delete(activity, oldMediaUri, oldPath, mimeType) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to delete entry with path=$oldPath", e) + } + } + } + + return newFields } override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { @@ -445,9 +669,9 @@ class MediaStoreImageProvider : ImageProvider() { val contentId = newUri.tryParseId() if (contentId != null) { if (isImage(mimeType)) { - contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId) + contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId) } else if (isVideo(mimeType)) { - contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId) + contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId) } } @@ -462,6 +686,29 @@ class MediaStoreImageProvider : ImageProvider() { } } + fun getContentUriForPath(context: Context, path: String): Uri? { + val projection = arrayOf(MediaStore.MediaColumns._ID) + val selection = "${MediaColumns.PATH} = ?" + val selectionArgs = arrayOf(path) + + fun check(context: Context, contentUri: Uri): Uri? { + var mediaContentUri: Uri? = null + try { + val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, null) + if (cursor != null && cursor.moveToFirst()) { + cursor.getColumnIndex(MediaStore.MediaColumns._ID).let { + if (it != -1) mediaContentUri = ContentUris.withAppendedId(contentUri, cursor.getLong(it)) + } + cursor.close() + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to get URI for contentUri=$contentUri path=$path", e) + } + return mediaContentUri + } + return check(context, IMAGE_CONTENT_URI) ?: check(context, VIDEO_CONTENT_URI) + } + companion object { private val LOG_TAG = LogUtils.createTag() @@ -494,8 +741,6 @@ class MediaStoreImageProvider : ImageProvider() { MediaStore.MediaColumns.ORIENTATION, ) else emptyArray() ) - - var pendingDeleteCompleter: CompletableFuture? = null } } @@ -513,7 +758,7 @@ object MediaColumns { @SuppressLint("InlinedApi") const val DURATION = MediaStore.MediaColumns.DURATION - @Suppress("DEPRECATION") + @Suppress("deprecation") const val PATH = MediaStore.MediaColumns.DATA } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt new file mode 100644 index 000000000..d31dd5c26 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt @@ -0,0 +1,42 @@ +package deckers.thibault.aves.utils + +import android.app.ActivityManager +import android.app.Service +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Handler +import android.os.Looper +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +object ContextUtils { + fun Context.resourceUri(resourceId: Int): Uri = with(resources) { + Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(getResourcePackageName(resourceId)) + .appendPath(getResourceTypeName(resourceId)) + .appendPath(getResourceEntryName(resourceId)) + .build() + } + + suspend fun Context.runOnUiThread(r: Runnable) { + if (Looper.myLooper() != mainLooper) { + suspendCoroutine { cont -> + Handler(mainLooper).post { + r.run() + cont.resume(true) + } + } + } else { + r.run() + } + } + + fun Context.isMyServiceRunning(serviceClass: Class): Boolean { + val am = this.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + am ?: return false + @Suppress("deprecation") + return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name } + } +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt new file mode 100644 index 000000000..98a1dbed6 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt @@ -0,0 +1,49 @@ +package deckers.thibault.aves.utils + +import android.content.Context +import android.util.Log +import deckers.thibault.aves.utils.ContextUtils.runOnUiThread +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.loader.FlutterLoader +import io.flutter.view.FlutterCallbackInformation + +object FlutterUtils { + private val LOG_TAG = LogUtils.createTag() + + suspend fun initFlutterEngine(context: Context, sharedPreferencesKey: String, callbackHandleKey: String, engineSetter: (engine: FlutterEngine) -> Unit) { + val callbackHandle = context.getSharedPreferences(sharedPreferencesKey, Context.MODE_PRIVATE).getLong(callbackHandleKey, 0) + if (callbackHandle == 0L) { + Log.e(LOG_TAG, "failed to retrieve registered callback handle for sharedPreferencesKey=$sharedPreferencesKey callbackHandleKey=$callbackHandleKey") + return + } + + lateinit var flutterLoader: FlutterLoader + context.runOnUiThread { + // initialization must happen on the main thread + flutterLoader = FlutterInjector.instance().flutterLoader().apply { + startInitialization(context) + ensureInitializationComplete(context, null) + } + } + + val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) + if (callbackInfo == null) { + Log.e(LOG_TAG, "failed to find callback information for sharedPreferencesKey=$sharedPreferencesKey callbackHandleKey=$callbackHandleKey") + return + } + + val args = DartExecutor.DartCallback( + context.assets, + flutterLoader.findAppBundlePath(), + callbackInfo + ) + context.runOnUiThread { + val engine = FlutterEngine(context).apply { + dartExecutor.executeDartCallback(args) + } + engineSetter(engine) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt index eb4d5bd93..e7fb5f79b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt @@ -1,20 +1,20 @@ package deckers.thibault.aves.utils -import java.util.regex.Pattern - object LogUtils { const val LOG_TAG_MAX_LENGTH = 23 - val LOG_TAG_PACKAGE_PATTERN: Pattern = Pattern.compile("(\\w)(\\w*)\\.") + + val LOG_TAG_PACKAGE_PATTERN = Regex("(\\w)(\\w*)\\.") + val LOWER_CASE_PATTERN = Regex("[a-z]") // create an Android logger friendly log tag for the specified class inline fun createTag(): String { val kClass = T::class // shorten class name to "a.b.CccDdd" - var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(kClass.qualifiedName!!).replaceAll("$1.") + var logTag = LOG_TAG_PACKAGE_PATTERN.replace(kClass.qualifiedName!!, "$1.") if (logTag.length > LOG_TAG_MAX_LENGTH) { // shorten class name to "a.b.CD" val simpleName = kClass.simpleName!! - val shortSimpleName = simpleName.replace("[a-z]".toRegex(), "") + val shortSimpleName = simpleName.replace(LOWER_CASE_PATTERN, "") logTag = logTag.replace(simpleName, shortSimpleName) if (logTag.length > LOG_TAG_MAX_LENGTH) { // shorten class name to "CD" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index a5cece985..eb2e8bcd4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -23,21 +23,34 @@ object MimeTypes { // raw raster private const val ARW = "image/x-sony-arw" private const val CR2 = "image/x-canon-cr2" + private const val CRW = "image/x-canon-crw" + private const val DCR = "image/x-kodak-dcr" private const val DNG = "image/x-adobe-dng" + private const val ERF = "image/x-epson-erf" + private const val K25 = "image/x-kodak-k25" + private const val KDC = "image/x-kodak-kdc" + private const val MRW = "image/x-minolta-mrw" private const val NEF = "image/x-nikon-nef" private const val NRW = "image/x-nikon-nrw" private const val ORF = "image/x-olympus-orf" private const val PEF = "image/x-pentax-pef" private const val RAF = "image/x-fuji-raf" + private const val RAW = "image/x-panasonic-raw" private const val RW2 = "image/x-panasonic-rw2" + private const val SR2 = "image/x-sony-sr2" + private const val SRF = "image/x-sony-srf" private const val SRW = "image/x-samsung-srw" + private const val X3F = "image/x-sigma-x3f" // vector const val SVG = "image/svg+xml" private const val VIDEO = "video" + private const val AVI = "video/avi" + private const val AVI_VND = "video/vnd.avi" private const val MKV = "video/x-matroska" + private const val MOV = "video/quicktime" private const val MP2T = "video/mp2t" private const val MP2TS = "video/mp2ts" const val MP4 = "video/mp4" @@ -125,16 +138,47 @@ object MimeTypes { // extensions fun extensionFor(mimeType: String): String? = when (mimeType) { + ARW -> ".arw" + AVI, AVI_VND -> ".avi" BMP -> ".bmp" + CR2 -> ".cr2" + CRW -> ".crw" + DCR -> ".dcr" + DJVU -> ".djvu" + DNG -> ".dng" + ERF -> ".erf" GIF -> ".gif" HEIC, HEIF -> ".heif" + ICO -> ".ico" JPEG -> ".jpg" + K25 -> ".k25" + KDC -> ".kdc" + MKV -> ".mkv" + MOV -> ".mov" + MP2T, MP2TS -> ".m2ts" MP4 -> ".mp4" + MRW -> ".mrw" + NEF -> ".nef" + NRW -> ".nrw" + OGV -> ".ogv" + ORF -> ".orf" + PEF -> ".pef" PNG -> ".png" + PSD_VND, PSD_X -> ".psd" + RAF -> ".raf" + RAW -> ".raw" + RW2 -> ".rw2" + SR2 -> ".sr2" + SRF -> ".srf" + SRW -> ".srw" + SVG -> ".svg" TIFF -> ".tiff" + WBMP -> ".wbmp" + WEBM -> ".webm" WEBP -> ".webp" + X3F -> ".x3f" else -> null } - val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) + val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 96da1ae62..1655f9430 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -3,24 +3,35 @@ package deckers.thibault.aves.utils import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Binder import android.os.Build import android.os.Environment import android.os.storage.StorageManager +import android.provider.MediaStore import android.util.Log import androidx.annotation.RequiresApi import deckers.thibault.aves.MainActivity import deckers.thibault.aves.PendingStorageAccessResultHandler +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.StorageUtils.PathSegments import java.io.File import java.util.* +import java.util.concurrent.CompletableFuture import kotlin.collections.ArrayList object PermissionManager { private val LOG_TAG = LogUtils.createTag() + private val MEDIA_STORE_INSERTION_PRIMARY_DIRS = listOf( + Environment.DIRECTORY_DCIM, + Environment.DIRECTORY_DOWNLOADS, + Environment.DIRECTORY_PICTURES, + ) + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) - fun requestVolumeAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { + fun requestDirectoryAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { Log.i(LOG_TAG, "request user to select and grant access permission to path=$path") var intent: Intent? = null @@ -43,6 +54,35 @@ object PermissionManager { } } + @RequiresApi(Build.VERSION_CODES.R) + fun requestMediaFileAccess(activity: Activity, uris: List, mimeTypes: List): Boolean { + val safeUris = uris.mapIndexed { index, uri -> StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeTypes[index]) } + + val todoUris = ArrayList() + val pid = Binder.getCallingPid() + val uid = Binder.getCallingUid() + val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + activity.checkUriPermissions(safeUris, pid, uid, flags) + } else { + safeUris.map { activity.checkUriPermission(it, pid, uid, flags) }.toIntArray() + }.forEachIndexed { index, permission -> + if (permission != PackageManager.PERMISSION_GRANTED) { + todoUris.add(safeUris[index]) + } + } + if (todoUris.isEmpty()) return true + + Log.i(LOG_TAG, "request user to select and grant access permission to uris=$todoUris") + val intentSender = MediaStore.createWriteRequest(activity.contentResolver, safeUris).intentSender + MainActivity.pendingScopedStoragePermissionCompleter = CompletableFuture() + activity.startIntentSenderForResult(intentSender, MainActivity.MEDIA_WRITE_BULK_PERMISSION_REQUEST, null, 0, 0, 0, null) + val granted = MainActivity.pendingScopedStoragePermissionCompleter!!.join() + MainActivity.pendingScopedStoragePermissionCompleter = null + + return granted + } + fun getGrantedDirForPath(context: Context, anyPath: String): String? { return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) } } @@ -130,6 +170,18 @@ object PermissionManager { return dirs } + fun canInsertByMediaStore(directories: List): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + directories.all { + val relativeDir = it["relativeDir"] as String + val segments = relativeDir.split(File.separator) + segments.isNotEmpty() && MEDIA_STORE_INSERTION_PRIMARY_DIRS.contains(segments.first()) + } + } else { + true + } + } + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) fun revokeDirectoryAccess(context: Context, path: String): Boolean { return StorageUtils.convertDirPathToTreeUri(context, path)?.let { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index a5f2f3f29..4691a6bee 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -23,12 +23,16 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId import java.io.File import java.io.FileNotFoundException import java.io.InputStream +import java.io.OutputStream import java.util.* import java.util.regex.Pattern object StorageUtils { private val LOG_TAG = LogUtils.createTag() + private const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/" + private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)") + /** * Volume paths */ @@ -269,8 +273,8 @@ object StorageUtils { // content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/ // content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/ fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? { - val encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length) - val matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded)) + val encoded = treeUri.toString().substring(TREE_URI_ROOT.length) + val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded)) with(matcher) { if (find()) { val uuid = group(1) @@ -329,7 +333,7 @@ object StorageUtils { // returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise) // returns null if directory does not exist and could not be created - fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? { + fun createDirectoryDocIfAbsent(context: Context, dirPath: String): DocumentFileCompat? { val cleanDirPath = ensureTrailingSeparator(dirPath) return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null @@ -427,7 +431,14 @@ object StorageUtils { // This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException` // for some content URIs (e.g. `content://media/external_primary/downloads/...`) // so we build a typical `images` or `videos` content URI from the original content ID. - fun getGlideSafeUri(uri: Uri, mimeType: String): Uri { + fun getGlideSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType) + + // requesting access or writing to some MediaStore content URIs + // e.g. `content://0@media/...`, `content://media/external_primary/downloads/...` + // yields an exception with `All requested items must be referenced by specific ID` + fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType) + + private fun normalizeMediaUri(uri: Uri, mimeType: String): Uri { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) { // we cannot safely apply this to a file content URI, as it may point to a file not indexed // by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI @@ -439,7 +450,11 @@ object StorageUtils { else -> uri } } + } else if (uri.userInfo != null) { + // strip user info, if any + return Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", "")) } + } return uri } @@ -448,11 +463,22 @@ object StorageUtils { val effectiveUri = getOriginalUri(context, uri) return try { context.contentResolver.openInputStream(effectiveUri) - } catch (e: FileNotFoundException) { - Log.w(LOG_TAG, "failed to find file at uri=$effectiveUri") + } catch (e: Exception) { + // among various other exceptions, + // opening a file marked pending and owned by another package throws an `IllegalStateException` + Log.w(LOG_TAG, "failed to open input stream for uri=$uri effectiveUri=$effectiveUri", e) null - } catch (e: SecurityException) { - Log.w(LOG_TAG, "failed to open file at uri=$effectiveUri", e) + } + } + + fun openOutputStream(context: Context, uri: Uri, mimeType: String): OutputStream? { + val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType) + return try { + context.contentResolver.openOutputStream(effectiveUri) + } catch (e: Exception) { + // among various other exceptions, + // opening a file marked pending and owned by another package throws an `IllegalStateException` + Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri", e) null } } @@ -467,7 +493,7 @@ object StorageUtils { } } catch (e: Exception) { // unsupported format - Log.w(LOG_TAG, "failed to initialize MediaMetadataRetriever for uri=$uri") + Log.w(LOG_TAG, "failed to initialize MediaMetadataRetriever for uri=$uri effectiveUri=$effectiveUri") null } } @@ -482,7 +508,7 @@ object StorageUtils { class PathSegments(context: Context, fullPath: String) { var volumePath: String? = null // `volumePath` with trailing "/" var relativeDir: String? = null // `relativeDir` with trailing "/" - private var fileName: String? = null // null for directories + var fileName: String? = null // null for directories init { volumePath = getVolumePath(context, fullPath) diff --git a/android/app/src/main/res/drawable-v21/ic_notification.xml b/android/app/src/main/res/drawable-v21/ic_notification.xml new file mode 100644 index 000000000..a9fdcd46f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/ic_notification.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_outline_stop_24.xml b/android/app/src/main/res/drawable/ic_outline_stop_24.xml new file mode 100644 index 000000000..0214f1220 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_outline_stop_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml index 2c44e7463..2f2fe17ec 100644 --- a/android/app/src/main/res/values-ko/strings.xml +++ b/android/app/src/main/res/values-ko/strings.xml @@ -1,6 +1,10 @@  - 아베스 - 검색 - 동영상 + 아베스 + 검색 + 동영상 + 미디어 분석 + 사진과 동영상 분석 + 미디어 분석 + 취소 \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c296a8709..a37cd760c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,4 +3,8 @@ Aves Search Videos + Media scan + Scan images & videos + Scanning media + Stop \ No newline at end of file diff --git a/lib/geo/format.dart b/lib/geo/format.dart deleted file mode 100644 index a52dfd192..000000000 --- a/lib/geo/format.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:aves/utils/math_utils.dart'; -import 'package:intl/intl.dart'; -import 'package:latlong2/latlong.dart'; - -String _decimal2sexagesimal(final double degDecimal) { - List _split(final double value) { - // NumberFormat is necessary to create digit after comma if the value - // has no decimal point (only necessary for browser) - final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.'); - return [ - int.parse(tmp[0]).abs(), - int.parse(tmp[1]), - ]; - } - - final deg = _split(degDecimal)[0]; - final minDecimal = (degDecimal.abs() - deg) * 60; - final min = _split(minDecimal)[0]; - final sec = (minDecimal - min) * 60; - - return '$deg° $min′ ${roundToPrecision(sec, decimals: 2).toStringAsFixed(2)}″'; -} - -// returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E'] -List toDMS(LatLng latLng) { - final lat = latLng.latitude; - final lng = latLng.longitude; - return [ - '${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}', - '${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}', - ]; -} diff --git a/lib/image_providers/app_icon_image_provider.dart b/lib/image_providers/app_icon_image_provider.dart index 3628e7481..c9f70632e 100644 --- a/lib/image_providers/app_icon_image_provider.dart +++ b/lib/image_providers/app_icon_image_provider.dart @@ -1,6 +1,6 @@ import 'dart:ui' as ui show Codec; -import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/common/services.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -39,7 +39,7 @@ class AppIconImage extends ImageProvider { Future _loadAsync(AppIconImageKey key, DecoderCallback decode) async { try { - final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size); + final bytes = await androidAppService.getAppIcon(key.packageName, key.size); return await decode(bytes.isEmpty ? kTransparentImage : bytes); } catch (error) { debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error'); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9217b8d68..a89aaabb3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -62,8 +62,10 @@ "@sourceStateLoading": {}, "sourceStateCataloguing": "Cataloguing", "@sourceStateCataloguing": {}, - "sourceStateLocating": "Locating", - "@sourceStateLocating": {}, + "sourceStateLocatingCountries": "Locating countries", + "@sourceStateLocatingCountries": {}, + "sourceStateLocatingPlaces": "Locating places", + "@sourceStateLocatingPlaces": {}, "chipActionDelete": "Delete", "@chipActionDelete": {}, @@ -159,6 +161,8 @@ "@filterTypeMotionPhotoLabel": {}, "filterTypePanoramaLabel": "Panorama", "@filterTypePanoramaLabel": {}, + "filterTypeRawLabel": "Raw", + "@filterTypeRawLabel": {}, "filterTypeSphericalVideoLabel": "360° Video", "@filterTypeSphericalVideoLabel": {}, "filterTypeGeotiffLabel": "GeoTIFF", @@ -173,6 +177,11 @@ "coordinateFormatDecimal": "Decimal degrees", "@coordinateFormatDecimal": {}, + "unitSystemMetric": "Metric", + "@unitSystemMetric": {}, + "unitSystemImperial": "Imperial", + "@unitSystemImperial": {}, + "videoLoopModeNever": "Never", "@videoLoopModeNever": {}, "videoLoopModeShortOnly": "Short videos only", @@ -193,6 +202,13 @@ "mapStyleStamenWatercolor": "Stamen Watercolor", "@mapStyleStamenWatercolor": {}, + "nameConflictStrategyRename": "Rename", + "@nameConflictStrategyRename": {}, + "nameConflictStrategyReplace": "Replace", + "@nameConflictStrategyReplace": {}, + "nameConflictStrategySkip": "Skip", + "@nameConflictStrategySkip": {}, + "keepScreenOnNever": "Never", "@keepScreenOnNever": {}, "keepScreenOnViewerOnly": "Viewer page only", @@ -273,6 +289,11 @@ } }, + "nameConflictDialogSingleSourceMessage": "Some files in the destination folder have the same name.", + "@nameConflictDialogSingleSourceMessage": {}, + "nameConflictDialogMultipleSourceMessage": "Some files have the same name.", + "@nameConflictDialogMultipleSourceMessage": {}, + "addShortcutDialogLabel": "Shortcut label", "@addShortcutDialogLabel": {}, "addShortcutButtonLabel": "ADD", @@ -327,6 +348,9 @@ } }, + "exportEntryDialogFormat": "Format:", + "@exportEntryDialogFormat": {}, + "renameEntryDialogLabel": "New name", "@renameEntryDialogLabel": {}, @@ -555,6 +579,8 @@ "@drawerCollectionMotionPhotos": {}, "drawerCollectionPanoramas": "Panoramas", "@drawerCollectionPanoramas": {}, + "drawerCollectionRaws": "Raw photos", + "@drawerCollectionRaws": {}, "drawerCollectionSphericalVideos": "360° Videos", "@drawerCollectionSphericalVideos": {}, @@ -688,8 +714,8 @@ "settingsSectionViewer": "Viewer", "@settingsSectionViewer": {}, - "settingsImageBackground": "Image background", - "@settingsImageBackground": {}, + "settingsViewerShowOverlayOnOpening": "Show overlay on opening", + "@settingsViewerShowOverlayOnOpening": {}, "settingsViewerShowMinimap": "Show minimap", "@settingsViewerShowMinimap": {}, "settingsViewerShowInformation": "Show information", @@ -702,6 +728,8 @@ "@settingsViewerEnableOverlayBlurEffect": {}, "settingsViewerUseCutout": "Use cutout area", "@settingsViewerUseCutout": {}, + "settingsImageBackground": "Image background", + "@settingsImageBackground": {}, "settingsViewerQuickActionsTile": "Quick actions", "@settingsViewerQuickActionsTile": {}, @@ -821,6 +849,10 @@ "@settingsCoordinateFormatTile": {}, "settingsCoordinateFormatTitle": "Coordinate Format", "@settingsCoordinateFormatTitle": {}, + "settingsUnitSystemTile": "Units", + "@settingsUnitSystemTile": {}, + "settingsUnitSystemTitle": "Units", + "@settingsUnitSystemTitle": {}, "statsPageTitle": "Stats", "@statsPageTitle": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index d35bb1b32..472bb957c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -27,7 +27,8 @@ "sourceStateLoading": "로딩 중", "sourceStateCataloguing": "분석 중", - "sourceStateLocating": "장소 찾는 중", + "sourceStateLocatingCountries": "국가 찾는 중", + "sourceStateLocatingPlaces": "장소 찾는 중", "chipActionDelete": "삭제", "chipActionGoToAlbumPage": "앨범 페이지에서 보기", @@ -78,6 +79,7 @@ "filterTypeAnimatedLabel": "애니메이션", "filterTypeMotionPhotoLabel": "모션 포토", "filterTypePanoramaLabel": "파노라마", + "filterTypeRawLabel": "Raw", "filterTypeSphericalVideoLabel": "360° 동영상", "filterTypeGeotiffLabel": "GeoTIFF", "filterMimeImageLabel": "사진", @@ -86,6 +88,9 @@ "coordinateFormatDms": "도분초", "coordinateFormatDecimal": "소수점", + "unitSystemMetric": "미터법", + "unitSystemImperial": "야드파운드법", + "videoLoopModeNever": "반복 안 함", "videoLoopModeShortOnly": "짧은 동영상만 반복", "videoLoopModeAlways": "항상 반복", @@ -97,6 +102,10 @@ "mapStyleStamenToner": "Stamen 토너", "mapStyleStamenWatercolor": "Stamen 수채화", + "nameConflictStrategyRename": "이름 변경", + "nameConflictStrategyReplace": "대체", + "nameConflictStrategySkip": "건너뛰기", + "keepScreenOnNever": "자동 꺼짐", "keepScreenOnViewerOnly": "뷰어 이용 시 작동", "keepScreenOnAlways": "항상 켜짐", @@ -121,6 +130,9 @@ "notEnoughSpaceDialogTitle": "저장공간 부족", "notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.", + "nameConflictDialogSingleSourceMessage": "이동할 폴더에 이름이 같은 파일이 있습니다.", + "nameConflictDialogMultipleSourceMessage": "이름이 같은 파일이 있습니다.", + "addShortcutDialogLabel": "바로가기 라벨", "addShortcutButtonLabel": "추가", @@ -146,6 +158,8 @@ "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}", + "exportEntryDialogFormat": "형식:", + "renameEntryDialogLabel": "이름", "editEntryDateDialogTitle": "날짜 및 시간", @@ -256,6 +270,7 @@ "drawerCollectionVideos": "동영상", "drawerCollectionMotionPhotos": "모션 포토", "drawerCollectionPanoramas": "파노라마", + "drawerCollectionRaws": "Raw 이미지", "drawerCollectionSphericalVideos": "360° 동영상", "chipSortTitle": "정렬", @@ -330,13 +345,14 @@ "settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.", "settingsSectionViewer": "뷰어", - "settingsImageBackground": "사진 배경", + "settingsViewerShowOverlayOnOpening": "열릴 때 오버레이 표시", "settingsViewerShowMinimap": "미니맵 표시", "settingsViewerShowInformation": "상세 정보 표시", "settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시", "settingsViewerShowShootingDetails": "촬영 정보 표시", "settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과", "settingsViewerUseCutout": "컷아웃 영역 사용", + "settingsImageBackground": "이미지 배경", "settingsViewerQuickActionsTile": "빠른 작업", "settingsViewerQuickActionEditorTitle": "빠른 작업", @@ -401,6 +417,8 @@ "settingsLanguage": "언어", "settingsCoordinateFormatTile": "좌표 표현", "settingsCoordinateFormatTitle": "좌표 표현", + "settingsUnitSystemTile": "단위법", + "settingsUnitSystemTitle": "단위법", "statsPageTitle": "통계", "statsImage": "{count, plural, other{사진}}", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 6177764b8..a3bd9f7d3 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry_cache.dart'; @@ -8,7 +10,6 @@ import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/multipage.dart'; -import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/video/metadata.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/service_policy.dart'; @@ -20,7 +21,6 @@ import 'package:aves/utils/change_notifier.dart'; import 'package:collection/collection.dart'; import 'package:country_code/country_code.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; class AvesEntry { @@ -76,6 +76,7 @@ class AvesEntry { String? uri, String? path, int? contentId, + String? title, int? dateModifiedSecs, List? burstEntries, }) { @@ -90,7 +91,7 @@ class AvesEntry { height: height, sourceRotationDegrees: sourceRotationDegrees, sizeBytes: sizeBytes, - sourceTitle: sourceTitle, + sourceTitle: title ?? sourceTitle, dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis, @@ -160,6 +161,7 @@ class AvesEntry { String? get path => _path; + // directory path, without the trailing separator String? get directory { _directory ??= path != null ? pContext.dirname(path!) : null; return _directory; @@ -170,11 +172,14 @@ class AvesEntry { return _filename; } + // file extension, including the `.` String? get extension { _extension ??= path != null ? pContext.extension(path!) : null; return _extension; } + bool get isMissingAtPath => path != null && !File(path!).existsSync(); + // the MIME type reported by the Media Store is unreliable // so we use the one found during cataloguing if possible String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType; @@ -420,7 +425,7 @@ class AvesEntry { _xmpSubjects = null; metadataChangeNotifier.notifyListeners(); - _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); } void clearMetadata() { @@ -428,17 +433,18 @@ class AvesEntry { addressDetails = null; } - Future catalog({bool background = false, bool persist = true, bool force = false}) async { + Future catalog({required bool background, required bool persist, required bool force}) async { if (isCatalogued && !force) return; if (isSvg) { // vector image sizing is not essential, so we should not spend time for it during loading // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing final size = await SvgMetadataService.getSize(this); if (size != null) { - await _applyNewFields({ + final fields = { 'width': size.width.ceil(), 'height': size.height.ceil(), - }, persist: persist); + }; + await _applyNewFields(fields, persist: persist); } catalogMetadata = CatalogMetadata(contentId: contentId); } else { @@ -462,17 +468,17 @@ class AvesEntry { addressChangeNotifier.notifyListeners(); } - Future locate({required bool background}) async { + Future locate({required bool background, required bool force, required Locale geocoderLocale}) async { if (!hasGps) return; - await _locateCountry(); + await _locateCountry(force: force); if (await availability.canLocatePlaces) { - await locatePlace(background: background); + await locatePlace(background: background, force: force, geocoderLocale: geocoderLocale); } } // quick reverse geocoding to find the country, using an offline asset - Future _locateCountry() async { - if (!hasGps || hasAddress) return; + Future _locateCountry({required bool force}) async { + if (!hasGps || (hasAddress && !force)) return; final countryCode = await countryTopology.countryCode(latLng!); setCountry(countryCode); } @@ -486,16 +492,9 @@ class AvesEntry { ); } - String? _geocoderLocale; - - String get geocoderLocale { - _geocoderLocale ??= (settings.locale ?? WidgetsBinding.instance!.window.locale).toString(); - return _geocoderLocale!; - } - // full reverse geocoding, requiring Play Services and some connectivity - Future locatePlace({required bool background}) async { - if (!hasGps || hasFineAddress) return; + Future locatePlace({required bool background, required bool force, required Locale geocoderLocale}) async { + if (!hasGps || (hasFineAddress && !force)) return; try { Future> call() => GeocodingService.getAddress(latLng!, geocoderLocale); final addresses = await (background @@ -524,7 +523,7 @@ class AvesEntry { } } - Future findAddressLine() async { + Future findAddressLine({required Locale geocoderLocale}) async { if (!hasGps) return null; try { @@ -558,6 +557,10 @@ class AvesEntry { }.any((s) => s != null && s.toUpperCase().contains(query)); Future _applyNewFields(Map newFields, {required bool persist}) async { + final oldDateModifiedSecs = this.dateModifiedSecs; + final oldRotationDegrees = this.rotationDegrees; + final oldIsFlipped = this.isFlipped; + final uri = newFields['uri']; if (uri is String) this.uri = uri; final path = newFields['path']; @@ -593,10 +596,11 @@ class AvesEntry { if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); } + await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); metadataChangeNotifier.notifyListeners(); } - Future refresh({required bool persist}) async { + Future refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async { _catalogMetadata = null; _addressDetails = null; _bestDate = null; @@ -609,8 +613,8 @@ class AvesEntry { final updated = await mediaFileService.getEntry(uri, mimeType); if (updated != null) { await _applyNewFields(updated.toMap(), persist: persist); - await catalog(background: false, persist: persist); - await locate(background: false); + await catalog(background: background, persist: persist, force: force); + await locate(background: background, force: force, geocoderLocale: geocoderLocale); } } @@ -618,11 +622,7 @@ class AvesEntry { final newFields = await metadataEditService.rotate(this, clockwise: clockwise); if (newFields.isEmpty) return false; - final oldDateModifiedSecs = dateModifiedSecs; - final oldRotationDegrees = rotationDegrees; - final oldIsFlipped = isFlipped; await _applyNewFields(newFields, persist: persist); - await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } @@ -630,11 +630,7 @@ class AvesEntry { final newFields = await metadataEditService.flip(this); if (newFields.isEmpty) return false; - final oldDateModifiedSecs = dateModifiedSecs; - final oldRotationDegrees = rotationDegrees; - final oldIsFlipped = isFlipped; await _applyNewFields(newFields, persist: persist); - await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } @@ -663,7 +659,7 @@ class AvesEntry { } // when the entry image itself changed (e.g. after rotation) - Future _onImageChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { + Future _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); imageChangeNotifier.notifyListeners(); diff --git a/lib/model/filters/coordinate.dart b/lib/model/filters/coordinate.dart new file mode 100644 index 000000000..fed0f5702 --- /dev/null +++ b/lib/model/filters/coordinate.dart @@ -0,0 +1,63 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/coordinate_format.dart'; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/geo_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +class CoordinateFilter extends CollectionFilter { + static const type = 'coordinate'; + + final LatLng sw; + final LatLng ne; + final bool minuteSecondPadding; + + @override + List get props => [sw, ne]; + + const CoordinateFilter(this.sw, this.ne, {this.minuteSecondPadding = false}); + + CoordinateFilter.fromMap(Map json) + : this( + LatLng.fromJson(json['sw']), + LatLng.fromJson(json['ne']), + ); + + @override + Map toMap() => { + 'type': type, + 'sw': sw.toJson(), + 'ne': ne.toJson(), + }; + + @override + EntryFilter get test => (entry) => GeoUtils.contains(sw, ne, entry.latLng); + + String _formatBounds(CoordinateFormat format) { + String s(LatLng latLng) => format.format( + latLng, + minuteSecondPadding: minuteSecondPadding, + dmsSecondDecimals: 0, + ); + return '${s(ne)}\n${s(sw)}'; + } + + @override + String get universalLabel => _formatBounds(CoordinateFormat.decimal); + + @override + String getLabel(BuildContext context) => _formatBounds(context.read().coordinateFormat); + + @override + Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.geoBounds, size: size); + + @override + String get category => type; + + @override + String get key => '$type-$sw-$ne'; +} diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 725085d3a..7fc08a231 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; @@ -24,6 +25,7 @@ abstract class CollectionFilter extends Equatable implements Comparable get props => [path]; - const PathFilter(this.path); + PathFilter(this.path) : _rootAlbum = path.substring(0, path.length - 1); PathFilter.fromMap(Map json) : this( @@ -22,7 +27,12 @@ class PathFilter extends CollectionFilter { }; @override - EntryFilter get test => (entry) => entry.directory?.startsWith(path) ?? false; + EntryFilter get test => (entry) { + final dir = entry.directory; + if (dir == null) return false; + // avoid string building in most cases + return dir.startsWith(_rootAlbum) && '$dir${pContext.separator}'.startsWith(path); + }; @override String get universalLabel => path; diff --git a/lib/model/filters/type.dart b/lib/model/filters/type.dart index ff7d79b9b..b445c3e7d 100644 --- a/lib/model/filters/type.dart +++ b/lib/model/filters/type.dart @@ -10,6 +10,7 @@ class TypeFilter extends CollectionFilter { static const _geotiff = 'geotiff'; // subset of `image/tiff` static const _motionPhoto = 'motion_photo'; // subset of `image/jpeg` static const _panorama = 'panorama'; // subset of images + static const _raw = 'raw'; // specific image formats static const _sphericalVideo = 'spherical_video'; // subset of videos final String itemType; @@ -20,6 +21,7 @@ class TypeFilter extends CollectionFilter { static final geotiff = TypeFilter._private(_geotiff); static final motionPhoto = TypeFilter._private(_motionPhoto); static final panorama = TypeFilter._private(_panorama); + static final raw = TypeFilter._private(_raw); static final sphericalVideo = TypeFilter._private(_sphericalVideo); @override @@ -43,6 +45,10 @@ class TypeFilter extends CollectionFilter { _test = (entry) => entry.isImage && entry.is360; _icon = AIcons.threeSixty; break; + case _raw: + _test = (entry) => entry.isRaw; + _icon = AIcons.raw; + break; case _sphericalVideo: _test = (entry) => entry.isVideo && entry.is360; _icon = AIcons.threeSixty; @@ -76,6 +82,8 @@ class TypeFilter extends CollectionFilter { return context.l10n.filterTypeMotionPhotoLabel; case _panorama: return context.l10n.filterTypePanoramaLabel; + case _raw: + return context.l10n.filterTypeRawLabel; case _sphericalVideo: return context.l10n.filterTypeSphericalVideoLabel; case _geotiff: diff --git a/lib/model/metadata/address.dart b/lib/model/metadata/address.dart index 86962a736..d7e5f232e 100644 --- a/lib/model/metadata/address.dart +++ b/lib/model/metadata/address.dart @@ -1,13 +1,17 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @immutable -class AddressDetails { +class AddressDetails extends Equatable { final int? contentId; final String? countryCode, countryName, adminArea, locality; String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea; + @override + List get props => [contentId, countryCode, countryName, adminArea, locality]; + const AddressDetails({ this.contentId, this.countryCode, @@ -45,7 +49,4 @@ class AddressDetails { 'adminArea': adminArea, 'locality': locality, }; - - @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 4fb1f07a8..51f95d755 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -98,7 +98,6 @@ class SqfliteMetadataDb implements MetadataDb { @override Future init() async { - debugPrint('$runtimeType init'); _database = openDatabase( await path, onCreate: (db, version) async { @@ -171,7 +170,6 @@ class SqfliteMetadataDb implements MetadataDb { Future removeIds(Set contentIds, {required bool metadataOnly}) async { if (contentIds.isEmpty) return; - // final stopwatch = Stopwatch()..start(); final db = await _database; // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead final batch = db.batch(); @@ -188,7 +186,6 @@ class SqfliteMetadataDb implements MetadataDb { } }); await batch.commit(noResult: true); - // debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries'); } // entries @@ -202,11 +199,9 @@ class SqfliteMetadataDb implements MetadataDb { @override Future> loadEntries() async { - // final stopwatch = Stopwatch()..start(); final db = await _database; final maps = await db.query(entryTable); final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet(); - // debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); return entries; } diff --git a/lib/model/settings/coordinate_format.dart b/lib/model/settings/coordinate_format.dart index 788b95197..b65619a06 100644 --- a/lib/model/settings/coordinate_format.dart +++ b/lib/model/settings/coordinate_format.dart @@ -1,4 +1,4 @@ -import 'package:aves/geo/format.dart'; +import 'package:aves/utils/geo_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; import 'package:latlong2/latlong.dart'; @@ -15,10 +15,10 @@ extension ExtraCoordinateFormat on CoordinateFormat { } } - String format(LatLng latLng) { + String format(LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) { switch (this) { case CoordinateFormat.dms: - return toDMS(latLng).join(', '); + return GeoUtils.toDMS(latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', '); case CoordinateFormat.decimal: return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', '); } diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 4aaa597e4..347d8fd5e 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart'; class SettingsDefaults { // app static const hasAcceptedTerms = false; + static const canUseAnalysisService = true; static const isErrorReportingEnabled = false; static const mustBackTwiceToExit = true; static const keepScreenOn = KeepScreenOn.viewerOnly; @@ -54,6 +55,7 @@ class SettingsDefaults { EntryAction.share, EntryAction.rotateScreen, ]; + static const showOverlayOnOpening = true; static const showOverlayMinimap = false; static const showOverlayInfo = true; static const showOverlayShootingDetails = false; @@ -81,6 +83,7 @@ class SettingsDefaults { static const infoMapStyle = EntryMapStyle.stamenWatercolor; // `infoMapStyle` has a contextual default value static const infoMapZoom = 12.0; static const coordinateFormat = CoordinateFormat.dms; + static const unitSystem = UnitSystem.metric; // rendering static const imageBackground = EntryBackground.white; diff --git a/lib/model/settings/enums.dart b/lib/model/settings/enums.dart index 1085d1107..f4678d208 100644 --- a/lib/model/settings/enums.dart +++ b/lib/model/settings/enums.dart @@ -13,4 +13,6 @@ enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenTo enum KeepScreenOn { never, viewerOnly, always } +enum UnitSystem { metric, imperial } + enum VideoLoopMode { never, shortOnly, always } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index a5b72a7a6..cccc52f81 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -15,6 +15,7 @@ import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; final Settings settings = Settings._private(); @@ -27,9 +28,7 @@ class Settings extends ChangeNotifier { static SharedPreferences? _prefs; - Settings._private() { - _platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?)); - } + Settings._private(); static const Set internalKeys = { hasAcceptedTermsKey, @@ -41,6 +40,7 @@ class Settings extends ChangeNotifier { // app static const hasAcceptedTermsKey = 'has_accepted_terms'; + static const canUseAnalysisServiceKey = 'can_use_analysis_service'; static const isErrorReportingEnabledKey = 'is_crashlytics_enabled'; static const localeKey = 'locale'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; @@ -73,6 +73,7 @@ class Settings extends ChangeNotifier { // viewer static const viewerQuickActionsKey = 'viewer_quick_actions'; + static const showOverlayOnOpeningKey = 'show_overlay_on_opening'; static const showOverlayMinimapKey = 'show_overlay_minimap'; static const showOverlayInfoKey = 'show_overlay_info'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; @@ -97,6 +98,7 @@ class Settings extends ChangeNotifier { static const infoMapStyleKey = 'info_map_style'; static const infoMapZoomKey = 'info_map_zoom'; static const coordinateFormatKey = 'coordinates_format'; + static const unitSystemKey = 'unit_system'; // rendering static const imageBackgroundKey = 'image_background'; @@ -122,12 +124,16 @@ class Settings extends ChangeNotifier { bool get initialized => _prefs != null; Future init({ + required bool monitorPlatformSettings, bool isRotationLocked = false, bool areAnimationsRemoved = false, }) async { _prefs = await SharedPreferences.getInstance(); _isRotationLocked = isRotationLocked; _areAnimationsRemoved = areAnimationsRemoved; + if (monitorPlatformSettings) { + _platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?)); + } } Future reset({required bool includeInternalKeys}) async { @@ -141,7 +147,7 @@ class Settings extends ChangeNotifier { Future setContextualDefaults() async { // performance final performanceClass = await deviceService.getPerformanceClass(); - enableOverlayBlurEffect = performanceClass >= 30; + enableOverlayBlurEffect = performanceClass >= 29; // availability final hasPlayServices = await availability.hasPlayServices; @@ -163,6 +169,10 @@ class Settings extends ChangeNotifier { set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue); + bool get canUseAnalysisService => getBoolOrDefault(canUseAnalysisServiceKey, SettingsDefaults.canUseAnalysisService); + + set canUseAnalysisService(bool newValue) => setAndNotify(canUseAnalysisServiceKey, newValue); + bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled); set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue); @@ -193,6 +203,17 @@ class Settings extends ChangeNotifier { ].join(localeSeparator); } setAndNotify(localeKey, tag); + _appliedLocale = null; + } + + Locale? _appliedLocale; + + Locale get appliedLocale { + if (_appliedLocale == null) { + final preferredLocale = locale; + _appliedLocale = basicLocaleListResolution(preferredLocale != null ? [preferredLocale] : null, AppLocalizations.supportedLocales); + } + return _appliedLocale!; } bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, SettingsDefaults.mustBackTwiceToExit); @@ -296,6 +317,10 @@ class Settings extends ChangeNotifier { set viewerQuickActions(List newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList()); + bool get showOverlayOnOpening => getBoolOrDefault(showOverlayOnOpeningKey, SettingsDefaults.showOverlayOnOpening); + + set showOverlayOnOpening(bool newValue) => setAndNotify(showOverlayOnOpeningKey, newValue); + bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, SettingsDefaults.showOverlayMinimap); set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue); @@ -374,6 +399,10 @@ class Settings extends ChangeNotifier { set coordinateFormat(CoordinateFormat newValue) => setAndNotify(coordinateFormatKey, newValue.toString()); + UnitSystem get unitSystem => getEnumOrDefault(unitSystemKey, SettingsDefaults.unitSystem, UnitSystem.values); + + set unitSystem(UnitSystem newValue) => setAndNotify(unitSystemKey, newValue.toString()); + // rendering EntryBackground get imageBackground => getEnumOrDefault(imageBackgroundKey, SettingsDefaults.imageBackground, EntryBackground.values); @@ -540,6 +569,7 @@ class Settings extends ChangeNotifier { case showThumbnailMotionPhotoKey: case showThumbnailRawKey: case showThumbnailVideoDurationKey: + case showOverlayOnOpeningKey: case showOverlayMinimapKey: case showOverlayInfoKey: case showOverlayShootingDetailsKey: @@ -568,6 +598,7 @@ class Settings extends ChangeNotifier { case subtitleTextAlignmentKey: case infoMapStyleKey: case coordinateFormatKey: + case unitSystemKey: case imageBackgroundKey: case accessibilityAnimationsKey: case timeToTakeActionKey: diff --git a/lib/model/settings/unit_system.dart b/lib/model/settings/unit_system.dart new file mode 100644 index 000000000..cde99328b --- /dev/null +++ b/lib/model/settings/unit_system.dart @@ -0,0 +1,15 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +import 'enums.dart'; + +extension ExtraUnitSystem on UnitSystem { + String getName(BuildContext context) { + switch (this) { + case UnitSystem.metric: + return context.l10n.unitSystemMetric; + case UnitSystem.imperial: + return context.l10n.unitSystemImperial; + } + } +} diff --git a/lib/model/source/analysis_controller.dart b/lib/model/source/analysis_controller.dart new file mode 100644 index 000000000..aeac9dd31 --- /dev/null +++ b/lib/model/source/analysis_controller.dart @@ -0,0 +1,16 @@ +import 'package:flutter/foundation.dart'; + +class AnalysisController { + final bool canStartService, force; + final List? contentIds; + final ValueNotifier stopSignal; + + AnalysisController({ + this.canStartService = true, + this.contentIds, + this.force = false, + ValueNotifier? stopSignal, + }) : stopSignal = stopSignal ?? ValueNotifier(false); + + bool get isStopping => stopSignal.value; +} diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 8936faa5e..76c6026fe 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -46,8 +46,8 @@ class CollectionLens with ChangeNotifier { id ??= hashCode; if (listenToSource) { final sourceEvents = source.eventBus; - _subscriptions.add(sourceEvents.on().listen((e) => onEntryAdded(e.entries))); - _subscriptions.add(sourceEvents.on().listen((e) => onEntryRemoved(e.entries))); + _subscriptions.add(sourceEvents.on().listen((e) => _onEntryAdded(e.entries))); + _subscriptions.add(sourceEvents.on().listen((e) => _onEntryRemoved(e.entries))); _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); @@ -73,6 +73,20 @@ class CollectionLens with ChangeNotifier { super.dispose(); } + CollectionLens copyWith({ + CollectionSource? source, + Set? filters, + bool? listenToSource, + List? fixedSelection, + }) => + CollectionLens( + source: source ?? this.source, + filters: filters ?? this.filters, + id: id, + listenToSource: listenToSource ?? this.listenToSource, + fixedSelection: fixedSelection ?? this.fixedSelection, + ); + bool get isEmpty => _filteredSortedEntries.isEmpty; int get entryCount => _filteredSortedEntries.length; @@ -103,16 +117,16 @@ class CollectionLens with ChangeNotifier { filters.removeWhere((old) => old.category == filter.category); } filters.add(filter); - onFilterChanged(); + _onFilterChanged(); } void removeFilter(CollectionFilter filter) { if (!filters.contains(filter)) return; filters.remove(filter); - onFilterChanged(); + _onFilterChanged(); } - void onFilterChanged() { + void _onFilterChanged() { _refresh(); filterChangeNotifier.notifyListeners(); } @@ -229,11 +243,11 @@ class CollectionLens with ChangeNotifier { } } - void onEntryAdded(Set? entries) { + void _onEntryAdded(Set? entries) { _refresh(); } - void onEntryRemoved(Set entries) { + void _onEntryRemoved(Set entries) { if (groupBursts) { // find impacted burst groups final obsoleteBurstEntries = {}; @@ -256,6 +270,7 @@ class CollectionLens with ChangeNotifier { // we should remove obsolete entries and sections // but do not apply sort/section // as section order change would surprise the user while browsing + fixedSelection?.removeWhere(entries.contains); _filteredSortedEntries.removeWhere(entries.contains); _sortedEntries?.removeWhere(entries.contains); sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains)); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index a9d34a885..5e026c490 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -9,9 +9,11 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; +import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; @@ -29,11 +31,9 @@ mixin SourceBase { ValueNotifier stateNotifier = ValueNotifier(SourceState.ready); - final StreamController _progressStreamController = StreamController.broadcast(); + ValueNotifier progressNotifier = ValueNotifier(const ProgressEvent(done: 0, total: 0)); - Stream get progressStream => _progressStreamController.stream; - - void setProgress({required int done, required int total}) => _progressStreamController.add(ProgressEvent(done: done, total: total)); + void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total); } abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { @@ -70,9 +70,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM late Map _savedDates; Future loadDates() async { - final stopwatch = Stopwatch()..start(); _savedDates = Map.unmodifiable(await metadataDb.loadDates()); - debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries'); } Iterable _applyHiddenFilters(Iterable entries) { @@ -88,6 +86,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM invalidateTagFilterSummary(entries); } + void updateDerivedFilters([Set? entries]) { + _invalidate(entries); + // it is possible for entries hidden by a filter type, to have an impact on other types + // e.g. given a sole entry for country C and tag T, hiding T should make C disappear too + updateDirectories(); + updateLocations(); + updateTags(); + } + void addEntries(Set entries) { if (entries.isEmpty) return; @@ -115,11 +122,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM entries.forEach((v) => _entryById.remove(v.contentId)); _rawEntries.removeAll(entries); - _invalidate(entries); - - cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet()); - updateLocations(); - updateTags(); + updateDerivedFilters(entries); eventBus.fire(EntryRemovedEvent(entries)); } @@ -159,13 +162,34 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future renameEntry(AvesEntry entry, String newName, {required bool persist}) async { if (newName == entry.filenameWithoutExtension) return true; - final newFields = await mediaFileService.rename(entry, '$newName${entry.extension}'); - if (newFields.isEmpty) return false; - await _moveEntry(entry, newFields, persist: persist); - entry.metadataChangeNotifier.notifyListeners(); - eventBus.fire(EntryMovedEvent({entry})); - return true; + pauseMonitoring(); + final completer = Completer(); + final processed = {}; + mediaFileService.rename({entry}, newName: '$newName${entry.extension}').listen( + processed.add, + onError: (error) => reportService.recordError('renameEntry failed with error=$error', null), + onDone: () async { + final successOps = processed.where((e) => e.success).toSet(); + if (successOps.isEmpty) { + completer.complete(false); + return; + } + final newFields = successOps.first.newFields; + if (newFields.isEmpty) { + completer.complete(false); + return; + } + await _moveEntry(entry, newFields, persist: persist); + entry.metadataChangeNotifier.notifyListeners(); + eventBus.fire(EntryMovedEvent({entry})); + completer.complete(true); + }, + ); + + final success = await completer.future; + resumeMonitoring(); + return success; } Future renameAlbum(String sourceAlbum, String destinationAlbum, Set todoEntries, Set movedOps) async { @@ -215,6 +239,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM uri: newFields['uri'] as String?, path: newFields['path'] as String?, contentId: newFields['contentId'] as int?, + // title can change when moved files are automatically renamed to avoid conflict + title: newFields['title'] as String?, dateModifiedSecs: newFields['dateModifiedSecs'] as int?, )); } @@ -252,18 +278,52 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future init(); - Future refresh(); + Future refresh({AnalysisController? analysisController}); - Future rescan(Set entries); + Future> refreshUris(Set changedUris, {AnalysisController? analysisController}); - Future refreshMetadata(Set entries) async { - await Future.forEach(entries, (entry) => entry.refresh(persist: true)); + Future refreshEntry(AvesEntry entry) async { + await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale); + updateDerivedFilters({entry}); + eventBus.fire(EntryRefreshedEvent({entry})); + } - _invalidate(entries); - updateLocations(); - updateTags(); - - eventBus.fire(EntryRefreshedEvent(entries)); + Future analyze(AnalysisController? analysisController, {Set? entries}) async { + final todoEntries = entries ?? visibleEntries; + final _analysisController = analysisController ?? AnalysisController(); + final force = _analysisController.force; + if (!_analysisController.isStopping) { + var startAnalysisService = false; + if (_analysisController.canStartService && settings.canUseAnalysisService) { + // cataloguing + if (!startAnalysisService) { + final opCount = (force ? todoEntries : todoEntries.where(TagMixin.catalogEntriesTest)).length; + if (opCount > TagMixin.commitCountThreshold) { + startAnalysisService = true; + } + } + // ignore locating countries + // locating places + if (!startAnalysisService && await availability.canLocatePlaces) { + final opCount = (force ? todoEntries.where((entry) => entry.hasGps) : todoEntries.where(LocationMixin.locatePlacesTest)).length; + if (opCount > LocationMixin.commitCountThreshold) { + startAnalysisService = true; + } + } + } + if (startAnalysisService) { + await AnalysisService.startService( + force: force, + contentIds: entries?.map((entry) => entry.contentId).whereNotNull().toList(), + ); + } else { + await catalogEntries(_analysisController, todoEntries); + updateDerivedFilters(todoEntries); + await locateEntries(_analysisController, todoEntries); + updateDerivedFilters(todoEntries); + } + } + stateNotifier.value = SourceState.ready; } // monitoring @@ -310,46 +370,45 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM settings.searchHistory = settings.searchHistory..removeWhere(filters.contains); } settings.hiddenFilters = hiddenFilters; - - _invalidate(); - // it is possible for entries hidden by a filter type, to have an impact on other types - // e.g. given a sole entry for country C and tag T, hiding T should make C disappear too - updateDirectories(); - updateLocations(); - updateTags(); - + updateDerivedFilters(); eventBus.fire(FilterVisibilityChangedEvent(filters, visible)); if (visible) { - refreshMetadata(visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet()); + final candidateEntries = visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet(); + analyze(null, entries: candidateEntries); } } } +@immutable class EntryAddedEvent { final Set? entries; const EntryAddedEvent([this.entries]); } +@immutable class EntryRemovedEvent { final Set entries; const EntryRemovedEvent(this.entries); } +@immutable class EntryMovedEvent { final Set entries; const EntryMovedEvent(this.entries); } +@immutable class EntryRefreshedEvent { final Set entries; const EntryRefreshedEvent(this.entries); } +@immutable class FilterVisibilityChangedEvent { final Set filters; final bool visible; @@ -357,6 +416,7 @@ class FilterVisibilityChangedEvent { const FilterVisibilityChangedEvent(this.filters, this.visible); } +@immutable class ProgressEvent { final int done, total; diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index 228310df4..451e27fef 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -1,4 +1,4 @@ -enum SourceState { loading, cataloguing, locating, ready } +enum SourceState { loading, cataloguing, locatingCountries, locatingPlaces, ready } enum ChipSortFactor { date, name, count } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index e1683125e..764025ce8 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -4,6 +4,8 @@ import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/metadata/address.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/services.dart'; @@ -12,38 +14,43 @@ import 'package:flutter/foundation.dart'; import 'package:tuple/tuple.dart'; mixin LocationMixin on SourceBase { - static const _commitCountThreshold = 50; + static const commitCountThreshold = 200; + static const _stopCheckCountThreshold = 50; List sortedCountries = List.unmodifiable([]); List sortedPlaces = List.unmodifiable([]); Future loadAddresses() async { - // final stopwatch = Stopwatch()..start(); final saved = await metadataDb.loadAddresses(); final idMap = entryById; saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata); - // debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries'); onAddressMetadataChanged(); } - Future locateEntries() async { - await _locateCountries(); - await _locatePlaces(); + Future locateEntries(AnalysisController controller, Set candidateEntries) async { + await _locateCountries(controller, candidateEntries); + await _locatePlaces(controller, candidateEntries); } + static bool locateCountriesTest(AvesEntry entry) => entry.hasGps && !entry.hasAddress; + + static bool locatePlacesTest(AvesEntry entry) => entry.hasGps && !entry.hasFineAddress; + // quick reverse geocoding to find the countries, using an offline asset - Future _locateCountries() async { - final todo = visibleEntries.where((entry) => entry.hasGps && !entry.hasAddress).toSet(); + Future _locateCountries(AnalysisController controller, Set candidateEntries) async { + if (controller.isStopping) return; + + final force = controller.force; + final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locateCountriesTest)).toSet(); if (todo.isEmpty) return; - stateNotifier.value = SourceState.locating; + stateNotifier.value = SourceState.locatingCountries; var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); - // final stopwatch = Stopwatch()..start(); final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet()); - final newAddresses = []; + final newAddresses = {}; todo.forEach((entry) { final position = entry.latLng; final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key; @@ -54,19 +61,18 @@ mixin LocationMixin on SourceBase { setProgress(done: ++progressDone, total: progressTotal); }); if (newAddresses.isNotEmpty) { - await metadataDb.saveAddresses(Set.of(newAddresses)); + await metadataDb.saveAddresses(Set.unmodifiable(newAddresses)); onAddressMetadataChanged(); } - // debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms'); } // full reverse geocoding, requiring Play Services and some connectivity - Future _locatePlaces() async { + Future _locatePlaces(AnalysisController controller, Set candidateEntries) async { + if (controller.isStopping) return; if (!(await availability.canLocatePlaces)) return; - // final stopwatch = Stopwatch()..start(); - final byLocated = groupBy(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.hasFineAddress); - final todo = byLocated[false] ?? []; + final force = controller.force; + final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locatePlacesTest)).toSet(); if (todo.isEmpty) return; // geocoder calls take between 150ms and 250ms @@ -81,47 +87,53 @@ mixin LocationMixin on SourceBase { final latLngFactor = pow(10, 2); Tuple2 approximateLatLng(AvesEntry entry) { // entry has coordinates - final lat = entry.catalogMetadata!.latitude!; - final lng = entry.catalogMetadata!.longitude!; + final catalogMetadata = entry.catalogMetadata!; + final lat = catalogMetadata.latitude!; + final lng = catalogMetadata.longitude!; return Tuple2((lat * latLngFactor).round(), (lng * latLngFactor).round()); } + final located = visibleEntries.where((entry) => entry.hasGps).toSet().difference(todo); final knownLocations = , AddressDetails?>{}; - byLocated[true]?.forEach((entry) { + located.forEach((entry) { knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails); }); - stateNotifier.value = SourceState.locating; + stateNotifier.value = SourceState.locatingPlaces; var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); - final newAddresses = []; - await Future.forEach(todo, (entry) async { + var stopCheckCount = 0; + final newAddresses = {}; + for (final entry in todo) { final latLng = approximateLatLng(entry); if (knownLocations.containsKey(latLng)) { entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); } else { - await entry.locatePlace(background: true); + await entry.locatePlace(background: true, force: force, geocoderLocale: settings.appliedLocale); // it is intended to insert `null` if the geocoder failed, // so that we skip geocoding of following entries with the same coordinates knownLocations[latLng] = entry.addressDetails; } if (entry.hasFineAddress) { newAddresses.add(entry.addressDetails!); - if (newAddresses.length >= _commitCountThreshold) { - await metadataDb.saveAddresses(Set.of(newAddresses)); + if (newAddresses.length >= commitCountThreshold) { + await metadataDb.saveAddresses(Set.unmodifiable(newAddresses)); onAddressMetadataChanged(); newAddresses.clear(); } + if (++stopCheckCount >= _stopCheckCountThreshold) { + stopCheckCount = 0; + if (controller.isStopping) return; + } } setProgress(done: ++progressDone, total: progressTotal); - }); + } if (newAddresses.isNotEmpty) { - await metadataDb.saveAddresses(Set.of(newAddresses)); + await metadataDb.saveAddresses(Set.unmodifiable(newAddresses)); onAddressMetadataChanged(); } - // debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s'); } void onAddressMetadataChanged() { @@ -142,9 +154,15 @@ mixin LocationMixin on SourceBase { // so we merge countries by code, keeping only one name for each code final countriesByCode = Map.fromEntries(locations.map((address) { final code = address.countryCode; - return code?.isNotEmpty == true ? MapEntry(code, address.countryName) : null; + if (code == null || code.isEmpty) return null; + return MapEntry(code, address.countryName); }).whereNotNull()); - final updatedCountries = countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase); + final updatedCountries = countriesByCode.entries.map((kv) { + final code = kv.key; + final name = kv.value; + return '${name != null && name.isNotEmpty ? name : code}${LocationFilter.locationSeparator}$code'; + }).toList() + ..sort(compareAsciiUpperCase); if (!listEquals(updatedCountries, sortedCountries)) { sortedCountries = List.unmodifiable(updatedCountries); invalidateCountryFilterSummary(); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 13e1d3955..124de29ad 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -5,6 +5,7 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/services.dart'; @@ -38,11 +39,11 @@ class MediaStoreSource extends CollectionSource { } await loadDates(); _initialized = true; - debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}'); + debugPrint('$runtimeType init complete in ${stopwatch.elapsed.inMilliseconds}ms'); } @override - Future refresh() async { + Future refresh({AnalysisController? analysisController}) async { assert(_initialized); debugPrint('$runtimeType refresh start'); final stopwatch = Stopwatch()..start(); @@ -59,10 +60,10 @@ class MediaStoreSource extends CollectionSource { // show known entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries'); addEntries(oldEntries); - debugPrint('$runtimeType refresh ${stopwatch.elapsed} load catalog metadata'); + debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata'); await loadCatalogMetadata(); - debugPrint('$runtimeType refresh ${stopwatch.elapsed} load address metadata'); await loadAddresses(); + updateDerivedFilters(); // clean up obsolete entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); @@ -110,11 +111,12 @@ class MediaStoreSource extends CollectionSource { updateDirectories(); } - debugPrint('$runtimeType refresh ${stopwatch.elapsed} catalog entries'); - await catalogEntries(); - debugPrint('$runtimeType refresh ${stopwatch.elapsed} locate entries'); - await locateEntries(); - stateNotifier.value = SourceState.ready; + Set? analysisEntries; + final analysisIds = analysisController?.contentIds; + if (analysisIds != null) { + analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.contentId)).toSet(); + } + await analyze(analysisController, entries: analysisEntries); debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete'); }, @@ -127,7 +129,8 @@ class MediaStoreSource extends CollectionSource { // 2) registered in the Media Store but still being processed by their owner in a temporary location // For example, when taking a picture with a Galaxy S10e default camera app, querying the Media Store // sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg` - Future> refreshUris(Set changedUris) async { + @override + Future> refreshUris(Set changedUris, {AnalysisController? analysisController}) async { if (!_initialized || !isMonitoring) return changedUris; debugPrint('$runtimeType refreshUris ${changedUris.length} uris'); @@ -180,18 +183,10 @@ class MediaStoreSource extends CollectionSource { addEntries(newEntries); await metadataDb.saveEntries(newEntries); cleanEmptyAlbums(existingDirectories); - await catalogEntries(); - await locateEntries(); - stateNotifier.value = SourceState.ready; + + await analyze(analysisController, entries: newEntries); } return tempUris; } - - @override - Future rescan(Set entries) async { - final contentIds = entries.map((entry) => entry.contentId).whereNotNull().toSet(); - await metadataDb.removeIds(contentIds, metadataOnly: true); - return refresh(); - } } diff --git a/lib/model/source/source_state.dart b/lib/model/source/source_state.dart new file mode 100644 index 000000000..ef18cbfd1 --- /dev/null +++ b/lib/model/source/source_state.dart @@ -0,0 +1,19 @@ +import 'package:aves/model/source/enums.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +extension ExtraSourceState on SourceState { + String? getName(AppLocalizations l10n) { + switch (this) { + case SourceState.loading: + return l10n.sourceStateLoading; + case SourceState.cataloguing: + return l10n.sourceStateCataloguing; + case SourceState.locatingCountries: + return l10n.sourceStateLocatingCountries; + case SourceState.locatingPlaces: + return l10n.sourceStateLocatingPlaces; + case SourceState.ready: + return null; + } + } +} diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 661c37483..8f9922daa 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/services.dart'; @@ -8,22 +9,25 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; mixin TagMixin on SourceBase { - static const _commitCountThreshold = 300; + static const commitCountThreshold = 400; + static const _stopCheckCountThreshold = 100; List sortedTags = List.unmodifiable([]); Future loadCatalogMetadata() async { - // final stopwatch = Stopwatch()..start(); final saved = await metadataDb.loadMetadataEntries(); final idMap = entryById; saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata); - // debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries'); onCatalogMetadataChanged(); } - Future catalogEntries() async { -// final stopwatch = Stopwatch()..start(); - final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList(); + static bool catalogEntriesTest(AvesEntry entry) => !entry.isCatalogued; + + Future catalogEntries(AnalysisController controller, Set candidateEntries) async { + if (controller.isStopping) return; + + final force = controller.force; + final todo = force ? candidateEntries : candidateEntries.where(catalogEntriesTest).toSet(); if (todo.isEmpty) return; stateNotifier.value = SourceState.cataloguing; @@ -31,22 +35,26 @@ mixin TagMixin on SourceBase { final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); - final newMetadata = []; - await Future.forEach(todo, (entry) async { - await entry.catalog(background: true); + var stopCheckCount = 0; + final newMetadata = {}; + for (final entry in todo) { + await entry.catalog(background: true, persist: true, force: force); if (entry.isCatalogued) { newMetadata.add(entry.catalogMetadata!); - if (newMetadata.length >= _commitCountThreshold) { - await metadataDb.saveMetadata(Set.of(newMetadata)); + if (newMetadata.length >= commitCountThreshold) { + await metadataDb.saveMetadata(Set.unmodifiable(newMetadata)); onCatalogMetadataChanged(); newMetadata.clear(); } + if (++stopCheckCount >= _stopCheckCountThreshold) { + stopCheckCount = 0; + if (controller.isStopping) return; + } } setProgress(done: ++progressDone, total: progressTotal); - }); - await metadataDb.saveMetadata(Set.of(newMetadata)); + } + await metadataDb.saveMetadata(Set.unmodifiable(newMetadata)); onCatalogMetadataChanged(); -// debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s'); } void onCatalogMetadataChanged() { diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index b19e333b3..b81428f18 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -46,7 +46,7 @@ class MimeTypes { static const mov = 'video/quicktime'; static const mp2t = 'video/mp2t'; // .m2ts static const mp4 = 'video/mp4'; - static const ogg = 'video/ogg'; + static const ogv = 'video/ogg'; static const webm = 'video/webm'; static const json = 'application/json'; @@ -67,7 +67,7 @@ class MimeTypes { static const Set _knownOpaqueImages = {heic, heif, jpeg}; - static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogg, webm}; + static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogv, webm}; static final Set knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos}; diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index 274f5b838..161d3e418 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -13,25 +13,36 @@ class XMP { 'crs': 'Camera Raw Settings', 'dc': 'Dublin Core', 'drone-dji': 'DJI Drone', + 'exif': 'Exif', 'exifEX': 'Exif Ex', 'GettyImagesGIFT': 'Getty Images', + 'GAudio': 'Google Audio', + 'GDepth': 'Google Depth', + 'GImage': 'Google Image', 'GIMP': 'GIMP', 'GCamera': 'Google Camera', 'GCreations': 'Google Creations', 'GFocus': 'Google Focus', 'GPano': 'Google Panorama', 'illustrator': 'Illustrator', + 'Iptc4xmpCore': 'IPTC Core', + 'Iptc4xmpExt': 'IPTC Extension', 'lr': 'Lightroom', 'MicrosoftPhoto': 'Microsoft Photo', + 'mwg-rs': 'Regions', 'panorama': 'Panorama', + 'PanoStudioXMP': 'PanoramaStudio', 'pdf': 'PDF', 'pdfx': 'PDF/X', - 'PanoStudioXMP': 'PanoramaStudio', 'photomechanic': 'Photo Mechanic', + 'photoshop': 'Photoshop', 'plus': 'PLUS', 'pmtm': 'Photomatix', + 'tiff': 'TIFF', + 'xmp': 'Basic', 'xmpBJ': 'Basic Job Ticket', 'xmpDM': 'Dynamic Media', + 'xmpMM': 'Media Management', 'xmpRights': 'Rights Management', 'xmpTPg': 'Paged-Text', }; diff --git a/lib/services/analysis_service.dart b/lib/services/analysis_service.dart new file mode 100644 index 000000000..354068e6f --- /dev/null +++ b/lib/services/analysis_service.dart @@ -0,0 +1,189 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/analysis_controller.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/media_store_source.dart'; +import 'package:aves/model/source/source_state.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:fijkplayer/fijkplayer.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class AnalysisService { + static const platform = MethodChannel('deckers.thibault/aves/analysis'); + + static Future registerCallback() async { + try { + await platform.invokeMethod('registerCallback', { + 'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(), + }); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } + + static Future startService({required bool force, List? contentIds}) async { + try { + await platform.invokeMethod('startService', { + 'contentIds': contentIds, + 'force': force, + }); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } +} + +const _channel = MethodChannel('deckers.thibault/aves/analysis_service_background'); + +Future _init() async { + WidgetsFlutterBinding.ensureInitialized(); + initPlatformServices(); + await metadataDb.init(); + await settings.init(monitorPlatformSettings: false); + FijkLog.setLevel(FijkLogLevel.Warn); + await reportService.init(); + + final analyzer = Analyzer(); + _channel.setMethodCallHandler((call) { + switch (call.method) { + case 'start': + analyzer.start(call.arguments); + return Future.value(true); + case 'stop': + analyzer.stop(); + return Future.value(true); + default: + throw PlatformException(code: 'not-implemented', message: 'failed to handle method=${call.method}'); + } + }); + try { + await _channel.invokeMethod('initialized'); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } +} + +enum AnalyzerState { running, stopping, stopped } + +class Analyzer { + late AppLocalizations _l10n; + final ValueNotifier _serviceStateNotifier = ValueNotifier(AnalyzerState.stopped); + AnalysisController? _controller; + Timer? _notificationUpdateTimer; + final _source = MediaStoreSource(); + + AnalyzerState get serviceState => _serviceStateNotifier.value; + + bool get isRunning => serviceState == AnalyzerState.running; + + SourceState get sourceState => _source.stateNotifier.value; + + static const notificationUpdateInterval = Duration(seconds: 1); + + Analyzer() { + debugPrint('$runtimeType create'); + _serviceStateNotifier.addListener(_onServiceStateChanged); + _source.stateNotifier.addListener(_onSourceStateChanged); + } + + void dispose() { + debugPrint('$runtimeType dispose'); + _serviceStateNotifier.removeListener(_onServiceStateChanged); + _source.stateNotifier.removeListener(_onSourceStateChanged); + _stopUpdateTimer(); + } + + Future start(dynamic args) async { + debugPrint('$runtimeType start'); + List? contentIds; + var force = false; + if (args is Map) { + contentIds = (args['contentIds'] as List?)?.cast(); + force = args['force'] ?? false; + } + _controller = AnalysisController( + canStartService: false, + contentIds: contentIds, + force: force, + stopSignal: ValueNotifier(false), + ); + + _l10n = await AppLocalizations.delegate.load(settings.appliedLocale); + _serviceStateNotifier.value = AnalyzerState.running; + await _source.init(); + unawaited(_source.refresh(analysisController: _controller)); + + _notificationUpdateTimer = Timer.periodic(notificationUpdateInterval, (_) async { + if (!isRunning) return; + await _updateNotification(); + }); + } + + void stop() { + debugPrint('$runtimeType stop'); + _serviceStateNotifier.value = AnalyzerState.stopped; + } + + void _stopUpdateTimer() => _notificationUpdateTimer?.cancel(); + + Future _onServiceStateChanged() async { + switch (serviceState) { + case AnalyzerState.running: + break; + case AnalyzerState.stopping: + await _stopPlatformService(); + _serviceStateNotifier.value = AnalyzerState.stopped; + break; + case AnalyzerState.stopped: + _controller?.stopSignal.value = true; + _stopUpdateTimer(); + break; + } + } + + void _onSourceStateChanged() { + if (sourceState == SourceState.ready) { + _refreshApp(); + _serviceStateNotifier.value = AnalyzerState.stopping; + } + } + + Future _updateNotification() async { + if (!isRunning) return; + + final title = sourceState.getName(_l10n); + if (title == null) return; + + final progress = _source.progressNotifier.value; + final progressive = progress.total != 0 && sourceState != SourceState.locatingCountries; + + try { + await _channel.invokeMethod('updateNotification', { + 'title': title, + 'message': progressive ? '${progress.done}/${progress.total}' : null, + }); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } + + Future _refreshApp() async { + try { + await _channel.invokeMethod('refreshApp'); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } + + Future _stopPlatformService() async { + try { + await _channel.invokeMethod('stop'); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } +} diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index a8883adf8..ef7defa72 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -10,10 +10,35 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; -class AndroidAppService { +abstract class AndroidAppService { + Future> getPackages(); + + Future getAppIcon(String packageName, double size); + + Future copyToClipboard(String uri, String? label); + + Future edit(String uri, String mimeType); + + Future open(String uri, String mimeType); + + Future openMap(LatLng latLng); + + Future setAs(String uri, String mimeType); + + Future shareEntries(Iterable entries); + + Future shareSingle(String uri, String mimeType); + + Future canPinToHomeScreen(); + + Future pinToHomeScreen(String label, AvesEntry? entry, Set filters); +} + +class PlatformAndroidAppService implements AndroidAppService { static const platform = MethodChannel('deckers.thibault/aves/app'); - static Future> getPackages() async { + @override + Future> getPackages() async { try { final result = await platform.invokeMethod('getPackages'); final packages = (result as List).cast().map((map) => Package.fromMap(map)).toSet(); @@ -29,7 +54,8 @@ class AndroidAppService { return {}; } - static Future getAppIcon(String packageName, double size) async { + @override + Future getAppIcon(String packageName, double size) async { try { final result = await platform.invokeMethod('getAppIcon', { 'packageName': packageName, @@ -42,7 +68,8 @@ class AndroidAppService { return Uint8List(0); } - static Future copyToClipboard(String uri, String? label) async { + @override + Future copyToClipboard(String uri, String? label) async { try { final result = await platform.invokeMethod('copyToClipboard', { 'uri': uri, @@ -55,7 +82,8 @@ class AndroidAppService { return false; } - static Future edit(String uri, String mimeType) async { + @override + Future edit(String uri, String mimeType) async { try { final result = await platform.invokeMethod('edit', { 'uri': uri, @@ -68,7 +96,8 @@ class AndroidAppService { return false; } - static Future open(String uri, String mimeType) async { + @override + Future open(String uri, String mimeType) async { try { final result = await platform.invokeMethod('open', { 'uri': uri, @@ -81,7 +110,8 @@ class AndroidAppService { return false; } - static Future openMap(LatLng latLng) async { + @override + Future openMap(LatLng latLng) async { final latitude = roundToPrecision(latLng.latitude, decimals: 6); final longitude = roundToPrecision(latLng.longitude, decimals: 6); final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude'; @@ -97,7 +127,8 @@ class AndroidAppService { return false; } - static Future setAs(String uri, String mimeType) async { + @override + Future setAs(String uri, String mimeType) async { try { final result = await platform.invokeMethod('setAs', { 'uri': uri, @@ -110,7 +141,8 @@ class AndroidAppService { return false; } - static Future shareEntries(Iterable entries) async { + @override + Future shareEntries(Iterable entries) async { // loosen mime type to a generic one, so we can share with badly defined apps // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats final urisByMimeType = groupBy(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); @@ -125,7 +157,8 @@ class AndroidAppService { return false; } - static Future shareSingle(String uri, String mimeType) async { + @override + Future shareSingle(String uri, String mimeType) async { try { final result = await platform.invokeMethod('share', { 'urisByMimeType': { @@ -142,9 +175,10 @@ class AndroidAppService { // app shortcuts // this ability will not change over the lifetime of the app - static bool? _canPin; + bool? _canPin; - static Future canPinToHomeScreen() async { + @override + Future canPinToHomeScreen() async { if (_canPin != null) return SynchronousFuture(_canPin!); try { @@ -159,7 +193,8 @@ class AndroidAppService { return false; } - static Future pinToHomeScreen(String label, AvesEntry? entry, Set filters) async { + @override + Future pinToHomeScreen(String label, AvesEntry? entry, Set filters) async { Uint8List? iconBytes; if (entry != null) { final size = entry.isVideo ? 0.0 : 256.0; diff --git a/lib/services/common/services.dart b/lib/services/common/services.dart index a97b32bf8..9963efef7 100644 --- a/lib/services/common/services.dart +++ b/lib/services/common/services.dart @@ -1,5 +1,6 @@ import 'package:aves/model/availability.dart'; import 'package:aves/model/metadata_db.dart'; +import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/device_service.dart'; import 'package:aves/services/media/embedded_data_service.dart'; import 'package:aves/services/media/media_file_service.dart'; @@ -18,6 +19,7 @@ final p.Context pContext = getIt(); final AvesAvailability availability = getIt(); final MetadataDb metadataDb = getIt(); +final AndroidAppService androidAppService = getIt(); final DeviceService deviceService = getIt(); final EmbeddedDataService embeddedDataService = getIt(); final MediaFileService mediaFileService = getIt(); @@ -33,6 +35,7 @@ void initPlatformServices() { getIt.registerLazySingleton(() => LiveAvesAvailability()); getIt.registerLazySingleton(() => SqfliteMetadataDb()); + getIt.registerLazySingleton(() => PlatformAndroidAppService()); getIt.registerLazySingleton(() => PlatformDeviceService()); getIt.registerLazySingleton(() => PlatformEmbeddedDataService()); getIt.registerLazySingleton(() => PlatformMediaFileService()); diff --git a/lib/services/geocoding_service.dart b/lib/services/geocoding_service.dart index aed70015a..d411dfea2 100644 --- a/lib/services/geocoding_service.dart +++ b/lib/services/geocoding_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui'; import 'package:aves/services/common/services.dart'; import 'package:flutter/foundation.dart'; @@ -9,19 +10,21 @@ class GeocodingService { static const platform = MethodChannel('deckers.thibault/aves/geocoding'); // geocoding requires Google Play Services - static Future> getAddress(LatLng coordinates, String locale) async { + static Future> getAddress(LatLng coordinates, Locale locale) async { try { final result = await platform.invokeMethod('getAddress', { 'latitude': coordinates.latitude, 'longitude': coordinates.longitude, - 'locale': locale, + 'locale': locale.toString(), // we only really need one address, but sometimes the native geocoder // returns nothing with `maxResults` of 1, but succeeds with `maxResults` of 2+ 'maxResults': 2, }); return (result as List).cast().map((map) => Address.fromMap(map)).toList(); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (e.code != 'getAddress-empty') { + await reportService.recordError(e, stack); + } } return []; } diff --git a/lib/services/media/enums.dart b/lib/services/media/enums.dart new file mode 100644 index 000000000..7ffed6cf4 --- /dev/null +++ b/lib/services/media/enums.dart @@ -0,0 +1,20 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +// names should match possible values on platform +enum NameConflictStrategy { rename, replace, skip } + +extension ExtraNameConflictStrategy on NameConflictStrategy { + String toPlatform() => toString().substring('NameConflictStrategy.'.length); + + String getName(BuildContext context) { + switch (this) { + case NameConflictStrategy.rename: + return context.l10n.nameConflictStrategyRename; + case NameConflictStrategy.replace: + return context.l10n.nameConflictStrategyReplace; + case NameConflictStrategy.skip: + return context.l10n.nameConflictStrategySkip; + } + } +} diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index 7301795f9..c2dc44a4d 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -9,6 +9,7 @@ import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/output_buffer.dart'; import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; @@ -73,12 +74,19 @@ abstract class MediaFileService { Iterable entries, { required bool copy, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }); Stream export( Iterable entries, { required String mimeType, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, + }); + + Stream rename( + Iterable entries, { + required String newName, }); Future> captureFrame( @@ -87,9 +95,8 @@ abstract class MediaFileService { required Map exif, required Uint8List bytes, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }); - - Future> rename(AvesEntry entry, String newName); } class PlatformMediaFileService implements MediaFileService { @@ -305,6 +312,7 @@ class PlatformMediaFileService implements MediaFileService { Iterable entries, { required bool copy, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }) { try { return _opStreamChannel.receiveBroadcastStream({ @@ -312,6 +320,7 @@ class PlatformMediaFileService implements MediaFileService { 'entries': entries.map(_toPlatformEntryMap).toList(), 'copy': copy, 'destinationPath': destinationAlbum, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), }).map((event) => MoveOpEvent.fromMap(event)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); @@ -324,6 +333,7 @@ class PlatformMediaFileService implements MediaFileService { Iterable entries, { required String mimeType, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }) { try { return _opStreamChannel.receiveBroadcastStream({ @@ -331,6 +341,7 @@ class PlatformMediaFileService implements MediaFileService { 'entries': entries.map(_toPlatformEntryMap).toList(), 'mimeType': mimeType, 'destinationPath': destinationAlbum, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), }).map((event) => ExportOpEvent.fromMap(event)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); @@ -338,6 +349,23 @@ class PlatformMediaFileService implements MediaFileService { } } + @override + Stream rename( + Iterable entries, { + required String newName, + }) { + try { + return _opStreamChannel.receiveBroadcastStream({ + 'op': 'rename', + 'entries': entries.map(_toPlatformEntryMap).toList(), + 'newName': newName, + }).map((event) => MoveOpEvent.fromMap(event)); + } on PlatformException catch (e, stack) { + reportService.recordError(e, stack); + return Stream.error(e); + } + } + @override Future> captureFrame( AvesEntry entry, { @@ -345,6 +373,7 @@ class PlatformMediaFileService implements MediaFileService { required Map exif, required Uint8List bytes, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }) async { try { final result = await platform.invokeMethod('captureFrame', { @@ -353,21 +382,7 @@ class PlatformMediaFileService implements MediaFileService { 'exif': exif, 'bytes': bytes, 'destinationPath': destinationAlbum, - }); - if (result != null) return (result as Map).cast(); - } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); - } - return {}; - } - - @override - Future> rename(AvesEntry entry, String newName) async { - try { - // returns map with: 'contentId' 'path' 'title' 'uri' (all optional) - final result = await platform.invokeMethod('rename', { - 'entry': _toPlatformEntryMap(entry), - 'newName': newName, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 3729b393a..7c487b978 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -40,7 +40,9 @@ class PlatformMetadataFetchService implements MetadataFetchService { }); if (result != null) return result as Map; } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return {}; } @@ -118,7 +120,9 @@ class PlatformMetadataFetchService implements MetadataFetchService { } return MultiPageInfo.fromPageMaps(entry, pageMaps); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return null; } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 529e7f62a..529623a39 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -23,8 +23,15 @@ abstract class StorageService { // returns number of deleted directories Future deleteEmptyDirectories(Iterable dirPaths); - // returns whether user granted access to volume root at `volumePath` - Future requestVolumeAccess(String volumePath); + // returns whether user granted access to a directory of his choosing + Future requestDirectoryAccess(String volumePath); + + Future canRequestMediaFileAccess(); + + Future canInsertMedia(Set directories); + + // returns whether user granted access to URIs + Future requestMediaFileAccess(List uris, List mimeTypes); // return whether operation succeeded (`null` if user cancelled) Future createFile(String name, String mimeType, Uint8List bytes); @@ -127,13 +134,37 @@ class PlatformStorageService implements StorageService { return 0; } - // returns whether user granted access to volume root at `volumePath` @override - Future requestVolumeAccess(String volumePath) async { + Future canRequestMediaFileAccess() async { + try { + final result = await platform.invokeMethod('canRequestMediaFileBulkAccess'); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + + @override + Future canInsertMedia(Set directories) async { + try { + final result = await platform.invokeMethod('canInsertMedia', { + 'directories': directories.map((v) => v.toMap()).toList(), + }); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + + // returns whether user granted access to a directory of his choosing + @override + Future requestDirectoryAccess(String volumePath) async { try { final completer = Completer(); storageAccessChannel.receiveBroadcastStream({ - 'op': 'requestVolumeAccess', + 'op': 'requestDirectoryAccess', 'path': volumePath, }).listen( (data) => completer.complete(data as bool), @@ -150,6 +181,30 @@ class PlatformStorageService implements StorageService { return false; } + // returns whether user granted access to URIs + @override + Future requestMediaFileAccess(List uris, List mimeTypes) async { + try { + final completer = Completer(); + storageAccessChannel.receiveBroadcastStream({ + 'op': 'requestMediaFileAccess', + 'uris': uris, + 'mimeTypes': mimeTypes, + }).listen( + (data) => completer.complete(data as bool), + onError: completer.completeError, + onDone: () { + if (!completer.isCompleted) completer.complete(false); + }, + cancelOnError: true, + ); + return completer.future; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + @override Future createFile(String name, String mimeType, Uint8List bytes) async { try { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 8817bcf65..7f3eb7349 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -46,6 +46,7 @@ class AIcons { static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; static const IconData favouriteActive = Icons.favorite; + static const IconData geoBounds = Icons.public_outlined; static const IconData goUp = Icons.arrow_upward_outlined; static const IconData group = Icons.group_work_outlined; static const IconData hide = Icons.visibility_off_outlined; diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 378b4fd22..88e80e926 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -1,6 +1,4 @@ -import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/services.dart'; -import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; @@ -16,7 +14,7 @@ class AndroidFileUtils { List _potentialAppDirs = []; bool _initialized = false; - AChangeNotifier appNameChangeNotifier = AChangeNotifier(); + ValueNotifier areAppNamesReadyNotifier = ValueNotifier(false); Iterable get _launcherPackages => _packages.where((package) => package.categoryLauncher); @@ -41,9 +39,9 @@ class AndroidFileUtils { Future initAppNames() async { if (_packages.isEmpty) { - _packages = await AndroidAppService.getPackages(); + _packages = await androidAppService.getPackages(); _potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList(); - appNameChangeNotifier.notifyListeners(); + areAppNamesReadyNotifier.value = true; } } @@ -182,6 +180,11 @@ class VolumeRelativeDirectory extends Equatable { ); } + Map toMap() => { + 'volumePath': volumePath, + 'relativeDir': relativeDir, + }; + // prefer static method over a null returning factory constructor static VolumeRelativeDirectory? fromPath(String dirPath) { final volume = androidFileUtils.getStorageVolume(dirPath); diff --git a/lib/utils/geo_utils.dart b/lib/utils/geo_utils.dart index 66376135c..64d8ed44d 100644 --- a/lib/utils/geo_utils.dart +++ b/lib/utils/geo_utils.dart @@ -1,27 +1,79 @@ import 'dart:math'; +import 'package:aves/utils/math_utils.dart'; +import 'package:intl/intl.dart'; import 'package:latlong2/latlong.dart'; -LatLng getLatLngCenter(List points) { - double x = 0; - double y = 0; - double z = 0; +class GeoUtils { + static String _decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) { + List _split(final double value) { + // NumberFormat is necessary to create digit after comma if the value + // has no decimal point (only necessary for browser) + final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.'); + return [ + int.parse(tmp[0]).abs(), + int.parse(tmp[1]), + ]; + } - points.forEach((point) { - final lat = point.latitudeInRad; - final lng = point.longitudeInRad; - x += cos(lat) * cos(lng); - y += cos(lat) * sin(lng); - z += sin(lat); - }); + final deg = _split(degDecimal)[0]; + final minDecimal = (degDecimal.abs() - deg) * 60; + final min = _split(minDecimal)[0]; + final sec = (minDecimal - min) * 60; - final pointCount = points.length; - x /= pointCount; - y /= pointCount; - z /= pointCount; + final secRounded = roundToPrecision(sec, decimals: secondDecimals); + var minText = '$min'; + var secText = secRounded.toStringAsFixed(secondDecimals); + if (minuteSecondPadding) { + minText = minText.padLeft(2, '0'); + secText = secText.padLeft(secondDecimals > 0 ? 3 + secondDecimals : 2, '0'); + } - final lng = atan2(y, x); - final hyp = sqrt(x * x + y * y); - final lat = atan2(z, hyp); - return LatLng(radianToDeg(lat), radianToDeg(lng)); + return '$deg° $minText′ $secText″'; + } + + // returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E'] + static List toDMS(LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) { + final lat = latLng.latitude; + final lng = latLng.longitude; + return [ + '${_decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals)} ${lat < 0 ? 'S' : 'N'}', + '${_decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals)} ${lng < 0 ? 'W' : 'E'}', + ]; + } + + static LatLng getLatLngCenter(List points) { + double x = 0; + double y = 0; + double z = 0; + + points.forEach((point) { + final lat = point.latitudeInRad; + final lng = point.longitudeInRad; + x += cos(lat) * cos(lng); + y += cos(lat) * sin(lng); + z += sin(lat); + }); + + final pointCount = points.length; + x /= pointCount; + y /= pointCount; + z /= pointCount; + + final lng = atan2(y, x); + final hyp = sqrt(x * x + y * y); + final lat = atan2(z, hyp); + return LatLng(radianToDeg(lat), radianToDeg(lng)); + } + + static bool contains(LatLng sw, LatLng ne, LatLng? point) { + if (point == null) return false; + final lat = point.latitude; + final lng = point.longitude; + final south = sw.latitude; + final north = ne.latitude; + final west = sw.longitude; + final east = ne.longitude; + return (south <= lat && lat <= north) && (west <= east ? (west <= lng && lng <= east) : (west <= lng || lng <= east)); + } } diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 5665df751..bd774b3c3 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -19,6 +19,7 @@ import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/welcome_page.dart'; import 'package:equatable/equatable.dart'; +import 'package:fijkplayer/fijkplayer.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -46,6 +47,7 @@ class _AvesAppState extends State { List _navigatorObservers = []; final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change'); final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent'); + final EventChannel _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events'); final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error'); final GlobalKey _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); @@ -58,6 +60,7 @@ class _AvesAppState extends State { _appSetup = _setup(); _mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?)); + _analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion()); _errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?)); } @@ -144,9 +147,11 @@ class _AvesAppState extends State { Future _setup() async { await settings.init( + monitorPlatformSettings: true, isRotationLocked: await windowService.isRotationLocked(), areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(), ); + FijkLog.setLevel(FijkLogLevel.Warn); // keep screen on settings.updateStream.where((key) => key == Settings.keepScreenOnKey).listen( @@ -192,6 +197,13 @@ class _AvesAppState extends State { )); } + Future _onAnalysisCompletion() async { + debugPrint('Analysis completed'); + await _mediaStoreSource.loadCatalogMetadata(); + await _mediaStoreSource.loadAddresses(); + _mediaStoreSource.updateDerivedFilters(); + } + void _onMediaStoreChange(String? uri) { if (uri != null) changedUris.add(uri); if (changedUris.isNotEmpty) { diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 93e51e1cb..52699fbd2 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -9,7 +9,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; -import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/filter_bar.dart'; @@ -61,7 +61,7 @@ class _CollectionAppBarState extends State with SingleTickerPr vsync: this, ); _isSelectingNotifier.addListener(_onActivityChange); - _canAddShortcutsLoader = AndroidAppService.canPinToHomeScreen(); + _canAddShortcutsLoader = androidAppService.canPinToHomeScreen(); _registerWidget(widget); WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged()); } @@ -200,17 +200,8 @@ class _CollectionAppBarState extends State with SingleTickerPr final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection); return [ - _toMenuItem( - EntrySetAction.sort, - // key is expected by test driver - key: const Key('menu-sort'), - ), - if (groupable) - _toMenuItem( - EntrySetAction.group, - // key is expected by test driver - key: const Key('menu-group'), - ), + _toMenuItem(EntrySetAction.sort), + if (groupable) _toMenuItem(EntrySetAction.group), if (appMode == AppMode.main) ...[ if (!isSelecting) _toMenuItem( @@ -254,9 +245,10 @@ class _CollectionAppBarState extends State with SingleTickerPr ]; } - PopupMenuItem _toMenuItem(EntrySetAction action, {Key? key, bool enabled = true}) { + PopupMenuItem _toMenuItem(EntrySetAction action, {bool enabled = true}) { return PopupMenuItem( - key: key, + // key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map') + key: Key('menu-${action.toString().substring('EntrySetAction.'.length)}'), value: action, enabled: enabled, child: MenuRow(text: action.getText(context), icon: action.getIcon()), @@ -356,7 +348,7 @@ class _CollectionAppBarState extends State with SingleTickerPr // we compute the default name beforehand // because some filter labels need localization final sortedFilters = List.from(filters)..sort(); - defaultName = sortedFilters.first.getLabel(context); + defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' '); } final result = await showDialog>( context: context, @@ -371,7 +363,7 @@ class _CollectionAppBarState extends State with SingleTickerPr final name = result.item2; if (name.isEmpty) return; - unawaited(AndroidAppService.pinToHomeScreen(name, coverEntry, filters)); + unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters)); } void _goToSearch() { diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index bb9ede8ab..a46b63497 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; @@ -6,19 +7,20 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/selection.dart'; +import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/stats/stats_page.dart'; @@ -63,7 +65,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware void _share(BuildContext context) { final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); - AndroidAppService.shareEntries(selectedItems).then((success) { + androidAppService.shareEntries(selectedItems).then((success) { if (!success) showNoMatchingAppDialog(context); }); } @@ -73,29 +75,18 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); - source.rescan(selectedItems); + final controller = AnalysisController(canStartService: true, force: true); + source.analyze(controller, entries: selectedItems); + selection.browse(); } Future _moveSelection(BuildContext context, {required MoveType moveType}) async { + final l10n = context.l10n; final source = context.read(); final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); - final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); - if (moveType == MoveType.move) { - // check whether moving is possible given OS restrictions, - // before asking to pick a destination album - final restrictedDirs = await storageService.getRestrictedDirectories(); - for (final selectionDir in selectionDirs) { - final dir = VolumeRelativeDirectory.fromPath(selectionDir); - if (dir == null) return; - if (restrictedDirs.contains(dir)) { - await showRestrictedDirectoryDialog(context, dir); - return; - } - } - } final destinationAlbum = await Navigator.push( context, @@ -107,7 +98,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; - if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs)) return; + if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return; @@ -119,13 +110,44 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final todoCount = todoEntries.length; assert(todoCount > 0); + final destinationDirectory = Directory(destinationAlbum); + final names = [ + ...todoEntries.map((v) => '${v.filenameWithoutExtension}${v.extension}'), + // do not guard up front based on directory existence, + // as conflicts could be within moved entries scattered across multiple albums + if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), + ]; + final uniqueNames = names.toSet(); + var nameConflictStrategy = NameConflictStrategy.rename; + if (uniqueNames.length < names.length) { + final value = await showDialog( + context: context, + builder: (context) { + return AvesSelectionDialog( + initialValue: nameConflictStrategy, + options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))), + message: selectionDirs.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage, + confirmationButtonLabel: l10n.continueButtonLabel, + ); + }, + ); + if (value == null) return; + nameConflictStrategy = value; + } + source.pauseMonitoring(); showOpReport( context: context, - opStream: mediaFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum), + opStream: mediaFileService.move( + todoEntries, + copy: copy, + destinationAlbum: destinationAlbum, + nameConflictStrategy: nameConflictStrategy, + ), itemCount: todoCount, onDone: (processed) async { - final movedOps = processed.where((e) => e.success).toSet(); + final successOps = processed.where((e) => e.success).toSet(); + final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet(); await source.updateAfterMove( todoEntries: todoEntries, copy: copy, @@ -140,50 +162,51 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware await storageService.deleteEmptyDirectories(selectionDirs); } - final l10n = context.l10n; - final movedCount = movedOps.length; - if (movedCount < todoCount) { - final count = todoCount - movedCount; + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); } else { - final count = movedCount; + final count = movedOps.length; showFeedback( context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count), - SnackBarAction( - label: context.l10n.showButtonLabel, - onPressed: () async { - final highlightInfo = context.read(); - final collection = context.read(); - var targetCollection = collection; - if (collection.filters.any((f) => f is AlbumFilter)) { - final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); - // we could simply add the filter to the current collection - // but navigating makes the change less jarring - targetCollection = CollectionLens( - source: collection.source, - filters: collection.filters, - )..addFilter(filter); - unawaited(Navigator.pushReplacement( - context, - MaterialPageRoute( - settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - collection: targetCollection, - ), - ), - )); - final delayDuration = context.read().staggeredAnimationPageTarget; - await Future.delayed(delayDuration); - } - await Future.delayed(Durations.highlightScrollInitDelay); - final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); - final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); - if (targetEntry != null) { - highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); - } - }, - ), + count > 0 + ? SnackBarAction( + label: l10n.showButtonLabel, + onPressed: () async { + final highlightInfo = context.read(); + final collection = context.read(); + var targetCollection = collection; + if (collection.filters.any((f) => f is AlbumFilter)) { + final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); + // we could simply add the filter to the current collection + // but navigating makes the change less jarring + targetCollection = CollectionLens( + source: collection.source, + filters: collection.filters, + )..addFilter(filter); + unawaited(Navigator.pushReplacement( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + collection: targetCollection, + ), + ), + )); + final delayDuration = context.read().staggeredAnimationPageTarget; + await Future.delayed(delayDuration); + } + await Future.delayed(Durations.highlightScrollInitDelay); + final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); + final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); + if (targetEntry != null) { + highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); + } + }, + ) + : null, ); } }, @@ -218,7 +241,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); if (confirmed == null || !confirmed) return; - if (!await checkStoragePermissionForAlbums(context, selectionDirs)) return; + if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; source.pauseMonitoring(); showOpReport( @@ -253,6 +276,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware MaterialPageRoute( settings: const RouteSettings(name: MapPage.routeName), builder: (context) => MapPage( + // need collection with fresh ID to prevent hero from scroller on Map page to Collection page collection: CollectionLens( source: collection.source, filters: collection.filters, diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index 97d4db0f7..0a8e5b142 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -73,10 +73,7 @@ class InteractiveThumbnail extends StatelessWidget { TransparentMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), pageBuilder: (context, a, sa) { - final viewerCollection = CollectionLens( - source: collection.source, - filters: collection.filters, - id: collection.id, + final viewerCollection = collection.copyWith( listenToSource: false, ); assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId)); diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index 22b856062..9b44db144 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -8,21 +8,41 @@ import 'package:flutter/material.dart'; mixin PermissionAwareMixin { Future checkStoragePermission(BuildContext context, Set entries) { - return checkStoragePermissionForAlbums(context, entries.map((e) => e.directory).whereNotNull().toSet()); + return checkStoragePermissionForAlbums(context, entries.map((e) => e.directory).whereNotNull().toSet(), entries: entries); } - Future checkStoragePermissionForAlbums(BuildContext context, Set albumPaths) async { + Future checkStoragePermissionForAlbums(BuildContext context, Set albumPaths, {Set? entries}) async { final restrictedDirs = await storageService.getRestrictedDirectories(); while (true) { final dirs = await storageService.getInaccessibleDirectories(albumPaths); - if (dirs.isEmpty) return true; - final restrictedInaccessibleDir = dirs.firstWhereOrNull(restrictedDirs.contains); - if (restrictedInaccessibleDir != null) { - await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir); - return false; + final restrictedInaccessibleDirs = dirs.where(restrictedDirs.contains).toSet(); + if (restrictedInaccessibleDirs.isNotEmpty) { + if (entries != null && await storageService.canRequestMediaFileAccess()) { + // request media file access for items in restricted directories + final uris = [], mimeTypes = []; + entries.where((entry) { + final dir = entry.directory; + return dir != null && restrictedInaccessibleDirs.contains(VolumeRelativeDirectory.fromPath(dir)); + }).forEach((entry) { + uris.add(entry.uri); + mimeTypes.add(entry.mimeType); + }); + final granted = await storageService.requestMediaFileAccess(uris, mimeTypes); + if (!granted) return false; + } else if (entries == null && await storageService.canInsertMedia(restrictedInaccessibleDirs)) { + // insertion in restricted directories + } else { + // cannot proceed further + await showRestrictedDirectoryDialog(context, restrictedInaccessibleDirs.first); + return false; + } + // clear restricted directories + dirs.removeAll(restrictedInaccessibleDirs); } + if (dirs.isEmpty) return true; + final dir = dirs.first; final confirmed = await showDialog( context: context, @@ -49,7 +69,7 @@ mixin PermissionAwareMixin { // abort if the user cancels in Flutter if (confirmed == null || !confirmed) return false; - final granted = await storageService.requestVolumeAccess(dir.volumePath); + final granted = await storageService.requestDirectoryAccess(dir.volumePath); if (!granted) { // abort if the user denies access from the native dialog return false; diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index d65dcdc4f..4362ee50d 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -1,5 +1,6 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/source_state.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; @@ -56,43 +57,29 @@ class SourceStateSubtitle extends StatelessWidget { @override Widget build(BuildContext context) { - String? subtitle; - switch (source.stateNotifier.value) { - case SourceState.loading: - subtitle = context.l10n.sourceStateLoading; - break; - case SourceState.cataloguing: - subtitle = context.l10n.sourceStateCataloguing; - break; - case SourceState.locating: - subtitle = context.l10n.sourceStateLocating; - break; - case SourceState.ready: - default: - break; - } - final subtitleStyle = Theme.of(context).textTheme.caption; - return subtitle == null - ? const SizedBox.shrink() - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(subtitle, style: subtitleStyle), - StreamBuilder( - stream: source.progressStream, - builder: (context, snapshot) { - if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink(); - final progress = snapshot.data!; - return Padding( - padding: const EdgeInsetsDirectional.only(start: 8), - child: Text( - '${progress.done}/${progress.total}', - style: subtitleStyle!.copyWith(color: Colors.white30), - ), - ); - }, + final sourceState = source.stateNotifier.value; + final subtitle = sourceState.getName(context.l10n); + if (subtitle == null) return const SizedBox(); + + final subtitleStyle = Theme.of(context).textTheme.caption!; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(subtitle, style: subtitleStyle), + ValueListenableBuilder( + valueListenable: source.progressNotifier, + builder: (context, progress, snapshot) { + if (progress.total == 0 || sourceState == SourceState.locatingCountries) return const SizedBox(); + return Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: Text( + '${progress.done}/${progress.total}', + style: subtitleStyle.copyWith(color: Colors.white30), ), - ], - ); + ); + }, + ), + ], + ); } } diff --git a/lib/widgets/common/basic/insets.dart b/lib/widgets/common/basic/insets.dart index 3e829eda0..2727565e6 100644 --- a/lib/widgets/common/basic/insets.dart +++ b/lib/widgets/common/basic/insets.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -13,17 +11,48 @@ class BottomGestureAreaProtector extends StatelessWidget { @override Widget build(BuildContext context) { - return Selector( - selector: (context, mq) => mq.systemGestureInsets.bottom, - builder: (context, systemGestureBottom, child) { - return Positioned( - left: 0, - right: 0, - bottom: 0, - height: systemGestureBottom, - child: const AbsorbPointer(), - ); - }, + return Positioned( + left: 0, + right: 0, + bottom: 0, + height: context.select((mq) => mq.systemGestureInsets.bottom), + child: GestureDetector( + // absorb vertical gestures only + onVerticalDragDown: (details) {}, + behavior: HitTestBehavior.translucent, + ), + ); + } +} + +// It will prevent the body from scrolling when a user swipe from edges to use Android Q style navigation gestures. +class SideGestureAreaProtector extends StatelessWidget { + const SideGestureAreaProtector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: Row( + children: [ + SizedBox( + width: context.select((mq) => mq.systemGestureInsets.left), + child: GestureDetector( + // absorb horizontal gestures only + onHorizontalDragDown: (details) {}, + behavior: HitTestBehavior.translucent, + ), + ), + const Spacer(), + SizedBox( + width: context.select((mq) => mq.systemGestureInsets.right), + child: GestureDetector( + // absorb horizontal gestures only + onHorizontalDragDown: (details) {}, + behavior: HitTestBehavior.translucent, + ), + ), + ], + ), ); } } @@ -54,7 +83,7 @@ class BottomPaddingSliver extends StatelessWidget { Widget build(BuildContext context) { return SliverToBoxAdapter( child: Selector( - selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), + selector: (context, mq) => mq.effectiveBottomPadding, builder: (context, mqPaddingBottom, child) { return SizedBox(height: mqPaddingBottom); }, diff --git a/lib/widgets/common/fx/transition_image.dart b/lib/widgets/common/fx/transition_image.dart index 01598fbe2..c530607db 100644 --- a/lib/widgets/common/fx/transition_image.dart +++ b/lib/widgets/common/fx/transition_image.dart @@ -13,6 +13,7 @@ class TransitionImage extends StatefulWidget { final double? width, height; final ValueListenable animation; final bool gaplessPlayback = false; + final Color? background; const TransitionImage({ Key? key, @@ -20,6 +21,7 @@ class TransitionImage extends StatefulWidget { required this.animation, this.width, this.height, + this.background, }) : super(key: key); @override @@ -136,10 +138,10 @@ class _TransitionImageState extends State { valueListenable: widget.animation, builder: (context, t, child) => CustomPaint( painter: _TransitionImagePainter( - // AssetImage(name).resolve(configuration) image: _imageInfo?.image, scale: _imageInfo?.scale ?? 1.0, t: t, + background: widget.background, ), ), ); @@ -150,11 +152,13 @@ class _TransitionImagePainter extends CustomPainter { final ui.Image? image; final double scale; final double t; + final Color? background; const _TransitionImagePainter({ required this.image, required this.scale, required this.t, + this.background, }); @override @@ -185,6 +189,9 @@ class _TransitionImagePainter extends CustomPainter { sourceSize, Offset.zero & inputSize, ); + if (background != null) { + canvas.drawRect(destinationRect, Paint()..color = background!); + } canvas.drawImageRect(image!, sourceRect, destinationRect, paint); } diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 859fac9c3..b774dc4c6 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -37,12 +37,11 @@ class AvesFilterDecoration { class AvesFilterChip extends StatefulWidget { final CollectionFilter filter; - final bool removable; - final bool showGenericIcon; + final bool removable, showGenericIcon, useFilterColor; final AvesFilterDecoration? decoration; final String? banner; final Widget? details; - final double padding; + final double padding, maxWidth; final HeroType heroType; final FilterCallback? onTap; final OffsetFilterCallback? onLongPress; @@ -52,7 +51,7 @@ class AvesFilterChip extends StatefulWidget { static const double outlineWidth = 2; static const double minChipHeight = kMinInteractiveDimension; static const double minChipWidth = 80; - static const double maxChipWidth = 160; + static const double defaultMaxChipWidth = 160; static const double iconSize = 18; static const double fontSize = 14; static const double decoratedContentVerticalPadding = 5; @@ -62,10 +61,12 @@ class AvesFilterChip extends StatefulWidget { required this.filter, this.removable = false, this.showGenericIcon = true, + this.useFilterColor = true, this.decoration, this.banner, this.details, this.padding = 6.0, + this.maxWidth = defaultMaxChipWidth, this.heroType = HeroType.onTap, this.onTap, this.onLongPress = showDefaultLongPressMenu, @@ -181,7 +182,6 @@ class _AvesFilterChipState extends State { ), softWrap: false, overflow: TextOverflow.fade, - maxLines: 1, ), ), if (trailing != null) ...[ @@ -216,7 +216,7 @@ class _AvesFilterChipState extends State { ); } else { content = Padding( - padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2), + padding: EdgeInsets.symmetric(horizontal: padding * 2), child: content, ); } @@ -224,9 +224,9 @@ class _AvesFilterChipState extends State { final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)); final banner = widget.banner; Widget chip = Container( - constraints: const BoxConstraints( + constraints: BoxConstraints( minWidth: AvesFilterChip.minChipWidth, - maxWidth: AvesFilterChip.maxChipWidth, + maxWidth: widget.maxWidth, minHeight: AvesFilterChip.minChipHeight, ), child: Stack( @@ -263,16 +263,13 @@ class _AvesFilterChipState extends State { return DecoratedBox( decoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( - color: _outlineColor, + color: widget.useFilterColor ? _outlineColor : AvesFilterChip.defaultOutlineColor, width: AvesFilterChip.outlineWidth, )), borderRadius: borderRadius, ), position: DecorationPosition.foreground, - child: Padding( - padding: EdgeInsets.symmetric(vertical: decoration != null ? 0 : 8), - child: content, - ), + child: content, ); }, ), diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index a4234f602..0745868a2 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -1,16 +1,20 @@ +import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/map/compass.dart'; import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -58,119 +62,126 @@ class MapButtonPanel extends StatelessWidget { break; } + final showCoordinateFilter = context.select((v) => v.showCoordinateFilter); final visualDensity = context.select((v) => v.visualDensity); final double padding = visualDensity == VisualDensity.compact ? 4 : 8; return Positioned.fill( - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Padding( - padding: EdgeInsets.all(padding), - child: TooltipTheme( - data: TooltipTheme.of(context).copyWith( - preferBelow: false, - ), - child: SafeArea( - bottom: false, - child: Stack( - children: [ - Positioned( - left: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (navigationButton != null) ...[ - navigationButton, - SizedBox(height: padding), - ], - ValueListenableBuilder( - valueListenable: boundsNotifier, - builder: (context, bounds, child) { - final degrees = bounds.rotation; - final opacity = degrees == 0 ? .0 : 1.0; - final animationDuration = context.select((v) => v.viewerOverlayAnimation); - return IgnorePointer( - ignoring: opacity == 0, - child: AnimatedOpacity( - opacity: opacity, - duration: animationDuration, - child: MapOverlayButton( - icon: Transform( - origin: iconSize.center(Offset.zero), - transform: Matrix4.rotationZ(degToRadian(degrees)), - child: CustomPaint( - painter: CompassPainter( - color: iconTheme.color!, + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: SafeArea( + bottom: false, + child: Stack( + children: [ + Positioned( + left: padding, + right: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(top: padding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (navigationButton != null) ...[ + navigationButton, + SizedBox(height: padding), + ], + ValueListenableBuilder( + valueListenable: boundsNotifier, + builder: (context, bounds, child) { + final degrees = bounds.rotation; + final opacity = degrees == 0 ? .0 : 1.0; + final animationDuration = context.select((v) => v.viewerOverlayAnimation); + return IgnorePointer( + ignoring: opacity == 0, + child: AnimatedOpacity( + opacity: opacity, + duration: animationDuration, + child: MapOverlayButton( + icon: Transform( + origin: iconSize.center(Offset.zero), + transform: Matrix4.rotationZ(degToRadian(degrees)), + child: CustomPaint( + painter: CompassPainter( + color: iconTheme.color!, + ), + size: iconSize, ), - size: iconSize, ), + onPressed: () => resetRotation?.call(), + tooltip: context.l10n.mapPointNorthUpTooltip, ), - onPressed: () => resetRotation?.call(), - tooltip: context.l10n.mapPointNorthUpTooltip, ), - ), - ); - }, - ), - ], + ); + }, + ), + ], + ), ), - ), - Positioned( - right: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - MapOverlayButton( - icon: const Icon(AIcons.layers), - onPressed: () async { - final hasPlayServices = await availability.hasPlayServices; - final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices); - final preferredStyle = settings.infoMapStyle; - final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first; - final style = await showDialog( - context: context, - builder: (context) { - return AvesSelectionDialog( - initialValue: initialStyle, - options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.mapStyleTitle, - ); - }, - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (style != null && style != settings.infoMapStyle) { - settings.infoMapStyle = style; - } - }, - tooltip: context.l10n.mapStyleTooltip, - ), - ], + showCoordinateFilter + ? Expanded( + child: _OverlayCoordinateFilterChip( + boundsNotifier: boundsNotifier, + padding: padding, + ), + ) + : const Spacer(), + Padding( + padding: EdgeInsets.only(top: padding), + child: MapOverlayButton( + icon: const Icon(AIcons.layers), + onPressed: () async { + final hasPlayServices = await availability.hasPlayServices; + final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices); + final preferredStyle = settings.infoMapStyle; + final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first; + final style = await showDialog( + context: context, + builder: (context) { + return AvesSelectionDialog( + initialValue: initialStyle, + options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))), + title: context.l10n.mapStyleTitle, + ); + }, + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + if (style != null && style != settings.infoMapStyle) { + settings.infoMapStyle = style; + } + }, + tooltip: context.l10n.mapStyleTooltip, + ), ), - ), - Positioned( - right: 0, - bottom: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - MapOverlayButton( - icon: const Icon(AIcons.zoomIn), - onPressed: zoomBy != null ? () => zoomBy?.call(1) : null, - tooltip: context.l10n.mapZoomInTooltip, - ), - SizedBox(height: padding), - MapOverlayButton( - icon: const Icon(AIcons.zoomOut), - onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null, - tooltip: context.l10n.mapZoomOutTooltip, - ), - ], - ), - ), - ], + ], + ), ), - ), + Positioned( + right: padding, + bottom: padding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MapOverlayButton( + icon: const Icon(AIcons.zoomIn), + onPressed: zoomBy != null ? () => zoomBy?.call(1) : null, + tooltip: context.l10n.mapZoomInTooltip, + ), + SizedBox(height: padding), + MapOverlayButton( + icon: const Icon(AIcons.zoomOut), + onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null, + tooltip: context.l10n.mapZoomOutTooltip, + ), + ], + ), + ), + ], ), ), ), @@ -225,3 +236,102 @@ class MapOverlayButton extends StatelessWidget { ); } } + +class _OverlayCoordinateFilterChip extends StatefulWidget { + final ValueNotifier boundsNotifier; + final double padding; + + const _OverlayCoordinateFilterChip({ + Key? key, + required this.boundsNotifier, + required this.padding, + }) : super(key: key); + + @override + _OverlayCoordinateFilterChipState createState() => _OverlayCoordinateFilterChipState(); +} + +class _OverlayCoordinateFilterChipState extends State<_OverlayCoordinateFilterChip> { + final Debouncer _debouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); + final ValueNotifier _idleBoundsNotifier = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant _OverlayCoordinateFilterChip oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(_OverlayCoordinateFilterChip widget) { + widget.boundsNotifier.addListener(_onBoundsChanged); + } + + void _unregisterWidget(_OverlayCoordinateFilterChip widget) { + widget.boundsNotifier.removeListener(_onBoundsChanged); + } + + @override + Widget build(BuildContext context) { + final blurred = settings.enableOverlayBlurEffect; + return Theme( + data: Theme.of(context).copyWith( + scaffoldBackgroundColor: overlayBackgroundColor(blurred: blurred), + ), + child: Align( + alignment: Alignment.topLeft, + child: Selector>( + selector: (context, v) => v.scale, + builder: (context, scale, child) => SizeTransition( + sizeFactor: scale, + axisAlignment: 1, + child: FadeTransition( + opacity: scale, + child: child, + ), + ), + child: ValueListenableBuilder( + valueListenable: _idleBoundsNotifier, + builder: (context, bounds, child) { + if (bounds == null) return const SizedBox(); + final filter = CoordinateFilter( + bounds.sw, + bounds.ne, + // more stable format when bounds change + minuteSecondPadding: true, + ); + return Padding( + padding: EdgeInsets.all(widget.padding), + child: BlurredRRect( + enabled: blurred, + borderRadius: AvesFilterChip.defaultRadius, + child: AvesFilterChip( + filter: filter, + useFilterColor: false, + maxWidth: double.infinity, + onTap: (filter) => FilterSelectedNotification(CoordinateFilter(bounds.sw, bounds.ne) ).dispatch(context), + ), + ), + ); + }, + ), + ), + ), + ); + } + + void _onBoundsChanged() { + _debouncer(() => _idleBoundsNotifier.value = widget.boundsNotifier.value); + } +} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 028090ee5..3fe2d3905 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -6,6 +6,7 @@ import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/widgets/common/map/attribution.dart'; @@ -27,6 +28,7 @@ import 'package:provider/provider.dart'; class GeoMap extends StatefulWidget { final AvesMapController? controller; + final Listenable? collectionListenable; final List entries; final AvesEntry? initialEntry; final ValueNotifier isAnimatingNotifier; @@ -42,6 +44,7 @@ class GeoMap extends StatefulWidget { const GeoMap({ Key? key, this.controller, + this.collectionListenable, required this.entries, this.initialEntry, required this.isAnimatingNotifier, @@ -57,27 +60,57 @@ class GeoMap extends StatefulWidget { } class _GeoMapState extends State { - // as of google_maps_flutter v2.0.6, Google Maps initialization is blocking + // as of google_maps_flutter v2.0.6, Google map initialization is blocking // cf https://github.com/flutter/flutter/issues/28493 // it is especially severe the first time, but still significant afterwards // so we prevent loading it while scrolling or animating bool _googleMapsLoaded = false; late final ValueNotifier _boundsNotifier; - late final Fluster _defaultMarkerCluster; + Fluster? _defaultMarkerCluster; Fluster? _slowMarkerCluster; + final AChangeNotifier _clusterChangeNotifier = AChangeNotifier(); List get entries => widget.entries; + // cap initial zoom to avoid a zoom change + // when toggling overlay on Google map initial state + static const double minInitialZoom = 3; + @override void initState() { super.initState(); final initialEntry = widget.initialEntry; final points = (initialEntry != null ? [initialEntry] : entries).map((v) => v.latLng!).toSet(); - _boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints( + final bounds = ZoomedBounds.fromPoints( points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, collocationZoom: settings.infoMapZoom, + ); + _boundsNotifier = ValueNotifier(bounds.copyWith( + zoom: max(bounds.zoom, minInitialZoom), )); - _defaultMarkerCluster = _buildFluster(); + _registerWidget(widget); + _onCollectionChanged(); + } + + @override + void didUpdateWidget(covariant GeoMap oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(GeoMap widget) { + widget.collectionListenable?.addListener(_onCollectionChanged); + } + + void _unregisterWidget(GeoMap widget) { + widget.collectionListenable?.removeListener(_onCollectionChanged); } @override @@ -92,11 +125,11 @@ class _GeoMapState extends State { return {geoEntry.entry!}; } - var points = _defaultMarkerCluster.points(clusterId); + var points = _defaultMarkerCluster?.points(clusterId) ?? []; if (points.length != geoEntry.pointsSize) { // `Fluster.points()` method does not always return all the points contained in a cluster // the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`) - _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(widget.entries.length)); + _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(entries.length)); points = _slowMarkerCluster!.points(clusterId); assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry'); } @@ -137,6 +170,7 @@ class _GeoMapState extends State { Widget child = isGoogleMaps ? EntryGoogleMap( controller: widget.controller, + clusterListenable: _clusterChangeNotifier, boundsNotifier: _boundsNotifier, minZoom: 0, maxZoom: 20, @@ -151,6 +185,7 @@ class _GeoMapState extends State { ) : EntryLeafletMap( controller: widget.controller, + clusterListenable: _clusterChangeNotifier, boundsNotifier: _boundsNotifier, minZoom: 2, maxZoom: 16, @@ -230,6 +265,12 @@ class _GeoMapState extends State { ); } + void _onCollectionChanged() { + _defaultMarkerCluster = _buildFluster(); + _slowMarkerCluster = null; + _clusterChangeNotifier.notifyListeners(); + } + Fluster _buildFluster({int nodeSize = 64}) { final markers = entries.map((entry) { final latLng = entry.latLng!; @@ -259,7 +300,7 @@ class _GeoMapState extends State { Map _buildMarkerClusters() { final bounds = _boundsNotifier.value; - final geoEntries = _defaultMarkerCluster.clusters(bounds.boundingBox, bounds.zoom.round()); + final geoEntries = _defaultMarkerCluster?.clusters(bounds.boundingBox, bounds.zoom.round()) ?? []; return Map.fromEntries(geoEntries.map((v) { if (v.isCluster!) { final uri = v.childMarkerId; diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index 0b844b1d4..e4f6d4485 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -21,6 +21,7 @@ import 'package:provider/provider.dart'; class EntryGoogleMap extends StatefulWidget { final AvesMapController? controller; + final Listenable clusterListenable; final ValueNotifier boundsNotifier; final double? minZoom, maxZoom; final EntryMapStyle style; @@ -35,6 +36,7 @@ class EntryGoogleMap extends StatefulWidget { const EntryGoogleMap({ Key? key, this.controller, + required this.clusterListenable, required this.boundsNotifier, this.minZoom, this.maxZoom, @@ -93,9 +95,11 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse if (avesMapController != null) { _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng)))); } + widget.clusterListenable.addListener(_updateMarkers); } void _unregisterWidget(EntryGoogleMap widget) { + widget.clusterListenable.removeListener(_updateMarkers); _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); @@ -109,7 +113,7 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse case AppLifecycleState.detached: break; case AppLifecycleState.resumed: - // workaround for blank Google Maps when resuming app + // workaround for blank Google map when resuming app // cf https://github.com/flutter/flutter/issues/40284 _googleMapController?.setMapStyle(null); break; @@ -167,52 +171,54 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse final interactive = context.select((v) => v.interactive); return ValueListenableBuilder( - valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null), - builder: (context, dotEntry, child) { - return GoogleMap( - initialCameraPosition: CameraPosition( - target: _toGoogleLatLng(bounds.center), - zoom: bounds.zoom, - ), - onMapCreated: (controller) async { - _googleMapController = controller; - final zoom = await controller.getZoomLevel(); - await _updateVisibleRegion(zoom: zoom, rotation: 0); - setState(() {}); - }, - // compass disabled to use provider agnostic controls - compassEnabled: false, - mapToolbarEnabled: false, - mapType: _toMapType(widget.style), - minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom), - rotateGesturesEnabled: true, - scrollGesturesEnabled: interactive, - // zoom controls disabled to use provider agnostic controls - zoomControlsEnabled: false, - zoomGesturesEnabled: interactive, - // lite mode disabled because it lacks camera animation - liteModeEnabled: false, - // tilt disabled to match leaflet - tiltGesturesEnabled: false, - myLocationEnabled: false, - myLocationButtonEnabled: false, - markers: { - ...markers, - if (dotEntry != null && _dotMarkerBitmap != null) - Marker( - markerId: const MarkerId('dot'), - anchor: const Offset(.5, .5), - consumeTapEvents: true, - icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!), - position: _toGoogleLatLng(dotEntry.latLng!), - zIndex: 1, - ) - }, - onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), - onCameraIdle: _onIdle, - onTap: (position) => widget.onMapTap?.call(), - ); - }); + valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null), + builder: (context, dotEntry, child) { + return GoogleMap( + initialCameraPosition: CameraPosition( + bearing: -bounds.rotation, + target: _toGoogleLatLng(bounds.center), + zoom: bounds.zoom, + ), + onMapCreated: (controller) async { + _googleMapController = controller; + final zoom = await controller.getZoomLevel(); + await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation); + setState(() {}); + }, + // compass disabled to use provider agnostic controls + compassEnabled: false, + mapToolbarEnabled: false, + mapType: _toMapType(widget.style), + minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom), + rotateGesturesEnabled: true, + scrollGesturesEnabled: interactive, + // zoom controls disabled to use provider agnostic controls + zoomControlsEnabled: false, + zoomGesturesEnabled: interactive, + // lite mode disabled because it lacks camera animation + liteModeEnabled: false, + // tilt disabled to match leaflet + tiltGesturesEnabled: false, + myLocationEnabled: false, + myLocationButtonEnabled: false, + markers: { + ...markers, + if (dotEntry != null && _dotMarkerBitmap != null) + Marker( + markerId: const MarkerId('dot'), + anchor: const Offset(.5, .5), + consumeTapEvents: true, + icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!), + position: _toGoogleLatLng(dotEntry.latLng!), + zIndex: 1, + ) + }, + onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), + onCameraIdle: _onIdle, + onTap: (position) => widget.onMapTap?.call(), + ); + }, + ); }, ); } @@ -220,6 +226,10 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse void _onIdle() { if (!mounted) return; widget.controller?.notifyIdle(bounds); + _updateMarkers(); + } + + void _updateMarkers() { setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); } diff --git a/lib/widgets/common/map/google/marker_generator.dart b/lib/widgets/common/map/google/marker_generator.dart index bc572cad0..b296d0575 100644 --- a/lib/widgets/common/map/google/marker_generator.dart +++ b/lib/widgets/common/map/google/marker_generator.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; -// generate bitmap from widget, for Google Maps +// generate bitmap from widget, for Google map class MarkerGeneratorWidget extends StatefulWidget { final List markers; final bool Function(T markerKey) isReadyToRender; diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index 7f2b14722..cfdcb0eb5 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/map/buttons.dart'; @@ -22,6 +23,7 @@ import 'package:provider/provider.dart'; class EntryLeafletMap extends StatefulWidget { final AvesMapController? controller; + final Listenable clusterListenable; final ValueNotifier boundsNotifier; final double minZoom, maxZoom; final EntryMapStyle style; @@ -37,6 +39,7 @@ class EntryLeafletMap extends StatefulWidget { const EntryLeafletMap({ Key? key, this.controller, + required this.clusterListenable, required this.boundsNotifier, this.minZoom = 0, this.maxZoom = 22, @@ -66,7 +69,7 @@ class _EntryLeafletMapState extends State with TickerProviderSt ZoomedBounds get bounds => boundsNotifier.value; - // duration should match the uncustomizable Google Maps duration + // duration should match the uncustomizable Google map duration static const _cameraAnimationDuration = Duration(milliseconds: 600); @override @@ -95,11 +98,13 @@ class _EntryLeafletMapState extends State with TickerProviderSt _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng))); } _subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion())); - boundsNotifier.addListener(_onBoundsChange); + widget.clusterListenable.addListener(_updateMarkers); + widget.boundsNotifier.addListener(_onBoundsChange); } void _unregisterWidget(EntryLeafletMap widget) { - boundsNotifier.removeListener(_onBoundsChange); + widget.clusterListenable.removeListener(_updateMarkers); + widget.boundsNotifier.removeListener(_onBoundsChange); _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); @@ -151,6 +156,7 @@ class _EntryLeafletMapState extends State with TickerProviderSt options: MapOptions( center: bounds.center, zoom: bounds.zoom, + rotation: bounds.rotation, minZoom: widget.minZoom, maxZoom: widget.maxZoom, // TODO TLAD [map] as of flutter_map v0.14.0, `doubleTapZoom` does not move when zoom is already maximal @@ -162,7 +168,9 @@ class _EntryLeafletMapState extends State with TickerProviderSt mapController: _leafletMapController, nonRotatedChildren: [ ScaleLayerWidget( - options: ScaleLayerOptions(), + options: ScaleLayerOptions( + unitSystem: settings.unitSystem, + ), ), ], children: [ @@ -212,6 +220,10 @@ class _EntryLeafletMapState extends State with TickerProviderSt void _onIdle() { if (!mounted) return; widget.controller?.notifyIdle(bounds); + _updateMarkers(); + } + + void _updateMarkers() { setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); } diff --git a/lib/widgets/common/map/leaflet/scale_layer.dart b/lib/widgets/common/map/leaflet/scale_layer.dart index d87005bcc..64e8321b2 100644 --- a/lib/widgets/common/map/leaflet/scale_layer.dart +++ b/lib/widgets/common/map/leaflet/scale_layer.dart @@ -1,5 +1,4 @@ -import 'dart:math'; - +import 'package:aves/model/settings/enums.dart'; import 'package:aves/widgets/common/basic/outlined_text.dart'; import 'package:aves/widgets/common/map/leaflet/scalebar_utils.dart'; import 'package:flutter/material.dart'; @@ -7,10 +6,12 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/plugin_api.dart'; class ScaleLayerOptions extends LayerOptions { + final UnitSystem unitSystem; final Widget Function(double width, String distance) builder; ScaleLayerOptions({ Key? key, + this.unitSystem = UnitSystem.metric, this.builder = defaultBuilder, rebuild, }) : super(key: key, rebuild: rebuild); @@ -41,7 +42,7 @@ class ScaleLayer extends StatelessWidget { // ignore: prefer_void_to_null final Stream stream; - final scale = [ + static const List scaleMeters = [ 25000000, 15000000, 8000000, @@ -67,6 +68,10 @@ class ScaleLayer extends StatelessWidget { 5, ]; + static const double metersInAKilometer = 1000; + static const double metersInAMile = 1609.344; + static const double metersInAFoot = 0.3048; + ScaleLayer(this.scaleLayerOpts, this.map, this.stream) : super(key: scaleLayerOpts.key); @override @@ -83,11 +88,33 @@ class ScaleLayer extends StatelessWidget { : latitude > 60 ? 3 : 2); - final distance = scale[max(0, min(20, level))].toDouble(); + final scaleLevel = level.clamp(0, 20); + late final double distanceMeters; + late final String displayDistance; + switch (scaleLayerOpts.unitSystem) { + case UnitSystem.metric: + // meters + distanceMeters = scaleMeters[scaleLevel]; + displayDistance = distanceMeters >= metersInAKilometer ? '${(distanceMeters / metersInAKilometer).toStringAsFixed(0)} km' : '${distanceMeters.toStringAsFixed(0)} m'; + break; + case UnitSystem.imperial: + if (scaleLevel < 15) { + // miles + final distanceMiles = scaleMeters[scaleLevel + 1] / 1000; + distanceMeters = distanceMiles * metersInAMile; + displayDistance = '${distanceMiles.toStringAsFixed(0)} mi'; + } else { + // feet + final distanceFeet = scaleMeters[scaleLevel - 1]; + distanceMeters = distanceFeet * metersInAFoot; + displayDistance = '${distanceFeet.toStringAsFixed(0)} ft'; + } + break; + } + final start = map.project(center); - final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distance); + final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distanceMeters); final end = map.project(targetPoint); - final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m'; final width = end.x - (start.x as double); return scaleLayerOpts.builder(width, displayDistance); diff --git a/lib/widgets/common/map/leaflet/scalebar_utils.dart b/lib/widgets/common/map/leaflet/scalebar_utils.dart index 8cdc50ca4..0027bc616 100644 --- a/lib/widgets/common/map/leaflet/scalebar_utils.dart +++ b/lib/widgets/common/map/leaflet/scalebar_utils.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:latlong2/latlong.dart'; class ScaleBarUtils { - static LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) { + static LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distanceMeters) { var mSemiMajorAxis = 6378137.0; //WGS84 major axis var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0; var mFlattening = 1.0 / 298.257223563; @@ -18,7 +18,7 @@ class ScaleBarUtils { var alpha1 = degToRadian(startBearing); var cosAlpha1 = cos(alpha1); var sinAlpha1 = sin(alpha1); - var s = distance; + var s = distanceMeters; var tanU1 = (1.0 - f) * tan(phi1); var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1); var sinU1 = tanU1 * cosU1; diff --git a/lib/widgets/common/map/marker.dart b/lib/widgets/common/map/marker.dart index 6a82a2814..a25cba8ff 100644 --- a/lib/widgets/common/map/marker.dart +++ b/lib/widgets/common/map/marker.dart @@ -39,7 +39,7 @@ class ImageMarker extends StatelessWidget { ) : const SizedBox(); - // need to be sized for the Google Maps marker generator + // need to be sized for the Google map marker generator child = SizedBox( width: extent, height: extent, diff --git a/lib/widgets/common/map/theme.dart b/lib/widgets/common/map/theme.dart index c2690d2f2..b0e70ee40 100644 --- a/lib/widgets/common/map/theme.dart +++ b/lib/widgets/common/map/theme.dart @@ -5,7 +5,7 @@ import 'package:provider/provider.dart'; enum MapNavigationButton { back, map } class MapTheme extends StatelessWidget { - final bool interactive; + final bool interactive, showCoordinateFilter; final MapNavigationButton navigationButton; final Animation scale; final VisualDensity? visualDensity; @@ -15,6 +15,7 @@ class MapTheme extends StatelessWidget { const MapTheme({ Key? key, required this.interactive, + required this.showCoordinateFilter, required this.navigationButton, this.scale = kAlwaysCompleteAnimation, this.visualDensity, @@ -28,6 +29,7 @@ class MapTheme extends StatelessWidget { update: (context, settings, __) { return MapThemeData( interactive: interactive, + showCoordinateFilter: showCoordinateFilter, navigationButton: navigationButton, scale: scale, visualDensity: visualDensity, @@ -40,7 +42,7 @@ class MapTheme extends StatelessWidget { } class MapThemeData { - final bool interactive; + final bool interactive, showCoordinateFilter; final MapNavigationButton navigationButton; final Animation scale; final VisualDensity? visualDensity; @@ -48,6 +50,7 @@ class MapThemeData { const MapThemeData({ required this.interactive, + required this.showCoordinateFilter, required this.navigationButton, required this.scale, required this.visualDensity, diff --git a/lib/widgets/common/map/zoomed_bounds.dart b/lib/widgets/common/map/zoomed_bounds.dart index 1175a62cb..db65719c9 100644 --- a/lib/widgets/common/map/zoomed_bounds.dart +++ b/lib/widgets/common/map/zoomed_bounds.dart @@ -13,7 +13,7 @@ class ZoomedBounds extends Equatable { // returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster List get boundingBox => [sw.longitude, sw.latitude, ne.longitude, ne.latitude]; - LatLng get center => getLatLngCenter([sw, ne]); + LatLng get center => GeoUtils.getLatLngCenter([sw, ne]); @override List get props => [sw, ne, zoom, rotation]; @@ -63,13 +63,19 @@ class ZoomedBounds extends Equatable { ); } - bool contains(LatLng point) { - final lat = point.latitude; - final lng = point.longitude; - final south = sw.latitude; - final north = ne.latitude; - final west = sw.longitude; - final east = ne.longitude; - return (south <= lat && lat <= north) && (west <= east ? (west <= lng && lng <= east) : (west <= lng || lng <= east)); + ZoomedBounds copyWith({ + LatLng? sw, + LatLng? ne, + double? zoom, + double? rotation, + }) { + return ZoomedBounds( + sw: sw ?? this.sw, + ne: ne ?? this.ne, + zoom: zoom ?? this.zoom, + rotation: rotation ?? this.rotation, + ); } + + bool contains(LatLng point) => GeoUtils.contains(sw, ne, point); } diff --git a/lib/widgets/common/thumbnail/image.dart b/lib/widgets/common/thumbnail/image.dart index ed3461e1c..26af6f3d8 100644 --- a/lib/widgets/common/thumbnail/image.dart +++ b/lib/widgets/common/thumbnail/image.dart @@ -232,12 +232,15 @@ class _ThumbnailImageState extends State { ); if (animate && widget.heroTag != null) { + final background = settings.imageBackground; + final backgroundColor = background.isColor? background.color : null; image = Hero( tag: widget.heroTag!, flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { return TransitionImage( image: entry.bestCachedThumbnail, animation: animation, + background: backgroundColor, ); }, transitionOnUserGestures: true, diff --git a/lib/widgets/debug/android_apps.dart b/lib/widgets/debug/android_apps.dart index eeaf09a44..c21c8e263 100644 --- a/lib/widgets/debug/android_apps.dart +++ b/lib/widgets/debug/android_apps.dart @@ -1,5 +1,5 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; -import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/info/common.dart'; @@ -21,7 +21,7 @@ class _DebugAndroidAppSectionState extends State with Au @override void initState() { super.initState(); - _loader = AndroidAppService.getPackages(); + _loader = androidAppService.getPackages(); } @override diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index cf09dc132..fdf0be831 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/analysis_service.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/debug/android_apps.dart'; @@ -103,6 +104,10 @@ class _AppDebugPageState extends State { }, child: const Text('Source full refresh'), ), + ElevatedButton( + onPressed: () => AnalysisService.startService(force: false), + child: const Text('Start analysis service'), + ), const Divider(), Padding( padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index b21cc3212..3f2231faa 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -38,6 +38,11 @@ class DebugSettingsSection extends StatelessWidget { onChanged: (v) => settings.hasAcceptedTerms = v, title: const Text('hasAcceptedTerms'), ), + SwitchListTile( + value: settings.canUseAnalysisService, + onChanged: (v) => settings.canUseAnalysisService = v, + title: const Text('canUseAnalysisService'), + ), SwitchListTile( value: settings.videoShowRawTimedText, onChanged: (v) => settings.videoShowRawTimedText = v, diff --git a/lib/widgets/dialogs/aves_dialog.dart b/lib/widgets/dialogs/aves_dialog.dart index 7fbc0c919..c4a28c2a0 100644 --- a/lib/widgets/dialogs/aves_dialog.dart +++ b/lib/widgets/dialogs/aves_dialog.dart @@ -5,8 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class AvesDialog extends AlertDialog { - static const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: 24); - static const borderWidth = 1.0; + static const EdgeInsets contentHorizontalPadding = EdgeInsets.symmetric(horizontal: 24); + static const double controlCaptionPadding = 16; + static const double borderWidth = 1.0; AvesDialog({ Key? key, diff --git a/lib/widgets/dialogs/aves_selection_dialog.dart b/lib/widgets/dialogs/aves_selection_dialog.dart index d339470a4..65ef50f58 100644 --- a/lib/widgets/dialogs/aves_selection_dialog.dart +++ b/lib/widgets/dialogs/aves_selection_dialog.dart @@ -10,14 +10,16 @@ class AvesSelectionDialog extends StatefulWidget { final T initialValue; final Map options; final TextBuilder? optionSubtitleBuilder; - final String title; + final String? title, message, confirmationButtonLabel; const AvesSelectionDialog({ Key? key, required this.initialValue, required this.options, this.optionSubtitleBuilder, - required this.title, + this.title, + this.message, + this.confirmationButtonLabel, }) : super(key: key); @override @@ -35,27 +37,48 @@ class _AvesSelectionDialogState extends State> { @override Widget build(BuildContext context) { + final message = widget.message; + final confirmationButtonLabel = widget.confirmationButtonLabel; + final needConfirmation = confirmationButtonLabel != null; return AvesDialog( context: context, title: widget.title, - scrollableContent: widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value)).toList(), + scrollableContent: [ + if (message != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(message), + ), + ...widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value, needConfirmation)), + ], actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), + if (needConfirmation) + TextButton( + onPressed: () => Navigator.pop(context, _selectedValue), + child: Text(confirmationButtonLabel!), + ), ], ); } - Widget _buildRadioListTile(T value, String title) { + Widget _buildRadioListTile(T value, String title, bool needConfirmation) { final subtitle = widget.optionSubtitleBuilder?.call(value); return ReselectableRadioListTile( // key is expected by test driver key: Key(value.toString()), value: value, groupValue: _selectedValue, - onChanged: (v) => Navigator.pop(context, v), + onChanged: (v) { + if (needConfirmation) { + setState(() => _selectedValue = v!); + } else { + Navigator.pop(context, v); + } + }, reselectable: true, title: Text( title, diff --git a/lib/widgets/dialogs/export_entry_dialog.dart b/lib/widgets/dialogs/export_entry_dialog.dart new file mode 100644 index 000000000..c25bdf57e --- /dev/null +++ b/lib/widgets/dialogs/export_entry_dialog.dart @@ -0,0 +1,70 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/utils/mime_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; + +import 'aves_dialog.dart'; + +class ExportEntryDialog extends StatefulWidget { + final AvesEntry entry; + + const ExportEntryDialog({ + Key? key, + required this.entry, + }) : super(key: key); + + @override + _ExportEntryDialogState createState() => _ExportEntryDialogState(); +} + +class _ExportEntryDialogState extends State { + String _mimeType = MimeTypes.jpeg; + + AvesEntry get entry => widget.entry; + + static const imageExportFormats = [ + MimeTypes.bmp, + MimeTypes.jpeg, + MimeTypes.png, + MimeTypes.webp, + ]; + + @override + Widget build(BuildContext context) { + return AvesDialog( + context: context, + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.exportEntryDialogFormat), + const SizedBox(width: AvesDialog.controlCaptionPadding), + DropdownButton( + items: imageExportFormats.map((mimeType) { + return DropdownMenuItem( + value: mimeType, + child: Text(MimeUtils.displayType(mimeType)), + ); + }).toList(), + value: _mimeType, + onChanged: (selected) { + if (selected != null) { + setState(() => _mimeType = selected); + } + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, _mimeType), + child: Text(context.l10n.applyButtonLabel), + ) + ], + ); + } +} diff --git a/lib/widgets/drawer/tile.dart b/lib/widgets/drawer/tile.dart index 575aa6483..54daa7fea 100644 --- a/lib/widgets/drawer/tile.dart +++ b/lib/widgets/drawer/tile.dart @@ -48,6 +48,7 @@ class DrawerFilterTitle extends StatelessWidget { if (filter == MimeFilter.video) return l10n.drawerCollectionVideos; if (filter == TypeFilter.motionPhoto) return l10n.drawerCollectionMotionPhotos; if (filter == TypeFilter.panorama) return l10n.drawerCollectionPanoramas; + if (filter == TypeFilter.raw) return l10n.drawerCollectionRaws; if (filter == TypeFilter.sphericalVideo) return l10n.drawerCollectionSphericalVideos; return filter.getLabel(context); } diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 28eee742d..4e798ed86 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -32,9 +32,9 @@ class AlbumListPage extends StatelessWidget { return !(eq.equals(t1.item1, t2.item1) && eq.equals(t1.item2, t2.item2) && eq.equals(t1.item3, t2.item3)); }, builder: (context, s, child) { - return AnimatedBuilder( - animation: androidFileUtils.appNameChangeNotifier, - builder: (context, child) => StreamBuilder( + return ValueListenableBuilder( + valueListenable: androidFileUtils.areAppNamesReadyNotifier, + builder: (context, areAppNamesReady, child) => StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) { final gridItems = getAlbumGridItems(context, source); diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index a2f0bb183..334f455b7 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -10,6 +10,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -226,7 +227,13 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { source.pauseMonitoring(); showOpReport( context: context, - opStream: mediaFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum), + opStream: mediaFileService.move( + todoEntries, + copy: false, + destinationAlbum: destinationAlbum, + // there should be no file conflict, as the target directory itself does not exist + nameConflictStrategy: NameConflictStrategy.rename, + ), itemCount: todoCount, onDone: (processed) async { final movedOps = processed.where((e) => e.success).toSet(); diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index 74d057f81..dc1375a7a 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -96,7 +96,14 @@ class CoveredFilterChip extends StatelessWidget { Widget _buildChip(BuildContext context, CollectionSource source) { final entry = coverEntry ?? source.coverEntry(filter); final titlePadding = min(4.0, extent / 32); + Key? chipKey; + if (filter is AlbumFilter) { + // when we asynchronously fetch installed app names, + // album filters themselves do not change, but decoration derived from it does + chipKey = ValueKey(androidFileUtils.areAppNamesReadyNotifier.value); + } return AvesFilterChip( + key: chipKey, filter: filter, showGenericIcon: false, decoration: AvesFilterDecoration( diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 0f079f8cb..22fd1363e 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -7,6 +7,7 @@ import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/global_search.dart'; import 'package:aves/services/viewer_service.dart'; @@ -55,6 +56,7 @@ class _HomePageState extends State { Widget build(BuildContext context) => const Scaffold(); Future _setup() async { + final stopwatch = Stopwatch()..start(); final permissions = await [ Permission.storage, // to access media with unredacted metadata with scoped storage (Android 10+) @@ -72,6 +74,7 @@ class _HomePageState extends State { final intentData = widget.intentData ?? await ViewerService.getIntentData(); if (intentData.isNotEmpty) { final action = intentData['action']; + await reportService.log('Intent action=$action'); switch (action) { case 'view': _viewerEntry = await _initViewerEntry( @@ -107,10 +110,12 @@ class _HomePageState extends State { unawaited(reportService.setCustomKey('app_mode', appMode.toString())); if (appMode != AppMode.view) { + debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms'); + unawaited(GlobalSearch.registerCallback()); + unawaited(AnalysisService.registerCallback()); final source = context.read(); await source.init(); unawaited(source.refresh()); - unawaited(GlobalSearch.registerCallback()); } // `pushReplacement` is not enough in some edge cases @@ -126,7 +131,7 @@ class _HomePageState extends State { final entry = await mediaFileService.getEntry(uri, mimeType); if (entry != null) { // cataloguing is essential for coordinates and video rotation - await entry.catalog(background: false, persist: false); + await entry.catalog(background: false, persist: false, force: false); } return entry; } diff --git a/lib/widgets/map/map_info_row.dart b/lib/widgets/map/map_info_row.dart index d3923a454..ee267c138 100644 --- a/lib/widgets/map/map_info_row.dart +++ b/lib/widgets/map/map_info_row.dart @@ -110,7 +110,7 @@ class _AddressRowState extends State<_AddressRow> { child: Container( alignment: AlignmentDirectional.centerStart, // addresses can include non-latin scripts with inconsistent line height, - // which is especially an issue for relayout/painting of heavy Google maps, + // which is especially an issue for relayout/painting of heavy Google map, // so we give extra height to give breathing room to the text and stabilize layout height: Theme.of(context).textTheme.bodyText2!.fontSize! * context.select((mq) => mq.textScaleFactor) * 2, child: ValueListenableBuilder( @@ -147,7 +147,7 @@ class _AddressRowState extends State<_AddressRow> { Future _getAddressLine(AvesEntry? entry) async { if (entry != null && await availability.canLocatePlaces) { - final addresses = await GeocodingService.getAddress(entry.latLng!, entry.geocoderLocale); + final addresses = await GeocodingService.getAddress(entry.latLng!, settings.appliedLocale); if (addresses.isNotEmpty) { final address = addresses.first; return address.addressLine; diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 5c70df61c..b7a7df05a 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/coordinate.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/map_style.dart'; @@ -8,6 +10,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; @@ -20,6 +23,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:aves/widgets/map/map_info_row.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -87,10 +91,10 @@ class _MapPageContentState extends State with SingleTickerProvid late AnimationController _overlayAnimationController; late Animation _overlayScale, _scrollerSize; - List get entries => widget.collection.sortedEntries; - CollectionLens? get regionCollection => _regionCollectionNotifier.value; + CollectionLens get openingCollection => widget.collection; + @override void initState() { super.initState(); @@ -154,49 +158,55 @@ class _MapPageContentState extends State with SingleTickerProvid @override Widget build(BuildContext context) { - return Selector( - selector: (context, s) => s.infoMapStyle, - builder: (context, mapStyle, child) { - late Widget scroller; - if (mapStyle.isGoogleMaps) { - // the Google map widget is too heavy for a smooth resizing animation - // so we just toggle visibility when overlay animation is done - scroller = ValueListenableBuilder( - valueListenable: _overlayAnimationController, - builder: (context, animation, child) { - return Visibility( - visible: !_overlayAnimationController.isDismissed, - child: child!, - ); - }, - child: child, - ); - } else { - // the Leaflet map widget is light enough for a smooth resizing animation - scroller = FadeTransition( - opacity: _scrollerSize, - child: SizeTransition( - sizeFactor: _scrollerSize, - axisAlignment: 1.0, - child: child, - ), - ); - } - - return Column( - children: [ - Expanded(child: _buildMap()), - scroller, - ], - ); + return NotificationListener( + onNotification: (notification) { + _goToCollection(notification.filter); + return true; }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - const Divider(height: 0), - _buildScroller(), - ], + child: Selector( + selector: (context, s) => s.infoMapStyle, + builder: (context, mapStyle, child) { + late Widget scroller; + if (mapStyle.isGoogleMaps) { + // the Google map widget is too heavy for a smooth resizing animation + // so we just toggle visibility when overlay animation is done + scroller = ValueListenableBuilder( + valueListenable: _overlayAnimationController, + builder: (context, animation, child) { + return Visibility( + visible: !_overlayAnimationController.isDismissed, + child: child!, + ); + }, + child: child, + ); + } else { + // the Leaflet map widget is light enough for a smooth resizing animation + scroller = FadeTransition( + opacity: _scrollerSize, + child: SizeTransition( + sizeFactor: _scrollerSize, + axisAlignment: 1.0, + child: child, + ), + ); + } + + return Column( + children: [ + Expanded(child: _buildMap()), + scroller, + ], + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + const Divider(height: 0), + _buildScroller(), + ], + ), ), ); } @@ -204,11 +214,15 @@ class _MapPageContentState extends State with SingleTickerProvid Widget _buildMap() { return MapTheme( interactive: true, + showCoordinateFilter: true, navigationButton: MapNavigationButton.back, scale: _overlayScale, child: GeoMap( + // key is expected by test driver + key: const Key('map_view'), controller: _mapController, - entries: entries, + collectionListenable: openingCollection, + entries: openingCollection.sortedEntries, initialEntry: widget.initialEntry, isAnimatingNotifier: _isPageAnimatingNotifier, dotEntryNotifier: _dotEntryNotifier, @@ -242,15 +256,21 @@ class _MapPageContentState extends State with SingleTickerProvid builder: (context, mqWidth, child) => ValueListenableBuilder( valueListenable: _regionCollectionNotifier, builder: (context, regionCollection, child) { - final regionEntries = regionCollection?.sortedEntries ?? []; - return ThumbnailScroller( - availableWidth: mqWidth, - entryCount: regionEntries.length, - entryBuilder: (index) => regionEntries[index], - indexNotifier: _selectedIndexNotifier, - onTap: _onThumbnailTap, - heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.uri]), - highlightable: true, + return AnimatedBuilder( + // update when entries are added/removed + animation: regionCollection ?? ChangeNotifier(), + builder: (context, child) { + final regionEntries = regionCollection?.sortedEntries ?? []; + return ThumbnailScroller( + availableWidth: mqWidth, + entryCount: regionEntries.length, + entryBuilder: (index) => index < regionEntries.length ? regionEntries[index] : null, + indexNotifier: _selectedIndexNotifier, + onTap: _onThumbnailTap, + heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.uri]), + highlightable: true, + ); + }, ); }, ), @@ -282,10 +302,11 @@ class _MapPageContentState extends State with SingleTickerProvid selectedEntry = selectedIndex != null && selectedIndex < regionEntries.length ? regionEntries[selectedIndex] : null; } - _regionCollectionNotifier.value = CollectionLens( - source: widget.collection.source, - listenToSource: false, - fixedSelection: entries.where((entry) => bounds.contains(entry.latLng!)).toList(), + _regionCollectionNotifier.value = openingCollection.copyWith( + filters: { + ...openingCollection.filters.whereNot((v) => v is CoordinateFilter), + CoordinateFilter(bounds.sw, bounds.ne), + }, ); // get entries from the new collection, so the entry order is the same @@ -335,7 +356,9 @@ class _MapPageContentState extends State with SingleTickerProvid settings: const RouteSettings(name: EntryViewerPage.routeName), pageBuilder: (context, a, sa) { return EntryViewerPage( - collection: regionCollection, + collection: regionCollection?.copyWith( + listenToSource: false, + ), initialEntry: initialEntry, ); }, @@ -343,11 +366,29 @@ class _MapPageContentState extends State with SingleTickerProvid ); } + void _goToCollection(CollectionFilter filter) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) { + return CollectionPage( + collection: CollectionLens( + source: openingCollection.source, + filters: openingCollection.filters, + )..addFilter(filter), + ); + }, + ), + (route) => false, + ); + } + // overlay void _toggleOverlay() => _overlayVisible.value = !_overlayVisible.value; - // TODO TLAD [map] as of Flutter v2.5.1 / google_maps_flutter v2.0.10, toggling overlay changes the size of the map, which is an issue for Google Maps on Android 12 + // TODO TLAD [map] as of Flutter v2.5.1 / google_maps_flutter v2.0.10, toggling overlay changes the size of the map, which is an issue for Google map on Android 12 // cf https://github.com/flutter/flutter/issues/90556 Future _onOverlayVisibleChange({bool animate = true}) async { if (_overlayVisible.value) { diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index fe79bce05..3796eb7a2 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -40,6 +40,7 @@ class CollectionSearchDelegate { TypeFilter.panorama, TypeFilter.sphericalVideo, TypeFilter.geotiff, + TypeFilter.raw, MimeFilter(MimeTypes.svg), ]; diff --git a/lib/widgets/settings/language/language.dart b/lib/widgets/settings/language/language.dart index ab6f79186..066ec33f1 100644 --- a/lib/widgets/settings/language/language.dart +++ b/lib/widgets/settings/language/language.dart @@ -1,6 +1,7 @@ import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/settings/unit_system.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; @@ -23,6 +24,7 @@ class LanguageSection extends StatelessWidget { @override Widget build(BuildContext context) { final currentCoordinateFormat = context.select((s) => s.coordinateFormat); + final currentUnitSystem = context.select((s) => s.unitSystem); return AvesExpansionTile( // use a fixed value instead of the title to identify this expansion tile @@ -55,6 +57,23 @@ class LanguageSection extends StatelessWidget { } }, ), + ListTile( + title: Text(context.l10n.settingsUnitSystemTile), + subtitle: Text(currentUnitSystem.getName(context)), + onTap: () async { + final value = await showDialog( + context: context, + builder: (context) => AvesSelectionDialog( + initialValue: currentUnitSystem, + options: Map.fromEntries(UnitSystem.values.map((v) => MapEntry(v, v.getName(context)))), + title: context.l10n.settingsUnitSystemTitle, + ), + ); + if (value != null) { + settings.unitSystem = value; + } + }, + ), ], ); } diff --git a/lib/widgets/settings/viewer/viewer.dart b/lib/widgets/settings/viewer/viewer.dart index 85269abb4..e06bb572b 100644 --- a/lib/widgets/settings/viewer/viewer.dart +++ b/lib/widgets/settings/viewer/viewer.dart @@ -32,6 +32,14 @@ class ViewerSection extends StatelessWidget { showHighlight: false, children: [ const ViewerActionsTile(), + Selector( + selector: (context, s) => s.showOverlayOnOpening, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showOverlayOnOpening = v, + title: Text(context.l10n.settingsViewerShowOverlayOnOpening), + ), + ), Selector( selector: (context, s) => s.showOverlayMinimap, builder: (context, current, child) => SwitchListTile( diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index 36403ffb7..2d5754440 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -21,7 +21,7 @@ class FilterTable extends StatelessWidget { required this.onFilterSelection, }) : super(key: key); - static const chipWidth = AvesFilterChip.maxChipWidth; + static const chipWidth = AvesFilterChip.defaultMaxChipWidth; static const countWidth = 32.0; static const percentIndicatorMinWidth = 80.0; diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index ece06d17e..7089a8581 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -68,6 +68,7 @@ class ViewerDebugPage extends StatelessWidget { 'sourceTitle': entry.sourceTitle ?? '', 'sourceMimeType': entry.sourceMimeType, 'mimeType': entry.mimeType, + 'isMissingAtPath': '${entry.isMissingAtPath}', }, ), const Divider(), diff --git a/lib/widgets/viewer/embedded/embedded_data_opener.dart b/lib/widgets/viewer/embedded/embedded_data_opener.dart index f09a5f04d..6afea2433 100644 --- a/lib/widgets/viewer/embedded/embedded_data_opener.dart +++ b/lib/widgets/viewer/embedded/embedded_data_opener.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/ref/mime_types.dart'; -import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; @@ -56,10 +55,10 @@ class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin { final uri = fields['uri']!; if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) { // open with another app - unawaited(AndroidAppService.open(uri, mimeType).then((success) { + unawaited(androidAppService.open(uri, mimeType).then((success) { if (!success) { // fallback to sharing, so that the file can be saved somewhere - AndroidAppService.shareSingle(uri, mimeType).then((success) { + androidAppService.shareSingle(uri, mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); }); } diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index aba76e6f6..58a134383 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -9,10 +9,9 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/ref/mime_types.dart'; -import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -20,6 +19,7 @@ import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/dialogs/rename_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; @@ -38,7 +38,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix entry.toggleFavourite(); break; case EntryAction.copyToClipboard: - AndroidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { + androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { showFeedback(context, success ? context.l10n.genericSuccessFeedback : context.l10n.genericFailureFeedback); }); break; @@ -67,17 +67,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix _flip(context, entry); break; case EntryAction.edit: - AndroidAppService.edit(entry.uri, entry.mimeType).then((success) { + androidAppService.edit(entry.uri, entry.mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; case EntryAction.open: - AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) { + androidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; case EntryAction.openMap: - AndroidAppService.openMap(entry.latLng!).then((success) { + androidAppService.openMap(entry.latLng!).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; @@ -85,12 +85,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix _rotateScreen(context); break; case EntryAction.setAs: - AndroidAppService.setAs(entry.uri, entry.mimeType).then((success) { + androidAppService.setAs(entry.uri, entry.mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; case EntryAction.share: - AndroidAppService.shareEntries({entry}).then((success) { + androidAppService.shareEntries({entry}).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; @@ -185,6 +185,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; + final mimeType = await showDialog( + context: context, + builder: (context) => ExportEntryDialog(entry: entry), + ); + if (mimeType == null) return; + final selection = {}; if (entry.isMultiPage) { final multiPageInfo = await entry.getMultiPageInfo(); @@ -201,20 +207,26 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } final selectionCount = selection.length; + source.pauseMonitoring(); showOpReport( context: context, // TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures) opStream: mediaFileService.export( selection, - mimeType: MimeTypes.jpeg, + mimeType: mimeType, destinationAlbum: destinationAlbum, + nameConflictStrategy: NameConflictStrategy.rename, ), itemCount: selectionCount, onDone: (processed) { - final movedOps = processed.where((e) => e.success); - final movedCount = movedOps.length; + final exportOps = processed.where((e) => e.success); + final exportCount = exportOps.length; final isMainMode = context.read>().value == AppMode.main; - final showAction = isMainMode && movedCount > 0 + + source.resumeMonitoring(); + source.refreshUris(exportOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet()); + + final showAction = isMainMode && exportCount > 0 ? SnackBarAction( label: context.l10n.showButtonLabel, onPressed: () async { @@ -235,7 +247,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix )); final delayDuration = context.read().staggeredAnimationPageTarget; await Future.delayed(delayDuration + Durations.highlightScrollInitDelay); - final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); + final newUris = exportOps.map((v) => v.newFields['uri'] as String?).toSet(); final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); if (targetEntry != null) { highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); @@ -243,8 +255,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix }, ) : null; - if (movedCount < selectionCount) { - final count = selectionCount - movedCount; + if (exportCount < selectionCount) { + final count = selectionCount - exportCount; showFeedback( context, context.l10n.collectionExportFailureFeedback(count), diff --git a/lib/widgets/viewer/entry_horizontal_pager.dart b/lib/widgets/viewer/entry_horizontal_pager.dart index 4670105e4..86eddea63 100644 --- a/lib/widgets/viewer/entry_horizontal_pager.dart +++ b/lib/widgets/viewer/entry_horizontal_pager.dart @@ -1,4 +1,6 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/accessibility_animations.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; @@ -54,19 +56,27 @@ class _MultiEntryScrollerState extends State with AutomaticK ) : _buildViewer(mainEntry); - child = AnimatedBuilder( - animation: pageController, - builder: (context, child) { - // parallax scrolling - double dx = 0; - if (pageController.hasClients && pageController.position.haveDimensions) { - final delta = pageController.page! - index; - dx = delta * pageController.position.viewportDimension / 2; - } - return Transform.translate( - offset: Offset(dx, 0), - child: child, - ); + child = Selector( + selector: (context, s) => s.accessibilityAnimations.animate, + builder: (context, animate, child) { + return animate + ? AnimatedBuilder( + animation: pageController, + builder: (context, child) { + // parallax scrolling + double dx = 0; + if (pageController.hasClients && pageController.position.haveDimensions) { + final delta = pageController.page! - index; + dx = delta * pageController.position.viewportDimension / 2; + } + return Transform.translate( + offset: Offset(dx, 0), + child: child, + ); + }, + child: child, + ) + : child!; }, child: child, ); @@ -83,7 +93,7 @@ class _MultiEntryScrollerState extends State with AutomaticK Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) { return EntryPageView( // key is expected by test driver - key: const Key('imageview'), + key: const Key('image_view'), mainEntry: mainEntry, pageEntry: pageEntry ?? mainEntry, onDisposed: () => widget.onViewDisposed(mainEntry, pageEntry), diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index ff1b785d2..7725da999 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; @@ -158,27 +159,33 @@ class _ViewerVerticalPageViewState extends State { } // when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted) - void _onEntryChanged() { + Future _onEntryChanged() async { _oldEntry?.imageChangeNotifier.removeListener(_onImageChanged); _oldEntry = entry; - if (entry != null) { - entry!.imageChangeNotifier.addListener(_onImageChanged); + final _entry = entry; + if (_entry != null) { + _entry.imageChangeNotifier.addListener(_onImageChanged); // make sure to locate the entry, // so that we can display the address instead of coordinates // even when initial collection locating has not reached this entry yet - entry!.catalog(background: false).then((_) => entry!.locate(background: false)); + await _entry.catalog(background: false, persist: true, force: false); + await _entry.locate(background: false, force: false, geocoderLocale: settings.appliedLocale); } else { Navigator.pop(context); } // needed to refresh when entry changes but the page does not (e.g. on page deletion) - setState(() {}); + if (mounted) { + setState(() {}); + } } // when the entry image itself changed (e.g. after rotation) void _onImageChanged() async { // rebuild to refresh the Image inside ImagePage - setState(() {}); + if (mounted) { + setState(() {}); + } } } diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 85334e619..40ac87f35 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -118,6 +118,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, parent: _overlayAnimationController, curve: Curves.easeOutQuad, )); + _overlayVisible.value = settings.showOverlayOnOpening; _overlayVisible.addListener(_onOverlayVisibleChange); _videoActionDelegate = VideoActionDelegate( collection: collection, @@ -213,6 +214,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, ), _buildTopOverlay(), _buildBottomOverlay(), + const SideGestureAreaProtector(), const BottomGestureAreaProtector(), ], ), diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index a4642f5c2..8fa125ab9 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -85,6 +85,7 @@ class BasicSection extends StatelessWidget { if (entry.isAnimated) TypeFilter.animated, if (entry.isGeotiff) TypeFilter.geotiff, if (entry.isMotionPhoto) TypeFilter.motionPhoto, + if (entry.isRaw) TypeFilter.raw, if (entry.isImage && entry.is360) TypeFilter.panorama, if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo, if (entry.isVideo && !entry.is360) MimeFilter.video, diff --git a/lib/widgets/viewer/info/entry_info_action_delegate.dart b/lib/widgets/viewer/info/entry_info_action_delegate.dart index b3185bbb8..2992a711d 100644 --- a/lib/widgets/viewer/info/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/info/entry_info_action_delegate.dart @@ -3,6 +3,7 @@ import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -40,9 +41,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin { final success = await apply(); if (success) { if (_isMainMode(context) && source != null) { - await source.refreshMetadata({entry}); + await source.refreshEntry(entry); } else { - await entry.refresh(persist: false); + await entry.refresh(background: false, persist: false, force: true, geocoderLocale: settings.appliedLocale); } showFeedback(context, l10n.genericSuccessFeedback); } else { diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index 670d2f6d2..f830ccdbb 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -86,6 +86,7 @@ class _LocationSectionState extends State { if (widget.showTitle) const SectionRow(icon: AIcons.location), MapTheme( interactive: false, + showCoordinateFilter: false, navigationButton: MapNavigationButton.map, visualDensity: VisualDensity.compact, mapHeight: 200, @@ -125,8 +126,8 @@ class _LocationSectionState extends State { MaterialPageRoute( settings: const RouteSettings(name: MapPage.routeName), builder: (context) => MapPage( - collection: CollectionLens( - source: baseCollection.source, + collection: baseCollection.copyWith( + listenToSource: true, fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(), ), initialEntry: entry, @@ -157,7 +158,7 @@ class _AddressInfoGroupState extends State<_AddressInfoGroup> { super.initState(); _addressLineLoader = availability.canLocatePlaces.then((connected) { if (connected) { - return entry.findAddressLine(); + return entry.findAddressLine(geocoderLocale: settings.appliedLocale); } return null; }); diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index 72a1c0fa8..5a9702eb7 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -40,9 +40,9 @@ class _MetadataSectionSliverState extends State { ValueNotifier> get metadataNotifier => widget.metadataNotifier; - // directory names may contain the name of their parent directory - // if so, they are separated by this character - static const parentChildSeparator = '/'; + // directory names may contain the name of their parent directory (as prefix + '/') + // directory names may contain an index (as suffix in '[]') + static final directoryNamePattern = RegExp(r'^((?.*?)/)?(?.*?)(\[(?\d+)\])?$'); @override void initState() { @@ -138,14 +138,27 @@ class _MetadataSectionSliverState extends State { var directoryName = dirKV.key as String; String? parent; - final parts = directoryName.split(parentChildSeparator); - if (parts.length > 1) { - parent = parts[0]; - directoryName = parts[1]; + int? index; + final match = directoryNamePattern.firstMatch(directoryName); + if (match != null) { + parent = match.namedGroup('parent'); + final nameMatch = match.namedGroup('name'); + if (nameMatch != null) { + directoryName = nameMatch; + } + final indexMatch = match.namedGroup('index'); + if (indexMatch != null) { + index = int.tryParse(indexMatch); + } } final rawTags = dirKV.value as Map; - return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags)); + return MetadataDirectory( + directoryName, + _toSortedTags(rawTags), + parent: parent, + index: index, + ); }).toList(); if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) { @@ -157,6 +170,9 @@ class _MetadataSectionSliverState extends State { if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) { title = '${dir.parent}/$title'; } + if (dir.index != null) { + title += ' ${dir.index}'; + } return MapEntry(title, dir); }).toList() ..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); @@ -171,7 +187,7 @@ class _MetadataSectionSliverState extends State { final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo); if (formattedMediaTags.isNotEmpty) { // overwrite generic directory found from the platform side - directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, null, _toSortedTags(formattedMediaTags))); + directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags))); } if (mediaInfo.containsKey(Keys.streams)) { @@ -210,7 +226,7 @@ class _MetadataSectionSliverState extends State { final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream); if (formattedStreamTags.isNotEmpty) { final color = stringToColor(typeText); - directories.add(MetadataDirectory(dirName, null, _toSortedTags(formattedStreamTags), color: color)); + directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color)); } } } @@ -232,7 +248,7 @@ class _MetadataSectionSliverState extends State { final names = value.whereNotNull().toSet().toList()..sort(compareAsciiUpperCase); return MapEntry(key, '$count items: ${names.join(', ')}'); }); - directories.add(MetadataDirectory('Attachments', null, _toSortedTags(rawTags))); + directories.add(MetadataDirectory('Attachments', _toSortedTags(rawTags))); } } } @@ -254,6 +270,7 @@ class MetadataDirectory { final String name; final Color? color; final String? parent; + final int? index; final SplayTreeMap allTags; final SplayTreeMap tags; @@ -265,14 +282,22 @@ class MetadataDirectory { const MetadataDirectory( this.name, - this.parent, this.allTags, { SplayTreeMap? tags, this.color, + this.parent, + this.index, }) : tags = tags ?? allTags; MetadataDirectory filterKeys(bool Function(String key) testKey) { final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key)))); - return MetadataDirectory(name, parent, tags, tags: filteredTags, color: color); + return MetadataDirectory( + name, + tags, + tags: filteredTags, + color: color, + parent: parent, + index: index, + ); } } diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 965cad733..695cd6cc0 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -4,6 +4,7 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/utils/string_utils.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_ns/crs.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/darktable.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart'; @@ -31,6 +32,8 @@ class XmpNamespace extends Equatable { switch (namespace) { case XmpBasicNamespace.ns: return XmpBasicNamespace(rawProps); + case XmpCrsNamespace.ns: + return XmpCrsNamespace(rawProps); case XmpDarktableNamespace.ns: return XmpDarktableNamespace(rawProps); case XmpExifNamespace.ns: diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart new file mode 100644 index 000000000..09884366d --- /dev/null +++ b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart @@ -0,0 +1,52 @@ +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; +import 'package:flutter/widgets.dart'; + +class XmpCrsNamespace extends XmpNamespace { + static const ns = 'crs'; + + static final cgbcPattern = RegExp(ns + r':CircularGradientBasedCorrections\[(\d+)\]/(.*)'); + static final gbcPattern = RegExp(ns + r':GradientBasedCorrections\[(\d+)\]/(.*)'); + static final pbcPattern = RegExp(ns + r':PaintBasedCorrections\[(\d+)\]/(.*)'); + static final lookPattern = RegExp(ns + r':Look/(.*)'); + + final cgbc = >{}; + final gbc = >{}; + final pbc = >{}; + final look = {}; + + XmpCrsNamespace(Map rawProps) : super(ns, rawProps); + + @override + bool extractData(XmpProp prop) { + final hasStructs = extractStruct(prop, lookPattern, look); + var hasIndexedStructs = extractIndexedStruct(prop, cgbcPattern, cgbc); + hasIndexedStructs |= extractIndexedStruct(prop, gbcPattern, gbc); + hasIndexedStructs |= extractIndexedStruct(prop, pbcPattern, pbc); + return hasStructs || hasIndexedStructs; + } + + @override + List buildFromExtractedData() => [ + if (cgbc.isNotEmpty) + XmpStructArrayCard( + title: 'Circular Gradient Based Corrections', + structByIndex: cgbc, + ), + if (gbc.isNotEmpty) + XmpStructArrayCard( + title: 'Gradient Based Corrections', + structByIndex: gbc, + ), + if (look.isNotEmpty) + XmpStructCard( + title: 'Look', + struct: look, + ), + if (pbc.isNotEmpty) + XmpStructArrayCard( + title: 'Paint Based Corrections', + structByIndex: pbc, + ), + ]; +} diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart b/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart index 1959630e2..a06d75964 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; class XmpDarktableNamespace extends XmpNamespace { static const ns = 'darktable'; - static final historyPattern = RegExp(r'darktable:history\[(\d+)\]/(.*)'); + static final historyPattern = RegExp(ns + r':history\[(\d+)\]/(.*)'); final history = >{}; diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart b/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart index d3493a7b1..da2855a2b 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart @@ -7,9 +7,6 @@ class XmpExifNamespace extends XmpNamespace { const XmpExifNamespace(Map rawProps) : super(ns, rawProps); - @override - String get displayTitle => 'Exif'; - @override String formatValue(XmpProp prop) { final v = prop.value; diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart index 906b6a893..5b7d3ad8e 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart @@ -39,9 +39,6 @@ class XmpGAudioNamespace extends XmpGoogleNamespace { @override List> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')]; - - @override - String get displayTitle => 'Google Audio'; } class XmpGDepthNamespace extends XmpGoogleNamespace { @@ -54,9 +51,6 @@ class XmpGDepthNamespace extends XmpGoogleNamespace { Tuple2('$ns:Data', '$ns:Mime'), Tuple2('$ns:Confidence', '$ns:ConfidenceMime'), ]; - - @override - String get displayTitle => 'Google Depth'; } class XmpGImageNamespace extends XmpGoogleNamespace { @@ -66,7 +60,4 @@ class XmpGImageNamespace extends XmpGoogleNamespace { @override List> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')]; - - @override - String get displayTitle => 'Google Image'; } diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart b/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart index 50ddf0385..cdfca8868 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart @@ -5,15 +5,12 @@ import 'package:flutter/material.dart'; class XmpIptcCoreNamespace extends XmpNamespace { static const ns = 'Iptc4xmpCore'; - static final creatorContactInfoPattern = RegExp(r'Iptc4xmpCore:CreatorContactInfo/(.*)'); + static final creatorContactInfoPattern = RegExp(ns + r':CreatorContactInfo/(.*)'); final creatorContactInfo = {}; XmpIptcCoreNamespace(Map rawProps) : super(ns, rawProps); - @override - String get displayTitle => 'IPTC Core'; - @override bool extractData(XmpProp prop) => extractStruct(prop, creatorContactInfoPattern, creatorContactInfo); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart b/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart index 8516dde69..fd4e6ac99 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart @@ -6,17 +6,14 @@ import 'package:flutter/widgets.dart'; class XmpMgwRegionsNamespace extends XmpNamespace { static const ns = 'mwg-rs'; - static final dimensionsPattern = RegExp(r'mwg-rs:Regions/mwg-rs:AppliedToDimensions/(.*)'); - static final regionListPattern = RegExp(r'mwg-rs:Regions/mwg-rs:RegionList\[(\d+)\]/(.*)'); + static final dimensionsPattern = RegExp(ns + r':Regions/mwg-rs:AppliedToDimensions/(.*)'); + static final regionListPattern = RegExp(ns + r':Regions/mwg-rs:RegionList\[(\d+)\]/(.*)'); final dimensions = {}; final regionList = >{}; XmpMgwRegionsNamespace(Map rawProps) : super(ns, rawProps); - @override - String get displayTitle => 'Regions'; - @override bool extractData(XmpProp prop) { final hasStructs = extractStruct(prop, dimensionsPattern, dimensions); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart index c44068e93..bb0d56cdd 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart @@ -1,14 +1,31 @@ // cf photoshop:ColorMode // cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; +import 'package:flutter/widgets.dart'; class XmpPhotoshopNamespace extends XmpNamespace { static const ns = 'photoshop'; - const XmpPhotoshopNamespace(Map rawProps) : super(ns, rawProps); + static final textLayersPattern = RegExp(ns + r':TextLayers\[(\d+)\]/(.*)'); + + final textLayers = >{}; + + XmpPhotoshopNamespace(Map rawProps) : super(ns, rawProps); @override - String get displayTitle => 'Photoshop'; + bool extractData(XmpProp prop) { + return extractIndexedStruct(prop, textLayersPattern, textLayers); + } + + @override + List buildFromExtractedData() => [ + if (textLayers.isNotEmpty) + XmpStructArrayCard( + title: 'Text Layers', + structByIndex: textLayers, + ), + ]; @override String formatValue(XmpProp prop) { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart b/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart index 91c1ba8a4..90d7e0fcc 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart @@ -5,9 +5,6 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; class XmpTiffNamespace extends XmpNamespace { static const ns = 'tiff'; - @override - String get displayTitle => 'TIFF'; - const XmpTiffNamespace(Map rawProps) : super(ns, rawProps); @override diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart index 3b89e15a4..2d41cc381 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart @@ -9,16 +9,13 @@ import 'package:flutter/material.dart'; class XmpBasicNamespace extends XmpNamespace { static const ns = 'xmp'; - static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)'); + static final thumbnailsPattern = RegExp(ns + r':Thumbnails\[(\d+)\]/(.*)'); static const thumbnailDataDisplayKey = 'Image'; final thumbnails = >{}; XmpBasicNamespace(Map rawProps) : super(ns, rawProps); - @override - String get displayTitle => 'Basic'; - @override bool extractData(XmpProp prop) => extractIndexedStruct(prop, thumbnailsPattern, thumbnails); @@ -51,10 +48,10 @@ class XmpMMNamespace extends XmpNamespace { static const didPrefix = 'xmp.did:'; static const iidPrefix = 'xmp.iid:'; - static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)'); - static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)'); - static final ingredientsPattern = RegExp(r'xmpMM:Ingredients\[(\d+)\]/(.*)'); - static final pantryPattern = RegExp(r'xmpMM:Pantry\[(\d+)\]/(.*)'); + static final derivedFromPattern = RegExp(ns + r':DerivedFrom/(.*)'); + static final historyPattern = RegExp(ns + r':History\[(\d+)\]/(.*)'); + static final ingredientsPattern = RegExp(ns + r':Ingredients\[(\d+)\]/(.*)'); + static final pantryPattern = RegExp(ns + r':Pantry\[(\d+)\]/(.*)'); final derivedFrom = {}; final history = >{}; @@ -63,9 +60,6 @@ class XmpMMNamespace extends XmpNamespace { XmpMMNamespace(Map rawProps) : super(ns, rawProps); - @override - String get displayTitle => 'Media Management'; - @override bool extractData(XmpProp prop) { final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom); diff --git a/lib/widgets/viewer/info/metadata/xmp_structs.dart b/lib/widgets/viewer/info/metadata/xmp_structs.dart index c478328ac..9b81e4723 100644 --- a/lib/widgets/viewer/info/metadata/xmp_structs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_structs.dart @@ -59,11 +59,11 @@ class _XmpStructArrayCardState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Flexible( + Expanded( child: HighlightTitle( title: '${widget.title} ${_index + 1}', - color: Colors.transparent, selectable: true, + showHighlight: false, ), ), IconButton( @@ -128,8 +128,8 @@ class XmpStructCard extends StatelessWidget { children: [ HighlightTitle( title: title, - color: Colors.transparent, selectable: true, + showHighlight: false, ), InfoRowGroup( info: struct, diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 921cd7e95..82c264bf0 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -11,6 +11,7 @@ import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/overlay/bottom/multipage.dart'; @@ -77,7 +78,8 @@ class _ViewerBottomOverlayState extends State { @override Widget build(BuildContext context) { - final hasEdgeContent = settings.showOverlayInfo || multiPageController != null; + final showOverlayInfo = settings.showOverlayInfo; + final hasEdgeContent = showOverlayInfo || multiPageController != null; final blurred = settings.enableOverlayBlurEffect; return BlurredRect( enabled: hasEdgeContent && blurred, @@ -92,14 +94,20 @@ class _ViewerBottomOverlayState extends State { final viewPadding = widget.viewPadding ?? mqViewPadding; final availableWidth = mqWidth - viewPadding.horizontal; - return Container( - color: hasEdgeContent ? overlayBackgroundColor(blurred: blurred) : Colors.transparent, - padding: EdgeInsets.only( - left: max(viewInsets.left, viewPadding.left), - top: 0, - right: max(viewInsets.right, viewPadding.right), - bottom: max(viewInsets.bottom, viewPadding.bottom), - ), + return Selector( + selector: (context, mq) => max(mq.effectiveBottomPadding, showOverlayInfo ? 0 : mq.systemGestureInsets.bottom), + builder: (context, mqPaddingBottom, child) { + return Container( + color: hasEdgeContent ? overlayBackgroundColor(blurred: blurred) : Colors.transparent, + padding: EdgeInsets.only( + left: max(viewInsets.left, viewPadding.left), + top: 0, + right: max(viewInsets.right, viewPadding.right), + bottom: mqPaddingBottom, + ), + child: child, + ); + }, child: FutureBuilder( future: _detailLoader, builder: (context, snapshot) { diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 5c606a422..03c055fca 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; @@ -74,7 +74,7 @@ class _VideoControlOverlayState extends State with SingleTi scale: scale, child: IconButton( icon: const Icon(AIcons.openOutside), - onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), + onPressed: () => androidAppService.open(entry.uri, entry.mimeTypeAnySubtype), tooltip: context.l10n.viewerOpenTooltip, ), ), @@ -119,6 +119,7 @@ class _VideoControlOverlayState extends State with SingleTi Widget _buildProgressBar() { const progressBarBorderRadius = 123.0; final blurred = settings.enableOverlayBlurEffect; + const textStyle = TextStyle(shadows: Constants.embossShadows); return SizeTransition( sizeFactor: scale, child: BlurredRRect( @@ -138,50 +139,62 @@ class _VideoControlOverlayState extends State with SingleTi onHorizontalDragEnd: (details) { if (_playingOnDragStart) controller!.play(); }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16) + const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: overlayBackgroundColor(blurred: blurred), - border: AvesBorder.border, - borderRadius: const BorderRadius.all(Radius.circular(progressBarBorderRadius)), + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: kMinInteractiveDimension, ), - child: Column( - key: _progressBarKey, - children: [ - Row( - children: [ - StreamBuilder( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + decoration: BoxDecoration( + color: overlayBackgroundColor(blurred: blurred), + border: AvesBorder.border, + borderRadius: const BorderRadius.all(Radius.circular(progressBarBorderRadius)), + ), + child: Column( + key: _progressBarKey, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + StreamBuilder( + stream: positionStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + final position = controller?.currentPosition.floor() ?? 0; + return Text( + formatFriendlyDuration(Duration(milliseconds: position)), + style: textStyle, + ); + }), + const Spacer(), + Text( + entry.durationText, + style: textStyle, + ), + ], + ), + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: StreamBuilder( stream: positionStream, builder: (context, snapshot) { // do not use stream snapshot because it is obsolete when switching between videos - final position = controller?.currentPosition.floor() ?? 0; - return Text( - formatFriendlyDuration(Duration(milliseconds: position)), - style: const TextStyle(shadows: Constants.embossShadows), + var progress = controller?.progress ?? 0.0; + if (!progress.isFinite) progress = 0.0; + return LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.shade700, ); }), - const Spacer(), - Text( - entry.durationText, - style: const TextStyle(shadows: Constants.embossShadows), - ), - ], - ), - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(4)), - child: StreamBuilder( - stream: positionStream, - builder: (context, snapshot) { - // do not use stream snapshot because it is obsolete when switching between videos - var progress = controller?.progress ?? 0.0; - if (!progress.isFinite) progress = 0.0; - return LinearProgressIndicator( - value: progress, - backgroundColor: Colors.grey.shade700, - ); - }), - ), - ], + ), + const Text( + // fake text below to match the height of the text above and center the whole thing + '', + style: textStyle, + ), + ], + ), ), ), ), diff --git a/lib/widgets/viewer/panorama_page.dart b/lib/widgets/viewer/panorama_page.dart index b2e0e4653..9e13e9965 100644 --- a/lib/widgets/viewer/panorama_page.dart +++ b/lib/widgets/viewer/panorama_page.dart @@ -1,9 +1,12 @@ +import 'dart:math'; + import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/model/panorama.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/foundation.dart'; @@ -70,7 +73,7 @@ class _PanoramaPageState extends State { croppedFullWidth: info.hasCroppedArea ? info.fullPanoSize!.width : 1.0, croppedFullHeight: info.hasCroppedArea ? info.fullPanoSize!.height : 1.0, onTap: (longitude, latitude, tilt) => _overlayVisible.value = !_overlayVisible.value, - child: child as Image?, + child: child as Image, ); }, child: Image( @@ -78,8 +81,8 @@ class _PanoramaPageState extends State { ), ), Positioned( - bottom: 0, right: 0, + bottom: 0, child: TooltipTheme( data: TooltipTheme.of(context).copyWith( preferBelow: false, @@ -89,24 +92,29 @@ class _PanoramaPageState extends State { builder: (context, overlayVisible, child) { return Visibility( visible: overlayVisible, - child: Selector( - selector: (context, mq) => mq.viewPadding + mq.viewInsets, - builder: (context, mqPadding, child) { - return Padding( - padding: const EdgeInsets.all(8) + EdgeInsets.only(right: mqPadding.right, bottom: mqPadding.bottom), - child: OverlayButton( - child: ValueListenableBuilder( - valueListenable: _sensorControl, - builder: (context, sensorControl, child) { - return IconButton( - icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControl : AIcons.sensorControlOff), - onPressed: _toggleSensor, - tooltip: sensorControl == SensorControl.None ? context.l10n.panoramaEnableSensorControl : context.l10n.panoramaDisableSensorControl, - ); - }), + child: Selector( + selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), + builder: (context, mqPaddingBottom, child) { + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom), + child: child, ), ); }, + child: OverlayButton( + child: ValueListenableBuilder( + valueListenable: _sensorControl, + builder: (context, sensorControl, child) { + return IconButton( + icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControl : AIcons.sensorControlOff), + onPressed: _toggleSensor, + tooltip: sensorControl == SensorControl.None ? context.l10n.panoramaEnableSensorControl : context.l10n.panoramaDisableSensorControl, + ); + }, + ), + ), ), ); }, diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index ee8776ca6..6801b545c 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -16,7 +16,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class IjkPlayerAvesVideoController extends AvesVideoController { - static bool _staticInitialized = false; late FijkPlayer _instance; final List _subscriptions = []; final StreamController _valueStreamController = StreamController.broadcast(); @@ -32,7 +31,8 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override final double minSpeed = .5; - // android.media.AudioTrack fails with speed > 2 + // ijkplayer configures `AudioTrack` buffer for a maximum speed of 2 + // but `SoundTouch` can go higher @override final double maxSpeed = 2; @@ -55,10 +55,6 @@ class IjkPlayerAvesVideoController extends AvesVideoController { static const gifLikeBitRateThreshold = 2 << 18; // 512kB/s (4Mb/s) IjkPlayerAvesVideoController(AvesEntry entry) : super(entry) { - if (!_staticInitialized) { - FijkLog.setLevel(FijkLogLevel.Warn); - _staticInitialized = true; - } _instance = FijkPlayer(); _valueStream.map((value) => value.videoRenderStart).firstWhere((v) => v, orElse: () => false).then( (started) => canCaptureFrameNotifier.value = started, @@ -94,6 +90,13 @@ class IjkPlayerAvesVideoController extends AvesVideoController { } Future _init({int startMillis = 0}) async { + if (isReady) { + _stopListening(); + await _instance.release(); + _instance = FijkPlayer(); + _startListening(); + } + sarNotifier.value = 1; _applyOptions(startMillis); @@ -178,8 +181,11 @@ class IjkPlayerAvesVideoController extends AvesVideoController { // `soundtouch`: enable SoundTouch // default: 0, in [0, 1] - // slowed down videos with SoundTouch enabled have a weird wobbly audio - options.setPlayerOption('soundtouch', 0); + // `SoundTouch` cannot be enabled/disabled after video is `prepared` + // When `SoundTouch` is enabled: + // - slowed down videos have a weird wobbly audio + // - we can set speeds higher than the `AudioTrack` limit of 2 + options.setPlayerOption('soundtouch', _needSoundTouch(speed) ? 1 : 0); // `subtitle`: decode subtitle stream // default: 0, in [0, 1] @@ -325,10 +331,18 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override set speed(double speed) { if (speed <= 0 || _speed == speed) return; + final optionChange = _needSoundTouch(speed) != _needSoundTouch(_speed); _speed = speed; - _applySpeed(); + + if (optionChange) { + _init(startMillis: currentPosition); + } else { + _applySpeed(); + } } + bool _needSoundTouch(double speed) => speed > 1; + // TODO TLAD [video] bug: setting speed fails when there is no audio stream or audio is disabled void _applySpeed() => _instance.setSpeed(speed); diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart index 96ac37e7f..6f87bef12 100644 --- a/lib/widgets/viewer/video_action_delegate.dart +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; @@ -91,6 +92,7 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix exif: exif, bytes: bytes, destinationAlbum: destinationAlbum, + nameConflictStrategy: NameConflictStrategy.rename, ); final success = newFields.isNotEmpty; diff --git a/lib/widgets/viewer/visual/vector.dart b/lib/widgets/viewer/visual/vector.dart index 0fcda1180..a2ae34d07 100644 --- a/lib/widgets/viewer/visual/vector.dart +++ b/lib/widgets/viewer/visual/vector.dart @@ -101,6 +101,8 @@ class _VectorImageViewState extends State { @override Widget build(BuildContext context) { + if (_displaySize == Size.zero) return widget.errorBuilder(context, 'Not sized', null); + return ValueListenableBuilder( valueListenable: viewStateNotifier, builder: (context, viewState, child) { diff --git a/pubspec.lock b/pubspec.lock index 6276941b7..21a920749 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "26.0.0" + version: "29.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "2.3.0" + version: "2.6.0" archive: dependency: transitive description: @@ -86,7 +86,7 @@ packages: name: cli_util url: "https://pub.dartlang.org" source: hosted - version: "0.3.3" + version: "0.3.5" clock: dependency: transitive description: @@ -107,7 +107,7 @@ packages: name: connectivity_plus url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "2.0.2" connectivity_plus_linux: dependency: transitive description: @@ -121,7 +121,7 @@ packages: name: connectivity_plus_macos url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.1" connectivity_plus_platform_interface: dependency: transitive description: @@ -142,7 +142,7 @@ packages: name: connectivity_plus_windows url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.0" convert: dependency: transitive description: @@ -198,7 +198,7 @@ packages: name: device_info_plus url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "3.1.0" device_info_plus_linux: dependency: transitive description: @@ -212,14 +212,14 @@ packages: name: device_info_plus_macos url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.0" device_info_plus_web: dependency: transitive description: @@ -276,7 +276,7 @@ packages: description: path: "." ref: aves - resolved-ref: "2aefcebb9f4bc08107e7de16927d91e577e10d7d" + resolved-ref: "1d10ebbdcd71a2d9970dd6cd1f3cf6315bb686e6" url: "git://github.com/deckerst/fijkplayer.git" source: git version: "0.10.0" @@ -293,7 +293,7 @@ packages: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "1.6.0" + version: "1.7.0" firebase_core_platform_interface: dependency: transitive description: @@ -314,14 +314,14 @@ packages: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "2.2.1" + version: "2.2.3" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.1.2" + version: "3.1.3" flex_color_picker: dependency: "direct main" description: @@ -385,7 +385,7 @@ packages: name: flutter_markdown url: "https://pub.dartlang.org" source: hosted - version: "0.6.6" + version: "0.6.7" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -435,14 +435,14 @@ packages: name: github url: "https://pub.dartlang.org" source: hosted - version: "8.1.1" + version: "8.2.1" glob: dependency: transitive description: name: glob url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" google_api_availability: dependency: "direct main" description: @@ -456,14 +456,14 @@ packages: name: google_maps_flutter url: "https://pub.dartlang.org" source: hosted - version: "2.0.10" + version: "2.0.11" google_maps_flutter_platform_interface: dependency: transitive description: name: google_maps_flutter_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.1.1" + version: "2.1.3" highlight: dependency: transitive description: @@ -477,7 +477,7 @@ packages: name: http url: "https://pub.dartlang.org" source: hosted - version: "0.13.3" + version: "0.13.4" http_multi_server: dependency: transitive description: @@ -498,7 +498,7 @@ packages: name: image url: "https://pub.dartlang.org" source: hosted - version: "3.0.5" + version: "3.0.8" intl: dependency: "direct main" description: @@ -526,14 +526,14 @@ packages: name: json_annotation url: "https://pub.dartlang.org" source: hosted - version: "4.1.0" + version: "4.3.0" latlong2: dependency: "direct main" description: name: latlong2 url: "https://pub.dartlang.org" source: hosted - version: "0.8.0" + version: "0.8.1" lints: dependency: transitive description: @@ -575,7 +575,7 @@ packages: name: material_design_icons_flutter url: "https://pub.dartlang.org" source: hosted - version: "5.0.5955-rc.1" + version: "5.0.6295" meta: dependency: transitive description: @@ -645,7 +645,7 @@ packages: name: package_info_plus url: "https://pub.dartlang.org" source: hosted - version: "1.0.6" + version: "1.3.0" package_info_plus_linux: dependency: transitive description: @@ -659,7 +659,7 @@ packages: name: package_info_plus_macos url: "https://pub.dartlang.org" source: hosted - version: "1.1.1" + version: "1.3.0" package_info_plus_platform_interface: dependency: transitive description: @@ -680,14 +680,14 @@ packages: name: package_info_plus_windows url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.4" palette_generator: dependency: "direct main" description: name: palette_generator url: "https://pub.dartlang.org" source: hosted - version: "0.3.1" + version: "0.3.2" panorama: dependency: "direct main" description: @@ -750,28 +750,28 @@ packages: name: percent_indicator url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.4.0" permission_handler: dependency: "direct main" description: name: permission_handler url: "https://pub.dartlang.org" source: hosted - version: "8.1.6" + version: "8.2.5" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.6.1" + version: "3.7.0" petitparser: dependency: transitive description: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "4.3.0" + version: "4.4.0" platform: dependency: transitive description: @@ -785,7 +785,7 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" pool: dependency: transitive description: @@ -799,7 +799,7 @@ packages: name: positioned_tap_detector_2 url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.0.4" printing: dependency: "direct main" description: @@ -827,7 +827,7 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "6.0.0" + version: "6.0.1" pub_semver: dependency: transitive description: @@ -848,7 +848,7 @@ packages: name: quiver url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.0.1+1" shared_preferences: dependency: "direct main" description: @@ -1072,7 +1072,7 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.0.11" + version: "6.0.12" url_launcher_linux: dependency: transitive description: @@ -1135,7 +1135,7 @@ packages: name: watcher url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.0.1" web_socket_channel: dependency: transitive description: @@ -1184,7 +1184,7 @@ packages: name: xml url: "https://pub.dartlang.org" source: hosted - version: "5.3.0" + version: "5.3.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index acaaca7da..b85ceada3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.5.3+57 +version: 1.5.4+58 publish_to: none environment: @@ -12,7 +12,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter -# TODO TLAD as of 2021/09/23, latest version (v0.11.0) is incompatible with Flutter v2.5 +# TODO TLAD as of 2021/10/18, latest version (v0.11.0) is incompatible with Flutter v2.5 charts_flutter: git: url: git://github.com/google/charts.git @@ -20,7 +20,7 @@ dependencies: collection: connectivity_plus: country_code: -# TODO TLAD as of 2021/09/23, null safe version is pre-release +# TODO TLAD as of 2021/10/18, null safe version is pre-release custom_rounded_rectangle_border: '>=0.2.0-nullsafety.0' decorated_icon: device_info_plus: @@ -47,12 +47,11 @@ dependencies: google_maps_flutter: intl: latlong2: -# TODO TLAD as of 2021/09/23, null safe version is pre-release - material_design_icons_flutter: '>=5.0.5955-rc.1' + material_design_icons_flutter: overlay_support: package_info_plus: palette_generator: -# TODO TLAD as of 2021/09/23, latest version (v0.4.1) has this issue: https://github.com/zesage/panorama/issues/25 +# TODO TLAD as of 2021/10/18, latest version (v0.4.1) has this issue: https://github.com/zesage/panorama/issues/25 panorama: 0.4.0 pdf: percent_indicator: @@ -117,7 +116,7 @@ flutter: # Test driver # run (any device): -# % flutter drive -t test_driver/driver_app.dart --profile +# % flutter drive --flavor universal -t test_driver/driver_app.dart --profile # capture shaders in profile mode (real device only): -# % flutter drive -t test_driver/driver_app.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json +# % flutter drive --flavor universal -t test_driver/driver_app.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json diff --git a/shaders_2.5.1.sksl.json b/shaders_2.5.1.sksl.json deleted file mode 100644 index 0ea1b33f8..000000000 --- a/shaders_2.5.1.sksl.json +++ /dev/null @@ -1 +0,0 @@ -{"platform":"android","name":"SM G970N","engineRevision":"b3af521a050e6ef076778bcaf16e27b2521df8f8","data":{"HQJAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAABAAAAAABBAMAEQAMAAAABAAAAAAABBAMAAA":"BwAAAExTS1PtAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdmxvY2FsQ29vcmRfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZsb2NhbENvb3JkX1N0YWdlMCA9IGxvY2FsQ29vcmQ7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAAAA2wEAAHVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7CglmbG9hdDIgdGV4Q29vcmQ7Cgl0ZXhDb29yZCA9IHZsb2NhbENvb3JkX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9ICgoc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdGV4Q29vcmQpICogaGFsZjQoMSkpKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","C4SAAAAAMAAAAABAYDRP7H2CAIAAAABAAAAAAABAMQAAAOIAAAAAAAQAAAABAMQA":"BwAAAExTS1PtAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQyIHVBdGxhc1NpemVJbnZfU3RhZ2UwOwppbiBmbG9hdDIgaW5Qb3NpdGlvbjsKaW4gdXNob3J0MiBpblRleHR1cmVDb29yZHM7Cm91dCBmbG9hdDIgdlRleHR1cmVDb29yZHNfU3RhZ2UwOwpmbGF0IG91dCBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBCaXRtYXBUZXh0CglpbnQgdGV4SWR4ID0gMDsKCWZsb2F0MiB1bm9ybVRleENvb3JkcyA9IGZsb2F0MihpblRleHR1cmVDb29yZHMueCwgaW5UZXh0dXJlQ29vcmRzLnkpOwoJdlRleHR1cmVDb29yZHNfU3RhZ2UwID0gdW5vcm1UZXhDb29yZHMgKiB1QXRsYXNTaXplSW52X1N0YWdlMDsKCXZUZXhJbmRleF9TdGFnZTAgPSBmbG9hdCh0ZXhJZHgpOwoJZmxvYXQyIF90bXBfMV9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gaW5Qb3NpdGlvbi54eTAxOwp9CgAAAAAAAAAAAAAAAAAAADACAAB1bmlmb3JtIGhhbGY0IHVDb2xvcl9TdGFnZTA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZUZXh0dXJlQ29vcmRzX1N0YWdlMDsKZmxhdCBpbiBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgQml0bWFwVGV4dAoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdUNvbG9yX1N0YWdlMDsKCWhhbGY0IHRleENvbG9yOwoJewoJCXRleENvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdlRleHR1cmVDb29yZHNfU3RhZ2UwKTsKCX0KCW91dHB1dENvbG9yX1N0YWdlMCA9IG91dHB1dENvbG9yX1N0YWdlMCAqIHRleENvbG9yOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAKAAAAaW5Qb3NpdGlvbgAADwAAAGluVGV4dHVyZUNvb3JkcwABAAAAAAAAAA==","HQJAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAABAAAAAABBAMADCAB4QAAAAAEAAAAAAHEAAAAAAAIAAAAAQGIAAAAAA":"BwAAAExTS1PtAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdmxvY2FsQ29vcmRfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZsb2NhbENvb3JkX1N0YWdlMCA9IGxvY2FsQ29vcmQ7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cn0KAAAAAQAAAAAAAAABAAAApwMAAHVuaWZvcm0gZmxvYXQ0IHVpbm5lclJlY3RfU3RhZ2UxX2MwOwp1bmlmb3JtIGhhbGYyIHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJZmxvYXQyIGR4eTAgPSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5MVCAtIHNrX0ZyYWdDb29yZC54eTsKCWZsb2F0MiBkeHkxID0gc2tfRnJhZ0Nvb3JkLnh5IC0gdWlubmVyUmVjdF9TdGFnZTFfYzAuUkI7CglmbG9hdDIgZHh5ID0gbWF4KG1heChkeHkwLCBkeHkxKSwgMC4wKTsKCWhhbGYgYWxwaGEgPSBoYWxmKHNhdHVyYXRlKHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzAueCAtIGxlbmd0aChkeHkpKSk7CglyZXR1cm4gX2lucHV0ICogYWxwaGE7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7CglmbG9hdDIgdGV4Q29vcmQ7Cgl0ZXhDb29yZCA9IHZsb2NhbENvb3JkX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9ICgoc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdGV4Q29vcmQpICogaGFsZjQoMSkpKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24KAAAAbG9jYWxDb29yZAAAAQAAAAAAAAA=","HQQACAAAAAGAAAAAAIWP57ZDH37P7777777QCAAAAAAAAAAAMIAHSAAAAAAQAAAAAA4QAAAAAABAAAAACAZAAAAAAA":"BwAAAExTS1PXAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7Cm91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZjb2xvcl9TdGFnZTAgPSBjb2xvcjsKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKfQoAAQAAAAAAAAABAAAADAMAAHVuaWZvcm0gZmxvYXQ0IHVpbm5lclJlY3RfU3RhZ2UxX2MwOwp1bmlmb3JtIGhhbGYyIHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzA7CmluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIF9pbnB1dCAqIGFscGhhOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24FAAAAY29sb3IAAAABAAAAAAAAAA==","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBACAIBAAAAAMYECAZAAEAQAAAAAAEQAMAAAABAAAAAAABBAMAAAAA":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADJAwAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMDsKdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxOwppbiBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBUZXh0dXJlRWZmZWN0X1N0YWdlMV9jMF9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdDIgaW5Db29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZCA9IGNsYW1wKHN1YnNldENvb3JkLCB1Y2xhbXBfU3RhZ2UxX2MwX2MwLnh5LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwLnp3KTsKCWhhbGY0IHRleHR1cmVDb2xvciA9IHNhbXBsZSh1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTEsIGNsYW1wZWRDb29yZCk7CglyZXR1cm4gdGV4dHVyZUNvbG9yOwp9CmhhbGY0IE1hdHJpeEVmZmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJcmV0dXJuIFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwKF9pbnB1dCk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gTWF0cml4RWZmZWN0X1N0YWdlMV9jMChvdXRwdXRDb2xvcl9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dF9TdGFnZTEgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAAAAHEAAAAAAAIAAAAAQGIAAAAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAIcCAABmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgR3JGaWxsUlJlY3RPcDo6UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJZmxvYXQgeF9wbHVzXzE9dmFyY2Nvb3JkX1N0YWdlMC54LCB5PXZhcmNjb29yZF9TdGFnZTAueTsKCWhhbGYgY292ZXJhZ2U7CglpZiAoMCA9PSB4X3BsdXNfMSkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKHkpOwoJfQoJZWxzZSAKCXsKCQlmbG9hdCBmbiA9IHhfcGx1c18xICogKHhfcGx1c18xIC0gMik7CgkJZm4gPSBmbWEoeSx5LCBmbik7CgkJZmxvYXQgZm53aWR0aCA9IGZ3aWR0aChmbik7CgkJY292ZXJhZ2UgPSAuNSAtIGhhbGYoZm4vZm53aWR0aCk7CgkJY292ZXJhZ2UgPSBjbGFtcChjb3ZlcmFnZSwgMCwgMSk7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChjb3ZlcmFnZSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAIAAAADgAAAHJhZGlpX3NlbGVjdG9yAAAZAAAAY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cwAAABUAAABhYV9ibG9hdF9hbmRfY292ZXJhZ2UAAAAEAAAAc2tldwkAAAB0cmFuc2xhdGUAAAAHAAAAcmFkaWlfeAAHAAAAcmFkaWlfeQAFAAAAY29sb3IAAAABAAAAAAAAAA==","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBASAAAAAAACAIAAAACRFAAAAAAAEAQAAAABIECAAAQCAAAAAZQIEBSAAABAAAAAAAJAAYAAAACAAAAAAACCAY":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADpBgAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMF9jMF9jMDsKdW5pZm9ybSBoYWxmMiB1XzBfSW5jcmVtZW50X1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBoYWxmNCB1XzFfS2VybmVsX1N0YWdlMV9jMF9jMFszXTsKdW5pZm9ybSBoYWxmNCB1XzJfT2Zmc2V0c19TdGFnZTFfYzBfYzBbM107CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMTsKaW4gZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoaGFsZjQgX2lucHV0LCBmbG9hdDIgX2Nvb3JkcykgCnsKCWZsb2F0MiBpbkNvb3JkID0gX2Nvb3JkczsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZC54ID0gc3Vic2V0Q29vcmQueDsKCWNsYW1wZWRDb29yZC55ID0gY2xhbXAoc3Vic2V0Q29vcmQueSwgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMC55LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwX2MwX2MwLncpOwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMF9jMChoYWxmNCBfaW5wdXQsIGZsb2F0MiBfY29vcmRzKSAKewoJcmV0dXJuIFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwX2MwKF9pbnB1dCwgZmxvYXQzeDIodW1hdHJpeF9TdGFnZTFfYzBfYzBfYzApICogX2Nvb3Jkcy54eTEpOwp9CmhhbGY0IEdhdXNzaWFuQ29udm9sdXRpb25fU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF8zX2NvbG9yID0gaGFsZjQoMC4wKTsKCWZsb2F0MiBfNF9jb29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZvciAoaW50IF81X2kgPSAwOyAoXzVfaSA8IDEwKTsgXzVfaSsrKSAoXzNfY29sb3IgKz0gKE1hdHJpeEVmZmVjdF9TdGFnZTFfYzBfYzBfYzAoX2lucHV0LCAoXzRfY29vcmQgKyBmbG9hdDIoKHVfMl9PZmZzZXRzX1N0YWdlMV9jMF9jMFsoXzVfaSAvIDQpXVsoXzVfaSAmIDMpXSAqIHVfMF9JbmNyZW1lbnRfU3RhZ2UxX2MwX2MwKSkpKSAqIHVfMV9LZXJuZWxfU3RhZ2UxX2MwX2MwWyhfNV9pIC8gNCldWyhfNV9pICYgMyldKSk7CglyZXR1cm4gXzNfY29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gR2F1c3NpYW5Db252b2x1dGlvbl9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0X1N0YWdlMSAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACAAAAHBvc2l0aW9uCgAAAGxvY2FsQ29vcmQAAAEAAAAAAAAA","C4QAAAAAMAAAAABAYAROFA2CAIAAAABAAAAAAAAAAAACABUQA4AAAEAAAAAABEADAAAAAIAAAAAAAIIDAAAA":"BwAAAExTS1M2AgAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQyIHVBdGxhc1NpemVJbnZfU3RhZ2UwOwppbiBmbG9hdDIgaW5Qb3NpdGlvbjsKaW4gaGFsZjQgaW5Db2xvcjsKaW4gdXNob3J0MiBpblRleHR1cmVDb29yZHM7Cm91dCBmbG9hdDIgdlRleHR1cmVDb29yZHNfU3RhZ2UwOwpmbGF0IG91dCBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBCaXRtYXBUZXh0CglpbnQgdGV4SWR4ID0gMDsKCWZsb2F0MiB1bm9ybVRleENvb3JkcyA9IGZsb2F0MihpblRleHR1cmVDb29yZHMueCwgaW5UZXh0dXJlQ29vcmRzLnkpOwoJdlRleHR1cmVDb29yZHNfU3RhZ2UwID0gdW5vcm1UZXhDb29yZHMgKiB1QXRsYXNTaXplSW52X1N0YWdlMDsKCXZUZXhJbmRleF9TdGFnZTAgPSBmbG9hdCh0ZXhJZHgpOwoJdmluQ29sb3JfU3RhZ2UwID0gaW5Db2xvcjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IGluUG9zaXRpb24ueHkwMTsKfQoAAAEAAAAAAAAAAQAAAMUDAAB1bmlmb3JtIGZsb2F0NCB1aW5uZXJSZWN0X1N0YWdlMV9jMDsKdW5pZm9ybSBoYWxmMiB1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwOwp1bmlmb3JtIHNhbXBsZXIyRCB1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTA7CmluIGZsb2F0MiB2VGV4dHVyZUNvb3Jkc19TdGFnZTA7CmZsYXQgaW4gZmxvYXQgdlRleEluZGV4X1N0YWdlMDsKaW4gaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdDIgZHh5MCA9IHVpbm5lclJlY3RfU3RhZ2UxX2MwLkxUIC0gc2tfRnJhZ0Nvb3JkLnh5OwoJZmxvYXQyIGR4eTEgPSBza19GcmFnQ29vcmQueHkgLSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5SQjsKCWZsb2F0MiBkeHkgPSBtYXgobWF4KGR4eTAsIGR4eTEpLCAwLjApOwoJaGFsZiBhbHBoYSA9IGhhbGYoc2F0dXJhdGUodXJhZGl1c1BsdXNIYWxmX1N0YWdlMV9jMC54IC0gbGVuZ3RoKGR4eSkpKTsKCXJldHVybiBfaW5wdXQgKiBhbHBoYTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgQml0bWFwVGV4dAoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJaGFsZjQgdGV4Q29sb3I7Cgl7CgkJdGV4Q29sb3IgPSBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwLCB2VGV4dHVyZUNvb3Jkc19TdGFnZTApLnJycnI7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSB0ZXhDb2xvcjsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAMAAAAKAAAAaW5Qb3NpdGlvbgAABwAAAGluQ29sb3IADwAAAGluVGV4dHVyZUNvb3JkcwABAAAAAAAAAA==","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAABZAAAAAAACAAAAAEBSAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAmAgAAaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBAYAAAAAAAAAIAAAACRRAAAAAAAAAQAAAABIECAAAACAAAAAZQIEBSAAAAAAAAAAAJAAYAAAACAAAAAAACCAY":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAACVBQAAdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMF9jMF9jMDsKdW5pZm9ybSBoYWxmMiB1XzBfSW5jcmVtZW50X1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBoYWxmNCB1XzFfS2VybmVsX1N0YWdlMV9jMF9jMFs0XTsKdW5pZm9ybSBoYWxmNCB1XzJfT2Zmc2V0c19TdGFnZTFfYzBfYzBbNF07CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMTsKaW4gZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoaGFsZjQgX2lucHV0LCBmbG9hdDIgX2Nvb3JkcykgCnsKCXJldHVybiBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxLCBfY29vcmRzKTsKfQpoYWxmNCBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwKGhhbGY0IF9pbnB1dCwgZmxvYXQyIF9jb29yZHMpIAp7CglyZXR1cm4gVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoX2lucHV0LCBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMF9jMF9jMCkgKiBfY29vcmRzLnh5MSk7Cn0KaGFsZjQgR2F1c3NpYW5Db252b2x1dGlvbl9TdGFnZTFfYzBfYzAoaGFsZjQgX2lucHV0KSAKewoJaGFsZjQgXzNfY29sb3IgPSBoYWxmNCgwLjApOwoJZmxvYXQyIF80X2Nvb3JkID0gdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwoJZm9yIChpbnQgXzVfaSA9IDA7IChfNV9pIDwgMTMpOyBfNV9pKyspIChfM19jb2xvciArPSAoTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMF9jMChfaW5wdXQsIChfNF9jb29yZCArIGZsb2F0MigodV8yX09mZnNldHNfU3RhZ2UxX2MwX2MwWyhfNV9pIC8gNCldWyhfNV9pICYgMyldICogdV8wX0luY3JlbWVudF9TdGFnZTFfYzBfYzApKSkpICogdV8xX0tlcm5lbF9TdGFnZTFfYzBfYzBbKF81X2kgLyA0KV1bKF81X2kgJiAzKV0pKTsKCXJldHVybiBfM19jb2xvcjsKfQpoYWxmNCBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCXJldHVybiBHYXVzc2lhbkNvbnZvbHV0aW9uX1N0YWdlMV9jMF9jMChfaW5wdXQpOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMCA9IGhhbGY0KDEpOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IE1hdHJpeEVmZmVjdF9TdGFnZTFfYzAob3V0cHV0Q29sb3JfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRfU3RhZ2UxICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24KAAAAbG9jYWxDb29yZAAAAQAAAAAAAAA=","C4QAAAAAMAAAAABAYAROFA2CAIAAAABAAAAAAAAAAAAAAOIAAAAAAAQAAAABAMQA":"BwAAAExTS1M2AgAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQyIHVBdGxhc1NpemVJbnZfU3RhZ2UwOwppbiBmbG9hdDIgaW5Qb3NpdGlvbjsKaW4gaGFsZjQgaW5Db2xvcjsKaW4gdXNob3J0MiBpblRleHR1cmVDb29yZHM7Cm91dCBmbG9hdDIgdlRleHR1cmVDb29yZHNfU3RhZ2UwOwpmbGF0IG91dCBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBCaXRtYXBUZXh0CglpbnQgdGV4SWR4ID0gMDsKCWZsb2F0MiB1bm9ybVRleENvb3JkcyA9IGZsb2F0MihpblRleHR1cmVDb29yZHMueCwgaW5UZXh0dXJlQ29vcmRzLnkpOwoJdlRleHR1cmVDb29yZHNfU3RhZ2UwID0gdW5vcm1UZXhDb29yZHMgKiB1QXRsYXNTaXplSW52X1N0YWdlMDsKCXZUZXhJbmRleF9TdGFnZTAgPSBmbG9hdCh0ZXhJZHgpOwoJdmluQ29sb3JfU3RhZ2UwID0gaW5Db2xvcjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IGluUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAAPkBAAB1bmlmb3JtIHNhbXBsZXIyRCB1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTA7CmluIGZsb2F0MiB2VGV4dHVyZUNvb3Jkc19TdGFnZTA7CmZsYXQgaW4gZmxvYXQgdlRleEluZGV4X1N0YWdlMDsKaW4gaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgQml0bWFwVGV4dAoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJaGFsZjQgdGV4Q29sb3I7Cgl7CgkJdGV4Q29sb3IgPSBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwLCB2VGV4dHVyZUNvb3Jkc19TdGFnZTApLnJycnI7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSB0ZXhDb2xvcjsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAPAAAAaW5UZXh0dXJlQ29vcmRzAAEAAAAAAAAA","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBAYAAAAAAACAIAAAACRRAAAAAAAEAQAAAABIECAAAQCAAAAAZQIEBSAAABAAAAAAAJAAYAAAACAAAAAAACCAY":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADpBgAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMF9jMF9jMDsKdW5pZm9ybSBoYWxmMiB1XzBfSW5jcmVtZW50X1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBoYWxmNCB1XzFfS2VybmVsX1N0YWdlMV9jMF9jMFs0XTsKdW5pZm9ybSBoYWxmNCB1XzJfT2Zmc2V0c19TdGFnZTFfYzBfYzBbNF07CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMTsKaW4gZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoaGFsZjQgX2lucHV0LCBmbG9hdDIgX2Nvb3JkcykgCnsKCWZsb2F0MiBpbkNvb3JkID0gX2Nvb3JkczsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZC54ID0gc3Vic2V0Q29vcmQueDsKCWNsYW1wZWRDb29yZC55ID0gY2xhbXAoc3Vic2V0Q29vcmQueSwgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMC55LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwX2MwX2MwLncpOwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMF9jMChoYWxmNCBfaW5wdXQsIGZsb2F0MiBfY29vcmRzKSAKewoJcmV0dXJuIFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwX2MwKF9pbnB1dCwgZmxvYXQzeDIodW1hdHJpeF9TdGFnZTFfYzBfYzBfYzApICogX2Nvb3Jkcy54eTEpOwp9CmhhbGY0IEdhdXNzaWFuQ29udm9sdXRpb25fU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF8zX2NvbG9yID0gaGFsZjQoMC4wKTsKCWZsb2F0MiBfNF9jb29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZvciAoaW50IF81X2kgPSAwOyAoXzVfaSA8IDEzKTsgXzVfaSsrKSAoXzNfY29sb3IgKz0gKE1hdHJpeEVmZmVjdF9TdGFnZTFfYzBfYzBfYzAoX2lucHV0LCAoXzRfY29vcmQgKyBmbG9hdDIoKHVfMl9PZmZzZXRzX1N0YWdlMV9jMF9jMFsoXzVfaSAvIDQpXVsoXzVfaSAmIDMpXSAqIHVfMF9JbmNyZW1lbnRfU3RhZ2UxX2MwX2MwKSkpKSAqIHVfMV9LZXJuZWxfU3RhZ2UxX2MwX2MwWyhfNV9pIC8gNCldWyhfNV9pICYgMyldKSk7CglyZXR1cm4gXzNfY29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gR2F1c3NpYW5Db252b2x1dGlvbl9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0X1N0YWdlMSAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACAAAAHBvc2l0aW9uCgAAAGxvY2FsQ29vcmQAAAEAAAAAAAAA","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAGEQMJHBTJAAQAADQAAAAAAAAAAAEADQAAAAIAAAAAAAIIDAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAAK0FAABjb25zdCBpbnQga0ZpbGxBQV9TdGFnZTFfYzAgPSAxOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwID0gMjsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCA9IDM7CnVuaWZvcm0gZmxvYXQ0IHVjaXJjbGVfU3RhZ2UxX2MwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBDaXJjbGVfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF90bXBfMF9pbkNvbG9yID0gX2lucHV0OwoJaGFsZiBkOwoJaWYgKGludCgzKSA9PSBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDMpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJZCA9IGhhbGYoKGxlbmd0aCgodWNpcmNsZV9TdGFnZTFfYzAueHkgLSBza19GcmFnQ29vcmQueHkpICogdWNpcmNsZV9TdGFnZTFfYzAudykgLSAxLjApICogdWNpcmNsZV9TdGFnZTFfYzAueik7Cgl9CgllbHNlIAoJewoJCWQgPSBoYWxmKCgxLjAgLSBsZW5ndGgoKHVjaXJjbGVfU3RhZ2UxX2MwLnh5IC0gc2tfRnJhZ0Nvb3JkLnh5KSAqIHVjaXJjbGVfU3RhZ2UxX2MwLncpKSAqIHVjaXJjbGVfU3RhZ2UxX2MwLnopOwoJfQoJaWYgKGludCgzKSA9PSBrRmlsbEFBX1N0YWdlMV9jMCB8fCBpbnQoMykgPT0ga0ludmVyc2VGaWxsQUFfU3RhZ2UxX2MwKSAKCXsKCQlyZXR1cm4gaGFsZjQoX2lucHV0ICogc2F0dXJhdGUoZCkpOwoJfQoJZWxzZSAKCXsKCQlyZXR1cm4gaGFsZjQoZCA+IDAuNSA/IF9pbnB1dCA6IGhhbGY0KDAuMCkpOwoJfQp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBHckZpbGxSUmVjdE9wOjpQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7CglmbG9hdCB4X3BsdXNfMT12YXJjY29vcmRfU3RhZ2UwLngsIHk9dmFyY2Nvb3JkX1N0YWdlMC55OwoJaGFsZiBjb3ZlcmFnZTsKCWlmICgwID09IHhfcGx1c18xKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoeSk7Cgl9CgllbHNlIAoJewoJCWZsb2F0IGZuID0geF9wbHVzXzEgKiAoeF9wbHVzXzEgLSAyKTsKCQlmbiA9IGZtYSh5LHksIGZuKTsKCQlmbG9hdCBmbndpZHRoID0gZndpZHRoKGZuKTsKCQljb3ZlcmFnZSA9IC41IC0gaGFsZihmbi9mbndpZHRoKTsKCQljb3ZlcmFnZSA9IGNsYW1wKGNvdmVyYWdlLCAwLCAxKTsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGNvdmVyYWdlKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY2xlX1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAAAAAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAACAAAAA4AAAByYWRpaV9zZWxlY3RvcgAAGQAAAGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHMAAAAVAAAAYWFfYmxvYXRfYW5kX2NvdmVyYWdlAAAABAAAAHNrZXcJAAAAdHJhbnNsYXRlAAAABwAAAHJhZGlpX3gABwAAAHJhZGlpX3kABQAAAGNvbG9yAAAAAQAAAAAAAAA=","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAAYIAMAAACAAAAAAASABQAAAAEAAAAAAAEEBQ":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAALUEAAB1bmlmb3JtIGZsb2F0NCB1aW5uZXJSZWN0X1N0YWdlMV9jMDsKdW5pZm9ybSBoYWxmMiB1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdCBkeDAgPSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5MIC0gc2tfRnJhZ0Nvb3JkLng7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChmbG9hdDIobWF4KGR4MCwgZHh5MS54KSwgZHh5MS55KSwgMC4wKTsKCWhhbGYgdG9wQWxwaGEgPSBoYWxmKHNhdHVyYXRlKHNrX0ZyYWdDb29yZC55IC0gdWlubmVyUmVjdF9TdGFnZTFfYzAuVCkpOwoJaGFsZiBhbHBoYSA9IHRvcEFscGhhICogaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIF9pbnB1dCAqIGFscGhhOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBHckZpbGxSUmVjdE9wOjpQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7CglmbG9hdCB4X3BsdXNfMT12YXJjY29vcmRfU3RhZ2UwLngsIHk9dmFyY2Nvb3JkX1N0YWdlMC55OwoJaGFsZiBjb3ZlcmFnZTsKCWlmICgwID09IHhfcGx1c18xKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoeSk7Cgl9CgllbHNlIAoJewoJCWZsb2F0IGZuID0geF9wbHVzXzEgKiAoeF9wbHVzXzEgLSAyKTsKCQlmbiA9IGZtYSh5LHksIGZuKTsKCQlmbG9hdCBmbndpZHRoID0gZndpZHRoKGZuKTsKCQljb3ZlcmFnZSA9IC41IC0gaGFsZihmbi9mbndpZHRoKTsKCQljb3ZlcmFnZSA9IGNsYW1wKGNvdmVyYWdlLCAwLCAxKTsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGNvdmVyYWdlKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAgAAAAOAAAAcmFkaWlfc2VsZWN0b3IAABkAAABjb3JuZXJfYW5kX3JhZGl1c19vdXRzZXRzAAAAFQAAAGFhX2Jsb2F0X2FuZF9jb3ZlcmFnZQAAAAQAAABza2V3CQAAAHRyYW5zbGF0ZQAAAAcAAAByYWRpaV94AAcAAAByYWRpaV95AAUAAABjb2xvcgAAAAEAAAAAAAAA","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIBRAA5ZQUCGQFAAAMAAIAAAAAAAAAAA4QAAAAAABAAAAACAZAAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAACnBQAAY29uc3QgaW50IGtGaWxsQldfU3RhZ2UxX2MwID0gMDsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCA9IDI7CmNvbnN0IGludCBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzAgPSAzOwp1bmlmb3JtIGZsb2F0NCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwOwppbiBmbG9hdDQgdmluQ2lyY2xlRWRnZV9TdGFnZTA7CmluIGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJaGFsZjQgX3RtcF8wX2luQ29sb3IgPSBfaW5wdXQ7CgloYWxmIGNvdmVyYWdlOwoJaWYgKGludCgzKSA9PSBrRmlsbEJXX1N0YWdlMV9jMCB8fCBpbnQoMykgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoYWxsKGdyZWF0ZXJUaGFuKGZsb2F0NChza19GcmFnQ29vcmQueHksIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAuencpLCBmbG9hdDQodXJlY3RVbmlmb3JtX1N0YWdlMV9jMC54eSwgc2tfRnJhZ0Nvb3JkLnh5KSkpID8gMSA6IDApOwoJfQoJZWxzZSAKCXsKCQloYWxmNCBkaXN0czQgPSBjbGFtcChoYWxmNCgxLjAsIDEuMCwgLTEuMCwgLTEuMCkgKiBoYWxmNChza19GcmFnQ29vcmQueHl4eSAtIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzApLCAwLjAsIDEuMCk7CgkJaGFsZjIgZGlzdHMyID0gKGRpc3RzNC54eSArIGRpc3RzNC56dykgLSAxLjA7CgkJY292ZXJhZ2UgPSBkaXN0czIueCAqIGRpc3RzMi55OwoJfQoJaWYgKGludCgzKSA9PSBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDMpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSAxLjAgLSBjb3ZlcmFnZTsKCX0KCXJldHVybiBoYWxmNChfaW5wdXQgKiBjb3ZlcmFnZSk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIENpcmNsZUdlb21ldHJ5UHJvY2Vzc29yCglmbG9hdDQgY2lyY2xlRWRnZTsKCWNpcmNsZUVkZ2UgPSB2aW5DaXJjbGVFZGdlX1N0YWdlMDsKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZpbkNvbG9yX1N0YWdlMDsKCWZsb2F0IGQgPSBsZW5ndGgoY2lyY2xlRWRnZS54eSk7CgloYWxmIGRpc3RhbmNlVG9PdXRlckVkZ2UgPSBoYWxmKGNpcmNsZUVkZ2UueiAqICgxLjAgLSBkKSk7CgloYWxmIGVkZ2VBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9PdXRlckVkZ2UpOwoJaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoZWRnZUFscGhhKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgABAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAAwAAABpbkNpcmNsZUVkZ2UBAAAAAAAAAA==","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBAYAAAAAAQAAIAAAACRRAAAAABAAAQAAAABIECAEAACAAAAAZQIEBSAAIAAAAAAAAJAAYAAAACAAAAAAACCAY":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADpBgAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMF9jMF9jMDsKdW5pZm9ybSBoYWxmMiB1XzBfSW5jcmVtZW50X1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBoYWxmNCB1XzFfS2VybmVsX1N0YWdlMV9jMF9jMFs0XTsKdW5pZm9ybSBoYWxmNCB1XzJfT2Zmc2V0c19TdGFnZTFfYzBfYzBbNF07CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMTsKaW4gZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoaGFsZjQgX2lucHV0LCBmbG9hdDIgX2Nvb3JkcykgCnsKCWZsb2F0MiBpbkNvb3JkID0gX2Nvb3JkczsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZC54ID0gY2xhbXAoc3Vic2V0Q29vcmQueCwgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMC54LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwX2MwX2MwLnopOwoJY2xhbXBlZENvb3JkLnkgPSBzdWJzZXRDb29yZC55OwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMF9jMChoYWxmNCBfaW5wdXQsIGZsb2F0MiBfY29vcmRzKSAKewoJcmV0dXJuIFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwX2MwKF9pbnB1dCwgZmxvYXQzeDIodW1hdHJpeF9TdGFnZTFfYzBfYzBfYzApICogX2Nvb3Jkcy54eTEpOwp9CmhhbGY0IEdhdXNzaWFuQ29udm9sdXRpb25fU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF8zX2NvbG9yID0gaGFsZjQoMC4wKTsKCWZsb2F0MiBfNF9jb29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZvciAoaW50IF81X2kgPSAwOyAoXzVfaSA8IDEzKTsgXzVfaSsrKSAoXzNfY29sb3IgKz0gKE1hdHJpeEVmZmVjdF9TdGFnZTFfYzBfYzBfYzAoX2lucHV0LCAoXzRfY29vcmQgKyBmbG9hdDIoKHVfMl9PZmZzZXRzX1N0YWdlMV9jMF9jMFsoXzVfaSAvIDQpXVsoXzVfaSAmIDMpXSAqIHVfMF9JbmNyZW1lbnRfU3RhZ2UxX2MwX2MwKSkpKSAqIHVfMV9LZXJuZWxfU3RhZ2UxX2MwX2MwWyhfNV9pIC8gNCldWyhfNV9pICYgMyldKSk7CglyZXR1cm4gXzNfY29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gR2F1c3NpYW5Db252b2x1dGlvbl9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0X1N0YWdlMSAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACAAAAHBvc2l0aW9uCgAAAGxvY2FsQ29vcmQAAAEAAAAAAAAA","AWAQGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAABZAAAAAAACAAAAAEBSAA":"BwAAAExTS1OSAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzJfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IF90bXBfMF9pblBvc2l0aW9uLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAAC3AgAAaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGYgZGlzdGFuY2VUb0lubmVyRWRnZSA9IGhhbGYoY2lyY2xlRWRnZS56ICogKGQgLSBjaXJjbGVFZGdlLncpKTsKCWhhbGYgaW5uZXJBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9Jbm5lckVkZ2UpOwoJZWRnZUFscGhhICo9IGlubmVyQWxwaGE7CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChlZGdlQWxwaGEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","AWAAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAABZAAAAAAACAAAAAEBSAA":"BwAAAExTS1OSAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzJfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IF90bXBfMF9pblBvc2l0aW9uLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAAAmAgAAaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","BUAAQAAAAQAAAAABC3777777777QAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1PDAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBEZWZhdWx0R2VvbWV0cnlQcm9jZXNzb3IKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IF90bXBfMV9pblBvc2l0aW9uLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAD8BAAB1bmlmb3JtIGhhbGY0IHVDb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBEZWZhdWx0R2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHVDb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAKAAAAaW5Qb3NpdGlvbgAAAQAAAAAAAAA=","AWQQGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIBRAA5ZQUCGQFAAAMAAAAAAAAAAAAAA4QAAAAAABAAAAACAZAAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAAA4BgAAY29uc3QgaW50IGtGaWxsQldfU3RhZ2UxX2MwID0gMDsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCA9IDI7CmNvbnN0IGludCBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzAgPSAzOwp1bmlmb3JtIGZsb2F0NCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwOwppbiBmbG9hdDQgdmluQ2lyY2xlRWRnZV9TdGFnZTA7CmluIGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJaGFsZjQgX3RtcF8wX2luQ29sb3IgPSBfaW5wdXQ7CgloYWxmIGNvdmVyYWdlOwoJaWYgKGludCgxKSA9PSBrRmlsbEJXX1N0YWdlMV9jMCB8fCBpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoYWxsKGdyZWF0ZXJUaGFuKGZsb2F0NChza19GcmFnQ29vcmQueHksIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAuencpLCBmbG9hdDQodXJlY3RVbmlmb3JtX1N0YWdlMV9jMC54eSwgc2tfRnJhZ0Nvb3JkLnh5KSkpID8gMSA6IDApOwoJfQoJZWxzZSAKCXsKCQloYWxmNCBkaXN0czQgPSBjbGFtcChoYWxmNCgxLjAsIDEuMCwgLTEuMCwgLTEuMCkgKiBoYWxmNChza19GcmFnQ29vcmQueHl4eSAtIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzApLCAwLjAsIDEuMCk7CgkJaGFsZjIgZGlzdHMyID0gKGRpc3RzNC54eSArIGRpc3RzNC56dykgLSAxLjA7CgkJY292ZXJhZ2UgPSBkaXN0czIueCAqIGRpc3RzMi55OwoJfQoJaWYgKGludCgxKSA9PSBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSAxLjAgLSBjb3ZlcmFnZTsKCX0KCXJldHVybiBoYWxmNChfaW5wdXQgKiBjb3ZlcmFnZSk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIENpcmNsZUdlb21ldHJ5UHJvY2Vzc29yCglmbG9hdDQgY2lyY2xlRWRnZTsKCWNpcmNsZUVkZ2UgPSB2aW5DaXJjbGVFZGdlX1N0YWdlMDsKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZpbkNvbG9yX1N0YWdlMDsKCWZsb2F0IGQgPSBsZW5ndGgoY2lyY2xlRWRnZS54eSk7CgloYWxmIGRpc3RhbmNlVG9PdXRlckVkZ2UgPSBoYWxmKGNpcmNsZUVkZ2UueiAqICgxLjAgLSBkKSk7CgloYWxmIGVkZ2VBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9PdXRlckVkZ2UpOwoJaGFsZiBkaXN0YW5jZVRvSW5uZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoZCAtIGNpcmNsZUVkZ2UudykpOwoJaGFsZiBpbm5lckFscGhhID0gc2F0dXJhdGUoZGlzdGFuY2VUb0lubmVyRWRnZSk7CgllZGdlQWxwaGEgKj0gaW5uZXJBbHBoYTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IFJlY3RfU3RhZ2UxX2MwKG91dHB1dENvdmVyYWdlX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0X1N0YWdlMTsKCX0KfQoBAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAAwAAABpbkNpcmNsZUVkZ2UBAAAAAAAAAA==","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIAGCADYAAAQAAAAAAAQAOAAAABAAAAAAABBAMAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAADyAwAAdW5pZm9ybSBmbG9hdDQgdWlubmVyUmVjdF9TdGFnZTFfYzA7CnVuaWZvcm0gaGFsZjIgdXJhZGl1c1BsdXNIYWxmX1N0YWdlMV9jMDsKaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIF9pbnB1dCAqIGFscGhhOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKG91dHB1dENvdmVyYWdlX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0X1N0YWdlMTsKCX0KfQoAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAMAAAAKAAAAaW5Qb3NpdGlvbgAABwAAAGluQ29sb3IADAAAAGluQ2lyY2xlRWRnZQEAAAAAAAAA","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIAGGADYAAAQAAAAAAAQAOAAAABAAAAAAABBAMAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAAAIBAAAdW5pZm9ybSBmbG9hdDQgdWlubmVyUmVjdF9TdGFnZTFfYzA7CnVuaWZvcm0gaGFsZjIgdXJhZGl1c1BsdXNIYWxmX1N0YWdlMV9jMDsKaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJYWxwaGEgPSAxLjAgLSBhbHBoYTsKCXJldHVybiBfaW5wdXQgKiBhbHBoYTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgQ2lyY2xlR2VvbWV0cnlQcm9jZXNzb3IKCWZsb2F0NCBjaXJjbGVFZGdlOwoJY2lyY2xlRWRnZSA9IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJZmxvYXQgZCA9IGxlbmd0aChjaXJjbGVFZGdlLnh5KTsKCWhhbGYgZGlzdGFuY2VUb091dGVyRWRnZSA9IGhhbGYoY2lyY2xlRWRnZS56ICogKDEuMCAtIGQpKTsKCWhhbGYgZWRnZUFscGhhID0gc2F0dXJhdGUoZGlzdGFuY2VUb091dGVyRWRnZSk7CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChlZGdlQWxwaGEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBASAAAAAAQAAIAAAACRFAAAAABAAAQAAAABIECAEAACAAAAAZQIEBSAAIAAAAAAAAJAAYAAAACAAAAAAACCAY":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADpBgAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMF9jMF9jMDsKdW5pZm9ybSBoYWxmMiB1XzBfSW5jcmVtZW50X1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBoYWxmNCB1XzFfS2VybmVsX1N0YWdlMV9jMF9jMFszXTsKdW5pZm9ybSBoYWxmNCB1XzJfT2Zmc2V0c19TdGFnZTFfYzBfYzBbM107CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMTsKaW4gZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoaGFsZjQgX2lucHV0LCBmbG9hdDIgX2Nvb3JkcykgCnsKCWZsb2F0MiBpbkNvb3JkID0gX2Nvb3JkczsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZC54ID0gY2xhbXAoc3Vic2V0Q29vcmQueCwgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMC54LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwX2MwX2MwLnopOwoJY2xhbXBlZENvb3JkLnkgPSBzdWJzZXRDb29yZC55OwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMF9jMChoYWxmNCBfaW5wdXQsIGZsb2F0MiBfY29vcmRzKSAKewoJcmV0dXJuIFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwX2MwKF9pbnB1dCwgZmxvYXQzeDIodW1hdHJpeF9TdGFnZTFfYzBfYzBfYzApICogX2Nvb3Jkcy54eTEpOwp9CmhhbGY0IEdhdXNzaWFuQ29udm9sdXRpb25fU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF8zX2NvbG9yID0gaGFsZjQoMC4wKTsKCWZsb2F0MiBfNF9jb29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZvciAoaW50IF81X2kgPSAwOyAoXzVfaSA8IDEwKTsgXzVfaSsrKSAoXzNfY29sb3IgKz0gKE1hdHJpeEVmZmVjdF9TdGFnZTFfYzBfYzBfYzAoX2lucHV0LCAoXzRfY29vcmQgKyBmbG9hdDIoKHVfMl9PZmZzZXRzX1N0YWdlMV9jMF9jMFsoXzVfaSAvIDQpXVsoXzVfaSAmIDMpXSAqIHVfMF9JbmNyZW1lbnRfU3RhZ2UxX2MwX2MwKSkpKSAqIHVfMV9LZXJuZWxfU3RhZ2UxX2MwX2MwWyhfNV9pIC8gNCldWyhfNV9pICYgMyldKSk7CglyZXR1cm4gXzNfY29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gR2F1c3NpYW5Db252b2x1dGlvbl9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0X1N0YWdlMSAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACAAAAHBvc2l0aW9uCgAAAGxvY2FsQ29vcmQAAAEAAAAAAAAA","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIBRAA5ZQUCGQFAAAMAAAAAAAAAAAAAA4QAAAAAABAAAAACAZAAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAACnBQAAY29uc3QgaW50IGtGaWxsQldfU3RhZ2UxX2MwID0gMDsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCA9IDI7CmNvbnN0IGludCBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzAgPSAzOwp1bmlmb3JtIGZsb2F0NCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwOwppbiBmbG9hdDQgdmluQ2lyY2xlRWRnZV9TdGFnZTA7CmluIGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJaGFsZjQgX3RtcF8wX2luQ29sb3IgPSBfaW5wdXQ7CgloYWxmIGNvdmVyYWdlOwoJaWYgKGludCgxKSA9PSBrRmlsbEJXX1N0YWdlMV9jMCB8fCBpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoYWxsKGdyZWF0ZXJUaGFuKGZsb2F0NChza19GcmFnQ29vcmQueHksIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAuencpLCBmbG9hdDQodXJlY3RVbmlmb3JtX1N0YWdlMV9jMC54eSwgc2tfRnJhZ0Nvb3JkLnh5KSkpID8gMSA6IDApOwoJfQoJZWxzZSAKCXsKCQloYWxmNCBkaXN0czQgPSBjbGFtcChoYWxmNCgxLjAsIDEuMCwgLTEuMCwgLTEuMCkgKiBoYWxmNChza19GcmFnQ29vcmQueHl4eSAtIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzApLCAwLjAsIDEuMCk7CgkJaGFsZjIgZGlzdHMyID0gKGRpc3RzNC54eSArIGRpc3RzNC56dykgLSAxLjA7CgkJY292ZXJhZ2UgPSBkaXN0czIueCAqIGRpc3RzMi55OwoJfQoJaWYgKGludCgxKSA9PSBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSAxLjAgLSBjb3ZlcmFnZTsKCX0KCXJldHVybiBoYWxmNChfaW5wdXQgKiBjb3ZlcmFnZSk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIENpcmNsZUdlb21ldHJ5UHJvY2Vzc29yCglmbG9hdDQgY2lyY2xlRWRnZTsKCWNpcmNsZUVkZ2UgPSB2aW5DaXJjbGVFZGdlX1N0YWdlMDsKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZpbkNvbG9yX1N0YWdlMDsKCWZsb2F0IGQgPSBsZW5ndGgoY2lyY2xlRWRnZS54eSk7CgloYWxmIGRpc3RhbmNlVG9PdXRlckVkZ2UgPSBoYWxmKGNpcmNsZUVkZ2UueiAqICgxLjAgLSBkKSk7CgloYWxmIGVkZ2VBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9PdXRlckVkZ2UpOwoJaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoZWRnZUFscGhhKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgABAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAAwAAABpbkNpcmNsZUVkZ2UBAAAAAAAAAA==","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBACAABAAAAAMYECAZAAEAAAAAAAAEQAMAAAABAAAAAAABBAMAAAAA":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADsAwAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMDsKdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxOwppbiBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBUZXh0dXJlRWZmZWN0X1N0YWdlMV9jMF9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdDIgaW5Db29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZC54ID0gY2xhbXAoc3Vic2V0Q29vcmQueCwgdWNsYW1wX1N0YWdlMV9jMF9jMC54LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwLnopOwoJY2xhbXBlZENvb3JkLnkgPSBzdWJzZXRDb29yZC55OwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0X1N0YWdlMSAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACAAAAHBvc2l0aW9uCgAAAGxvY2FsQ29vcmQAAAEAAAAAAAAA","E5YQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAAAAHEAAAAAAAIAAAAAQGIAAAAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDA7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAK0CAABmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgR3JGaWxsUlJlY3RPcDo6UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJZmxvYXQgeF9wbHVzXzE9dmFyY2Nvb3JkX1N0YWdlMC54LCB5PXZhcmNjb29yZF9TdGFnZTAueTsKCWhhbGYgY292ZXJhZ2U7CglpZiAoMCA9PSB4X3BsdXNfMSkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKHkpOwoJfQoJZWxzZSAKCXsKCQlmbG9hdCBmbiA9IHhfcGx1c18xICogKHhfcGx1c18xIC0gMik7CgkJZm4gPSBmbWEoeSx5LCBmbik7CgkJZmxvYXQgZm53aWR0aCA9IGZ3aWR0aChmbik7CgkJY292ZXJhZ2UgPSAuNSAtIGhhbGYoZm4vZm53aWR0aCk7CgkJY292ZXJhZ2UgPSBjbGFtcChjb3ZlcmFnZSwgMCwgMSk7Cgl9Cgljb3ZlcmFnZSA9IChjb3ZlcmFnZSA+PSAuNSkgPyAxIDogMDsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGNvdmVyYWdlKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAACAAAAA4AAAByYWRpaV9zZWxlY3RvcgAAGQAAAGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHMAAAAVAAAAYWFfYmxvYXRfYW5kX2NvdmVyYWdlAAAABAAAAHNrZXcJAAAAdHJhbnNsYXRlAAAABwAAAHJhZGlpX3gABwAAAHJhZGlpX3kABQAAAGNvbG9yAAAAAQAAAAAAAAA=","CIRQAAAABCYIR6AIWAMMAAAAAAAAAAAAABAA4AAAACAAAAAAACCAYAAAAA":"BwAAAExTS1NWAgAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDIgaW5FbGxpcHNlT2Zmc2V0OwppbiBmbG9hdDQgaW5FbGxpcHNlUmFkaWk7Cm91dCBmbG9hdDIgdkVsbGlwc2VPZmZzZXRzX1N0YWdlMDsKb3V0IGZsb2F0NCB2RWxsaXBzZVJhZGlpX1N0YWdlMDsKb3V0IGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgRWxsaXBzZUdlb21ldHJ5UHJvY2Vzc29yCgl2RWxsaXBzZU9mZnNldHNfU3RhZ2UwID0gaW5FbGxpcHNlT2Zmc2V0OwoJdkVsbGlwc2VSYWRpaV9TdGFnZTAgPSBpbkVsbGlwc2VSYWRpaTsKCXZpbkNvbG9yX1N0YWdlMCA9IGluQ29sb3I7CglmbG9hdDIgX3RtcF8wX2luUG9zaXRpb24gPSBpblBvc2l0aW9uOwoJZmxvYXQyIF90bXBfMV9pblBvc2l0aW9uID0gdWxvY2FsTWF0cml4X1N0YWdlMC54eiAqIGluUG9zaXRpb24gKyB1bG9jYWxNYXRyaXhfU3RhZ2UwLnl3OwoJc2tfUG9zaXRpb24gPSBfdG1wXzBfaW5Qb3NpdGlvbi54eTAxOwp9CgAAAAAAAAAAAAAAAAAAxgMAAGluIGZsb2F0MiB2RWxsaXBzZU9mZnNldHNfU3RhZ2UwOwppbiBmbG9hdDQgdkVsbGlwc2VSYWRpaV9TdGFnZTA7CmluIGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIEVsbGlwc2VHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJZmxvYXQyIG9mZnNldCA9IHZFbGxpcHNlT2Zmc2V0c19TdGFnZTAueHk7CglvZmZzZXQgKj0gdkVsbGlwc2VSYWRpaV9TdGFnZTAueHk7CglmbG9hdCB0ZXN0ID0gZG90KG9mZnNldCwgb2Zmc2V0KSAtIDEuMDsKCWZsb2F0MiBncmFkID0gMi4wKm9mZnNldCp2RWxsaXBzZVJhZGlpX1N0YWdlMC54eTsKCWZsb2F0IGdyYWRfZG90ID0gZG90KGdyYWQsIGdyYWQpOwoJZ3JhZF9kb3QgPSBtYXgoZ3JhZF9kb3QsIDEuMTc1NWUtMzgpOwoJZmxvYXQgaW52bGVuID0gaW52ZXJzZXNxcnQoZ3JhZF9kb3QpOwoJZmxvYXQgZWRnZUFscGhhID0gc2F0dXJhdGUoMC41LXRlc3QqaW52bGVuKTsKCW9mZnNldCA9IHZFbGxpcHNlT2Zmc2V0c19TdGFnZTAueHkqdkVsbGlwc2VSYWRpaV9TdGFnZTAuenc7Cgl0ZXN0ID0gZG90KG9mZnNldCwgb2Zmc2V0KSAtIDEuMDsKCWdyYWQgPSAyLjAqb2Zmc2V0KnZFbGxpcHNlUmFkaWlfU3RhZ2UwLnp3OwoJZ3JhZF9kb3QgPSBkb3QoZ3JhZCwgZ3JhZCk7CglpbnZsZW4gPSBpbnZlcnNlc3FydChncmFkX2RvdCk7CgllZGdlQWxwaGEgKj0gc2F0dXJhdGUoMC41K3Rlc3QqaW52bGVuKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGhhbGYoZWRnZUFscGhhKSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAABAAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAPAAAAaW5FbGxpcHNlT2Zmc2V0AA4AAABpbkVsbGlwc2VSYWRpaQAAAQAAAAAAAAA=","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAA2IAOAAACAAAAAAASABQAAAAEAAAAAAAEEBQ":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAAFMEAAB1bmlmb3JtIGZsb2F0NCB1aW5uZXJSZWN0X1N0YWdlMV9jMDsKdW5pZm9ybSBoYWxmMiB1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdDIgZHh5MCA9IHVpbm5lclJlY3RfU3RhZ2UxX2MwLkxUIC0gc2tfRnJhZ0Nvb3JkLnh5OwoJZmxvYXQyIGR4eTEgPSBza19GcmFnQ29vcmQueHkgLSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5SQjsKCWZsb2F0MiBkeHkgPSBtYXgobWF4KGR4eTAsIGR4eTEpLCAwLjApOwoJaGFsZiBhbHBoYSA9IGhhbGYoc2F0dXJhdGUodXJhZGl1c1BsdXNIYWxmX1N0YWdlMV9jMC54IC0gbGVuZ3RoKGR4eSkpKTsKCXJldHVybiBfaW5wdXQgKiBhbHBoYTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgR3JGaWxsUlJlY3RPcDo6UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJZmxvYXQgeF9wbHVzXzE9dmFyY2Nvb3JkX1N0YWdlMC54LCB5PXZhcmNjb29yZF9TdGFnZTAueTsKCWhhbGYgY292ZXJhZ2U7CglpZiAoMCA9PSB4X3BsdXNfMSkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKHkpOwoJfQoJZWxzZSAKCXsKCQlmbG9hdCBmbiA9IHhfcGx1c18xICogKHhfcGx1c18xIC0gMik7CgkJZm4gPSBmbWEoeSx5LCBmbik7CgkJZmxvYXQgZm53aWR0aCA9IGZ3aWR0aChmbik7CgkJY292ZXJhZ2UgPSAuNSAtIGhhbGYoZm4vZm53aWR0aCk7CgkJY292ZXJhZ2UgPSBjbGFtcChjb3ZlcmFnZSwgMCwgMSk7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChjb3ZlcmFnZSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKG91dHB1dENvdmVyYWdlX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0X1N0YWdlMTsKCX0KfQoAAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAACAAAAA4AAAByYWRpaV9zZWxlY3RvcgAAGQAAAGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHMAAAAVAAAAYWFfYmxvYXRfYW5kX2NvdmVyYWdlAAAABAAAAHNrZXcJAAAAdHJhbnNsYXRlAAAABwAAAHJhZGlpX3gABwAAAHJhZGlpX3kABQAAAGNvbG9yAAAAAQAAAAAAAAA=","HRJAAAAAAAMAAAAAARMPZ72HPQCFR7H7777QGAAAAACAAAAAACCAYABAA4AAAACAAAAAAACCAYAAA":"BwAAAExTS1MzAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7CmluIGZsb2F0MiBsb2NhbENvb3JkOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7Cgl2bG9jYWxDb29yZF9TdGFnZTAgPSBsb2NhbENvb3JkOwoJc2tfUG9zaXRpb24gPSBwb3NpdGlvbi54eTAxOwp9CgAAAAAAAAAAAAAAAAAcAgAAdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2bG9jYWxDb29yZF9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7CglmbG9hdDIgdGV4Q29vcmQ7Cgl0ZXhDb29yZCA9IHZsb2NhbENvb3JkX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9ICgoc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdGV4Q29vcmQpICogb3V0cHV0Q29sb3JfU3RhZ2UwKSk7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAgAAABwb3NpdGlvbgUAAABjb2xvcgAAAAoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","HRIAAAAAAAMAAAAAARMPZ72HPQCFR7H7777QGAAAAAAAAAAAIQCQAAACAAAAAZQIAAAAAAAAAAAAAABAA4AAAACAAAAAAACCAYAAAAA":"BwAAAExTS1OSAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7CmluIGZsb2F0MiBsb2NhbENvb3JkOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cgl7CgkJdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwID0gZmxvYXQzeDIodW1hdHJpeF9TdGFnZTFfYzApICogbG9jYWxDb29yZC54eTE7Cgl9Cn0KAAAAAAAAAAAAAAAAAADmAgAAdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMDsKdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCXJldHVybiBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxLCB2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTApLnJycnI7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IE1hdHJpeEVmZmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAgAAABwb3NpdGlvbgUAAABjb2xvcgAAAAoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","HQQAAAAAAAGAAAAAAIWP57ZDH37P7777777QCAAAAAAAAAAASABQAAAAEAAAAAAAEEBQAAA":"BwAAAExTS1PcAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7CmZsYXQgb3V0IGhhbGY0IHZjb2xvcl9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJc2tfUG9zaXRpb24gPSBwb3NpdGlvbi54eTAxOwp9CgAAAAAAAAAAAAAAAEUBAABmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgUAAABjb2xvcgAAAAEAAAAAAAAA","HQQACAAAAAGAAAAAAIWP57ZDH37P7777777QCAAAAAAAAAAASABQAAAAEAAAAAAAEEBQAAA":"BwAAAExTS1PXAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7Cm91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZjb2xvcl9TdGFnZTAgPSBjb2xvcjsKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAQAEAAGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgUAAABjb2xvcgAAAAEAAAAAAAAA","BUIBQAAAAQAAAAABCYIR7777777QAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1NGAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwpvdXQgaGFsZjQgdmNvbG9yX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgRGVmYXVsdEdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBjb2xvciA9IGluQ29sb3I7Cgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7CglmbG9hdDIgX3RtcF8xX2luUG9zaXRpb24gPSBpblBvc2l0aW9uOwoJZmxvYXQyIF90bXBfM19pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAADoBAABpbiBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgRGVmYXVsdEdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgABAAAAAAAAAA==","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAFIAA2AAAAAAQCAQAAAACUEQQCAAAAABQIMACCAYAAEAQAAAAAAADSAAAAAAAEAAAAAIDEAAAAA":"BwAAAExTS1NSAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzBfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzNfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfM19TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMF9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAAAAAGwEAAB1bmlmb3JtIGZsb2F0NCB1Y2xhbXBfU3RhZ2UxX2MwX2MwX2MwOwp1bmlmb3JtIGZsb2F0M3gzIHVtYXRyaXhfU3RhZ2UxX2MwX2MwOwp1bmlmb3JtIHNhbXBsZXIyRCB1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTE7CmluIGZsb2F0MiB2VHJhbnNmb3JtZWRDb29yZHNfM19TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBpbkNvb3JkID0gdlRyYW5zZm9ybWVkQ29vcmRzXzNfU3RhZ2UwOwoJZmxvYXQyIHN1YnNldENvb3JkOwoJc3Vic2V0Q29vcmQueCA9IGluQ29vcmQueDsKCXN1YnNldENvb3JkLnkgPSBpbkNvb3JkLnk7CglmbG9hdDIgY2xhbXBlZENvb3JkOwoJY2xhbXBlZENvb3JkID0gY2xhbXAoc3Vic2V0Q29vcmQsIHVjbGFtcF9TdGFnZTFfYzBfYzBfYzAueHksIHVjbGFtcF9TdGFnZTFfYzBfYzBfYzAuencpOwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzAoX2lucHV0KTsKfQpoYWxmNCBCbGVuZF9TdGFnZTFfYzAoaGFsZjQgX3NyYywgaGFsZjQgX2RzdCkgCnsKCS8vIEJsZW5kIG1vZGU6IE1vZHVsYXRlCglyZXR1cm4gYmxlbmRfbW9kdWxhdGUoTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMChfc3JjKSwgX3NyYyk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQmxlbmRfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCwgaGFsZjQoMSkpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dF9TdGFnZTEgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAA2IAOAAAQB5VRECGAEAAAMAAAAAEAAAAABAIZYA6C2SFCAAAAAGAAAAAAAAAAAEQAMAAAABAAAAAAABBAMAAAAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAAMUHAABjb25zdCBpbnQga0ZpbGxCV19TdGFnZTFfYzBfYzAgPSAwOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwX2MwID0gMjsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMF9jMCA9IDM7CnVuaWZvcm0gZmxvYXQ0IHVyZWN0VW5pZm9ybV9TdGFnZTFfYzBfYzA7CnVuaWZvcm0gZmxvYXQ0IHVpbm5lclJlY3RfU3RhZ2UxX2MwOwp1bmlmb3JtIGhhbGYyIHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzA7CmZsYXQgaW4gaGFsZjQgdmNvbG9yX1N0YWdlMDsKaW4gZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFJlY3RfU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF90bXBfMF9pbkNvbG9yID0gX2lucHV0OwoJaGFsZiBjb3ZlcmFnZTsKCWlmIChpbnQoMSkgPT0ga0ZpbGxCV19TdGFnZTFfYzBfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMF9jMCkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKGFsbChncmVhdGVyVGhhbihmbG9hdDQoc2tfRnJhZ0Nvb3JkLnh5LCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwX2MwLnp3KSwgZmxvYXQ0KHVyZWN0VW5pZm9ybV9TdGFnZTFfYzBfYzAueHksIHNrX0ZyYWdDb29yZC54eSkpKSA/IDEgOiAwKTsKCX0KCWVsc2UgCgl7CgkJaGFsZjQgZGlzdHM0ID0gY2xhbXAoaGFsZjQoMS4wLCAxLjAsIC0xLjAsIC0xLjApICogaGFsZjQoc2tfRnJhZ0Nvb3JkLnh5eHkgLSB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwX2MwKSwgMC4wLCAxLjApOwoJCWhhbGYyIGRpc3RzMiA9IChkaXN0czQueHkgKyBkaXN0czQuencpIC0gMS4wOwoJCWNvdmVyYWdlID0gZGlzdHMyLnggKiBkaXN0czIueTsKCX0KCWlmIChpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwX2MwIHx8IGludCgxKSA9PSBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzBfYzApIAoJewoJCWNvdmVyYWdlID0gMS4wIC0gY292ZXJhZ2U7Cgl9CglyZXR1cm4gaGFsZjQoX2lucHV0ICogY292ZXJhZ2UpOwp9CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIFJlY3RfU3RhZ2UxX2MwX2MwKF9pbnB1dCkgKiBhbHBoYTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgR3JGaWxsUlJlY3RPcDo6UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJZmxvYXQgeF9wbHVzXzE9dmFyY2Nvb3JkX1N0YWdlMC54LCB5PXZhcmNjb29yZF9TdGFnZTAueTsKCWhhbGYgY292ZXJhZ2U7CglpZiAoMCA9PSB4X3BsdXNfMSkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKHkpOwoJfQoJZWxzZSAKCXsKCQlmbG9hdCBmbiA9IHhfcGx1c18xICogKHhfcGx1c18xIC0gMik7CgkJZm4gPSBmbWEoeSx5LCBmbik7CgkJZmxvYXQgZm53aWR0aCA9IGZ3aWR0aChmbik7CgkJY292ZXJhZ2UgPSAuNSAtIGhhbGYoZm4vZm53aWR0aCk7CgkJY292ZXJhZ2UgPSBjbGFtcChjb3ZlcmFnZSwgMCwgMSk7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChjb3ZlcmFnZSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKG91dHB1dENvdmVyYWdlX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0X1N0YWdlMTsKCX0KfQoAAAABAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAIAAAADgAAAHJhZGlpX3NlbGVjdG9yAAAZAAAAY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cwAAABUAAABhYV9ibG9hdF9hbmRfY292ZXJhZ2UAAAAEAAAAc2tldwkAAAB0cmFuc2xhdGUAAAAHAAAAcmFkaWlfeAAHAAAAcmFkaWlfeQAFAAAAY29sb3IAAAABAAAAAAAAAA==","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAGGAHWWEQIYAQAABQAAAAAAAAAAAEADQAAAAIAAAAAAAIIDAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAAAgGAABjb25zdCBpbnQga0ZpbGxCV19TdGFnZTFfYzAgPSAwOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwID0gMjsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCA9IDM7CnVuaWZvcm0gZmxvYXQ0IHVyZWN0VW5pZm9ybV9TdGFnZTFfYzA7CmZsYXQgaW4gaGFsZjQgdmNvbG9yX1N0YWdlMDsKaW4gZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF90bXBfMF9pbkNvbG9yID0gX2lucHV0OwoJaGFsZiBjb3ZlcmFnZTsKCWlmIChpbnQoMSkgPT0ga0ZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKGFsbChncmVhdGVyVGhhbihmbG9hdDQoc2tfRnJhZ0Nvb3JkLnh5LCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwLnp3KSwgZmxvYXQ0KHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAueHksIHNrX0ZyYWdDb29yZC54eSkpKSA/IDEgOiAwKTsKCX0KCWVsc2UgCgl7CgkJaGFsZjQgZGlzdHM0ID0gY2xhbXAoaGFsZjQoMS4wLCAxLjAsIC0xLjAsIC0xLjApICogaGFsZjQoc2tfRnJhZ0Nvb3JkLnh5eHkgLSB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwKSwgMC4wLCAxLjApOwoJCWhhbGYyIGRpc3RzMiA9IChkaXN0czQueHkgKyBkaXN0czQuencpIC0gMS4wOwoJCWNvdmVyYWdlID0gZGlzdHMyLnggKiBkaXN0czIueTsKCX0KCWlmIChpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwIHx8IGludCgxKSA9PSBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzApIAoJewoJCWNvdmVyYWdlID0gMS4wIC0gY292ZXJhZ2U7Cgl9CglyZXR1cm4gaGFsZjQoX2lucHV0ICogY292ZXJhZ2UpOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBHckZpbGxSUmVjdE9wOjpQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7CglmbG9hdCB4X3BsdXNfMT12YXJjY29vcmRfU3RhZ2UwLngsIHk9dmFyY2Nvb3JkX1N0YWdlMC55OwoJaGFsZiBjb3ZlcmFnZTsKCWlmICgwID09IHhfcGx1c18xKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoeSk7Cgl9CgllbHNlIAoJewoJCWZsb2F0IGZuID0geF9wbHVzXzEgKiAoeF9wbHVzXzEgLSAyKTsKCQlmbiA9IGZtYSh5LHksIGZuKTsKCQlmbG9hdCBmbndpZHRoID0gZndpZHRoKGZuKTsKCQljb3ZlcmFnZSA9IC41IC0gaGFsZihmbi9mbndpZHRoKTsKCQljb3ZlcmFnZSA9IGNsYW1wKGNvdmVyYWdlLCAwLCAxKTsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGNvdmVyYWdlKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAgAAAAOAAAAcmFkaWlfc2VsZWN0b3IAABkAAABjb3JuZXJfYW5kX3JhZGl1c19vdXRzZXRzAAAAFQAAAGFhX2Jsb2F0X2FuZF9jb3ZlcmFnZQAAAAQAAABza2V3CQAAAHRyYW5zbGF0ZQAAAAcAAAByYWRpaV94AAcAAAByYWRpaV95AAUAAABjb2xvcgAAAAEAAAAAAAAA","HQJAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAABAAAAAABBAMAASANBMYOMDCQAAAAADAAAAAAAAAAAOIAAAAAAAQAAAABAMQAA":"BwAAAExTS1PtAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdmxvY2FsQ29vcmRfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZsb2NhbENvb3JkX1N0YWdlMCA9IGxvY2FsQ29vcmQ7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cn0KAAAAAQAAAAAAAAABAAAAAQUAAGNvbnN0IGludCBrRmlsbEFBX1N0YWdlMV9jMCA9IDE7CmNvbnN0IGludCBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgPSAyOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQUFfU3RhZ2UxX2MwID0gMzsKdW5pZm9ybSBmbG9hdDQgdWNpcmNsZV9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgQ2lyY2xlX1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CgloYWxmNCBfdG1wXzBfaW5Db2xvciA9IF9pbnB1dDsKCWhhbGYgZDsKCWlmIChpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwIHx8IGludCgxKSA9PSBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzApIAoJewoJCWQgPSBoYWxmKChsZW5ndGgoKHVjaXJjbGVfU3RhZ2UxX2MwLnh5IC0gc2tfRnJhZ0Nvb3JkLnh5KSAqIHVjaXJjbGVfU3RhZ2UxX2MwLncpIC0gMS4wKSAqIHVjaXJjbGVfU3RhZ2UxX2MwLnopOwoJfQoJZWxzZSAKCXsKCQlkID0gaGFsZigoMS4wIC0gbGVuZ3RoKCh1Y2lyY2xlX1N0YWdlMV9jMC54eSAtIHNrX0ZyYWdDb29yZC54eSkgKiB1Y2lyY2xlX1N0YWdlMV9jMC53KSkgKiB1Y2lyY2xlX1N0YWdlMV9jMC56KTsKCX0KCWlmIChpbnQoMSkgPT0ga0ZpbGxBQV9TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJcmV0dXJuIGhhbGY0KF9pbnB1dCAqIHNhdHVyYXRlKGQpKTsKCX0KCWVsc2UgCgl7CgkJcmV0dXJuIGhhbGY0KGQgPiAwLjUgPyBfaW5wdXQgOiBoYWxmNCgwLjApKTsKCX0KfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWZsb2F0MiB0ZXhDb29yZDsKCXRleENvb3JkID0gdmxvY2FsQ29vcmRfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gKChzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwLCB0ZXhDb29yZCkgKiBoYWxmNCgxKSkpOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IENpcmNsZV9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24KAAAAbG9jYWxDb29yZAAAAQAAAAAAAAA=","HQQAAAAAAAGAAAAAAIWP57ZDH37P7777777QCAAAAAAAAAAAMIAHSAAAAAAQAAAAAA4QAAAAAABAAAAACAZAAAAAAA":"BwAAAExTS1PcAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7CmZsYXQgb3V0IGhhbGY0IHZjb2xvcl9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJc2tfUG9zaXRpb24gPSBwb3NpdGlvbi54eTAxOwp9CgEAAAAAAAAAAQAAABEDAAB1bmlmb3JtIGZsb2F0NCB1aW5uZXJSZWN0X1N0YWdlMV9jMDsKdW5pZm9ybSBoYWxmMiB1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIF9pbnB1dCAqIGFscGhhOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24FAAAAY29sb3IAAAABAAAAAAAAAA==","C4QAAAAAMAAAAABAYAROFA2CAIAAAABAAAAAAAAAAAACAMMAPNMJARQBAAADAAAAAAAAAAAAIAHAAAAAQAAAAAAAQQGAAAAA":"BwAAAExTS1M2AgAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQyIHVBdGxhc1NpemVJbnZfU3RhZ2UwOwppbiBmbG9hdDIgaW5Qb3NpdGlvbjsKaW4gaGFsZjQgaW5Db2xvcjsKaW4gdXNob3J0MiBpblRleHR1cmVDb29yZHM7Cm91dCBmbG9hdDIgdlRleHR1cmVDb29yZHNfU3RhZ2UwOwpmbGF0IG91dCBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBCaXRtYXBUZXh0CglpbnQgdGV4SWR4ID0gMDsKCWZsb2F0MiB1bm9ybVRleENvb3JkcyA9IGZsb2F0MihpblRleHR1cmVDb29yZHMueCwgaW5UZXh0dXJlQ29vcmRzLnkpOwoJdlRleHR1cmVDb29yZHNfU3RhZ2UwID0gdW5vcm1UZXhDb29yZHMgKiB1QXRsYXNTaXplSW52X1N0YWdlMDsKCXZUZXhJbmRleF9TdGFnZTAgPSBmbG9hdCh0ZXhJZHgpOwoJdmluQ29sb3JfU3RhZ2UwID0gaW5Db2xvcjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IGluUG9zaXRpb24ueHkwMTsKfQoAAAEAAAAAAAAAAQAAAHoFAABjb25zdCBpbnQga0ZpbGxCV19TdGFnZTFfYzAgPSAwOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwID0gMjsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCA9IDM7CnVuaWZvcm0gZmxvYXQ0IHVyZWN0VW5pZm9ybV9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZUZXh0dXJlQ29vcmRzX1N0YWdlMDsKZmxhdCBpbiBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF90bXBfMF9pbkNvbG9yID0gX2lucHV0OwoJaGFsZiBjb3ZlcmFnZTsKCWlmIChpbnQoMSkgPT0ga0ZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKGFsbChncmVhdGVyVGhhbihmbG9hdDQoc2tfRnJhZ0Nvb3JkLnh5LCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwLnp3KSwgZmxvYXQ0KHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAueHksIHNrX0ZyYWdDb29yZC54eSkpKSA/IDEgOiAwKTsKCX0KCWVsc2UgCgl7CgkJaGFsZjQgZGlzdHM0ID0gY2xhbXAoaGFsZjQoMS4wLCAxLjAsIC0xLjAsIC0xLjApICogaGFsZjQoc2tfRnJhZ0Nvb3JkLnh5eHkgLSB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwKSwgMC4wLCAxLjApOwoJCWhhbGYyIGRpc3RzMiA9IChkaXN0czQueHkgKyBkaXN0czQuencpIC0gMS4wOwoJCWNvdmVyYWdlID0gZGlzdHMyLnggKiBkaXN0czIueTsKCX0KCWlmIChpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwIHx8IGludCgxKSA9PSBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzApIAoJewoJCWNvdmVyYWdlID0gMS4wIC0gY292ZXJhZ2U7Cgl9CglyZXR1cm4gaGFsZjQoX2lucHV0ICogY292ZXJhZ2UpOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBCaXRtYXBUZXh0CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CgloYWxmNCB0ZXhDb2xvcjsKCXsKCQl0ZXhDb2xvciA9IHNhbXBsZSh1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTAsIHZUZXh0dXJlQ29vcmRzX1N0YWdlMCkucnJycjsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IHRleENvbG9yOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBSZWN0X1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAAABAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAA8AAABpblRleHR1cmVDb29yZHMAAQAAAAAAAAA=","HQJAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAABAAAAAAJBAMADCAB4QAAAAAEAAAAAAHEAAAAAAAIAAAAAQGIAAAAAA":"BwAAAExTS1PtAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdmxvY2FsQ29vcmRfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZsb2NhbENvb3JkX1N0YWdlMCA9IGxvY2FsQ29vcmQ7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cn0KAAAAAQAAAAAAAAABAAAAsAMAAHVuaWZvcm0gZmxvYXQ0IHVpbm5lclJlY3RfU3RhZ2UxX2MwOwp1bmlmb3JtIGhhbGYyIHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlckV4dGVybmFsT0VTIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJZmxvYXQyIGR4eTAgPSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5MVCAtIHNrX0ZyYWdDb29yZC54eTsKCWZsb2F0MiBkeHkxID0gc2tfRnJhZ0Nvb3JkLnh5IC0gdWlubmVyUmVjdF9TdGFnZTFfYzAuUkI7CglmbG9hdDIgZHh5ID0gbWF4KG1heChkeHkwLCBkeHkxKSwgMC4wKTsKCWhhbGYgYWxwaGEgPSBoYWxmKHNhdHVyYXRlKHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzAueCAtIGxlbmd0aChkeHkpKSk7CglyZXR1cm4gX2lucHV0ICogYWxwaGE7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7CglmbG9hdDIgdGV4Q29vcmQ7Cgl0ZXhDb29yZCA9IHZsb2NhbENvb3JkX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9ICgoc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdGV4Q29vcmQpICogaGFsZjQoMSkpKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","BWABSAAAAQAAAAABC3777777AAOAAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1OIAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gaGFsZjQgdUNvbG9yX1N0YWdlMDsKaW4gZmxvYXQyIGluUG9zaXRpb247CmluIGhhbGYgaW5Db3ZlcmFnZTsKb3V0IGhhbGY0IHZjb2xvcl9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIERlZmF1bHRHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgY29sb3IgPSB1Q29sb3JfU3RhZ2UwOwoJY29sb3IgPSBjb2xvciAqIGluQ292ZXJhZ2U7Cgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7CglmbG9hdDIgX3RtcF8xX2luUG9zaXRpb24gPSBpblBvc2l0aW9uOwoJZmxvYXQyIF90bXBfM19pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAA6AQAAaW4gaGFsZjQgdmNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIERlZmF1bHRHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmNvbG9yX1N0YWdlMDsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAKAAAAaW5Qb3NpdGlvbgAACgAAAGluQ292ZXJhZ2UAAAEAAAAAAAAA","AWQQGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAABZAAAAAAACAAAAAEBSAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAC3AgAAaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGYgZGlzdGFuY2VUb0lubmVyRWRnZSA9IGhhbGYoY2lyY2xlRWRnZS56ICogKGQgLSBjaXJjbGVFZGdlLncpKTsKCWhhbGYgaW5uZXJBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9Jbm5lckVkZ2UpOwoJZWRnZUFscGhhICo9IGlubmVyQWxwaGE7CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChlZGdlQWxwaGEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","BWIASAAAAQAAAAABCYIR7777AAOAAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1NPAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBoYWxmIGluQ292ZXJhZ2U7Cm91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBEZWZhdWx0R2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IGNvbG9yID0gaW5Db2xvcjsKCWNvbG9yID0gY29sb3IgKiBpbkNvdmVyYWdlOwoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQyIF90bXBfMV9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAOgEAAGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBEZWZhdWx0R2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAAoAAABpbkNvdmVyYWdlAAABAAAAAAAAAA==","F6IAAAAAAIAAAAABCYBR6AAAAAAAAAAAADEACAAAAAIAAAAAQCIACAAAAA":"BwAAAExTS1MQAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkhhaXJRdWFkRWRnZTsKb3V0IGhhbGY0IHZIYWlyUXVhZEVkZ2VfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkCgl2SGFpclF1YWRFZGdlX1N0YWdlMCA9IGluSGFpclF1YWRFZGdlOwoJZmxvYXQyIF90bXBfMV9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAAA7AwAAdW5pZm9ybSBoYWxmNCB1Q29sb3JfU3RhZ2UwOwp1bmlmb3JtIGhhbGYgdUNvdmVyYWdlX1N0YWdlMDsKaW4gaGFsZjQgdkhhaXJRdWFkRWRnZV9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB1Q29sb3JfU3RhZ2UwOwoJaGFsZiBlZGdlQWxwaGE7CgloYWxmMiBkdXZkeCA9IGhhbGYyKGRGZHgodkhhaXJRdWFkRWRnZV9TdGFnZTAueHkpKTsKCWhhbGYyIGR1dmR5ID0gaGFsZjIoZEZkeSh2SGFpclF1YWRFZGdlX1N0YWdlMC54eSkpOwoJaGFsZjIgZ0YgPSBoYWxmMigyLjAgKiB2SGFpclF1YWRFZGdlX1N0YWdlMC54ICogZHV2ZHgueCAtIGR1dmR4LnksICAgICAgICAgICAgICAgMi4wICogdkhhaXJRdWFkRWRnZV9TdGFnZTAueCAqIGR1dmR5LnggLSBkdXZkeS55KTsKCWVkZ2VBbHBoYSA9IGhhbGYodkhhaXJRdWFkRWRnZV9TdGFnZTAueCAqIHZIYWlyUXVhZEVkZ2VfU3RhZ2UwLnggLSB2SGFpclF1YWRFZGdlX1N0YWdlMC55KTsKCWVkZ2VBbHBoYSA9IHNxcnQoZWRnZUFscGhhICogZWRnZUFscGhhIC8gZG90KGdGLCBnRikpOwoJZWRnZUFscGhhID0gbWF4KDEuMCAtIGVkZ2VBbHBoYSwgMC4wKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KHVDb3ZlcmFnZV9TdGFnZTAgKiBlZGdlQWxwaGEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAoAAABpblBvc2l0aW9uAAAOAAAAaW5IYWlyUXVhZEVkZ2UAAAEAAAAAAAAA","GABQAAAAAELBCHYCDYAAAAAAAEAAAACAIQAABSABAAAAAEAAAAAIBEABAA":"BwAAAExTS1NkAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBoYWxmMyBpblNoYWRvd1BhcmFtczsKb3V0IGhhbGYzIHZpblNoYWRvd1BhcmFtc19TdGFnZTA7Cm91dCBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIFJSZWN0U2hhZG93Cgl2aW5TaGFkb3dQYXJhbXNfU3RhZ2UwID0gaW5TaGFkb3dQYXJhbXM7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAABPAgAAdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwOwppbiBoYWxmMyB2aW5TaGFkb3dQYXJhbXNfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBSUmVjdFNoYWRvdwoJaGFsZjMgc2hhZG93UGFyYW1zOwoJc2hhZG93UGFyYW1zID0gdmluU2hhZG93UGFyYW1zX1N0YWdlMDsKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZpbkNvbG9yX1N0YWdlMDsKCWhhbGYgZCA9IGxlbmd0aChzaGFkb3dQYXJhbXMueHkpOwoJZmxvYXQyIHV2ID0gZmxvYXQyKHNoYWRvd1BhcmFtcy56ICogKDEuMCAtIGQpLCAwLjUpOwoJaGFsZiBmYWN0b3IgPSBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwLCB1dikuMDAwci5hOwoJaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoZmFjdG9yKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAMAAAAKAAAAaW5Qb3NpdGlvbgAABwAAAGluQ29sb3IADgAAAGluU2hhZG93UGFyYW1zAAABAAAAAAAAAA==","BWAAQAAAAQAAAAABC3777777AAOAAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1MWAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmIGluQ292ZXJhZ2U7Cm91dCBoYWxmIHZpbkNvdmVyYWdlX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgRGVmYXVsdEdlb21ldHJ5UHJvY2Vzc29yCglmbG9hdDIgX3RtcF8xX2luUG9zaXRpb24gPSBpblBvc2l0aW9uOwoJdmluQ292ZXJhZ2VfU3RhZ2UwID0gaW5Db3ZlcmFnZTsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAAIkBAAB1bmlmb3JtIGhhbGY0IHVDb2xvcl9TdGFnZTA7CmluIGhhbGYgdmluQ292ZXJhZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgRGVmYXVsdEdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB1Q29sb3JfU3RhZ2UwOwoJaGFsZiBhbHBoYSA9IDEuMDsKCWFscGhhID0gdmluQ292ZXJhZ2VfU3RhZ2UwOwoJaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoYWxwaGEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACgAAAGluUG9zaXRpb24AAAoAAABpbkNvdmVyYWdlAAABAAAAAAAAAA==","HSQACAAAAAGAAAAAAIWAAKRCH37P6BZQ737QCAAAAAAAAAAASABQAAAAEAAAAAAAEEBQAAA":"BwAAAExTS1OlAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQgY292ZXJhZ2U7CmluIGhhbGY0IGNvbG9yOwppbiBmbG9hdDQgZ2VvbVN1YnNldDsKZmxhdCBvdXQgaGFsZjQgdmNvbG9yX1N0YWdlMDsKb3V0IGZsb2F0IHZjb3ZlcmFnZV9TdGFnZTA7CmZsYXQgb3V0IGZsb2F0NCB2Z2VvbVN1YnNldF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQyIHBvc2l0aW9uID0gcG9zaXRpb24ueHk7Cgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7Cgl2Y292ZXJhZ2VfU3RhZ2UwID0gY292ZXJhZ2U7Cgl2Z2VvbVN1YnNldF9TdGFnZTAgPSBnZW9tU3Vic2V0OwoJc2tfUG9zaXRpb24gPSBwb3NpdGlvbi54eTAxOwp9CgAAAAEAAAAAAAAAAQAAAL0CAABmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0IHZjb3ZlcmFnZV9TdGFnZTA7CmZsYXQgaW4gZmxvYXQ0IHZnZW9tU3Vic2V0X1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmNvbG9yX1N0YWdlMDsKCWZsb2F0IGNvdmVyYWdlID0gdmNvdmVyYWdlX1N0YWdlMDsKCWZsb2F0NCBnZW9TdWJzZXQ7CglnZW9TdWJzZXQgPSB2Z2VvbVN1YnNldF9TdGFnZTA7CgloYWxmNCBkaXN0czQgPSBjbGFtcChoYWxmNCgxLCAxLCAtMSwgLTEpICogaGFsZjQoc2tfRnJhZ0Nvb3JkLnh5eHkgLSBnZW9TdWJzZXQpLCAwLCAxKTsKCWhhbGYyIGRpc3RzMiA9IGRpc3RzNC54eSArIGRpc3RzNC56dyAtIDE7CgloYWxmIHN1YnNldENvdmVyYWdlID0gZGlzdHMyLnggKiBkaXN0czIueTsKCWNvdmVyYWdlID0gbWluKGNvdmVyYWdlLCBzdWJzZXRDb3ZlcmFnZSk7CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChoYWxmKGNvdmVyYWdlKSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAQAAAAIAAAAcG9zaXRpb24IAAAAY292ZXJhZ2UFAAAAY29sb3IAAAAKAAAAZ2VvbVN1YnNldAAAAQAAAAAAAAA=","HMMAAAAABCYIR6AYYAAAAAAAAAAAAACABYAAAAEAAAAAAAEEBQAA":"BwAAAExTS1NPAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5RdWFkRWRnZTsKb3V0IGZsb2F0NCB2UXVhZEVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkRWRnZQoJdlF1YWRFZGdlX1N0YWdlMCA9IGluUXVhZEVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoAAQAAAAAAAAABAAAAaQMAAGluIGZsb2F0NCB2UXVhZEVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkRWRnZQoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJaGFsZiBlZGdlQWxwaGE7CgloYWxmMiBkdXZkeCA9IGhhbGYyKGRGZHgodlF1YWRFZGdlX1N0YWdlMC54eSkpOwoJaGFsZjIgZHV2ZHkgPSBoYWxmMihkRmR5KHZRdWFkRWRnZV9TdGFnZTAueHkpKTsKCWlmICh2UXVhZEVkZ2VfU3RhZ2UwLnogPiAwLjAgJiYgdlF1YWRFZGdlX1N0YWdlMC53ID4gMC4wKSAKCXsKCQllZGdlQWxwaGEgPSBoYWxmKG1pbihtaW4odlF1YWRFZGdlX1N0YWdlMC56LCB2UXVhZEVkZ2VfU3RhZ2UwLncpICsgMC41LCAxLjApKTsKCX0KCWVsc2UgCgl7CgkJaGFsZjIgZ0YgPSBoYWxmMihoYWxmKDIuMCp2UXVhZEVkZ2VfU3RhZ2UwLngqZHV2ZHgueCAtIGR1dmR4LnkpLCAgICAgICAgICAgICAgICAgaGFsZigyLjAqdlF1YWRFZGdlX1N0YWdlMC54KmR1dmR5LnggLSBkdXZkeS55KSk7CgkJZWRnZUFscGhhID0gaGFsZih2UXVhZEVkZ2VfU3RhZ2UwLngqdlF1YWRFZGdlX1N0YWdlMC54IC0gdlF1YWRFZGdlX1N0YWdlMC55KTsKCQllZGdlQWxwaGEgPSBzYXR1cmF0ZSgwLjUgLSBlZGdlQWxwaGEgLyBsZW5ndGgoZ0YpKTsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAMAAAAKAAAAaW5Qb3NpdGlvbgAABwAAAGluQ29sb3IACgAAAGluUXVhZEVkZ2UAAAEAAAAAAAAA"}} \ No newline at end of file diff --git a/shaders_2.5.3.sksl.json b/shaders_2.5.3.sksl.json new file mode 100644 index 000000000..20508646f --- /dev/null +++ b/shaders_2.5.3.sksl.json @@ -0,0 +1 @@ +{"platform":"android","name":"SM G970N","engineRevision":"d3ea636dc5d16b56819f3266241e1f708979c233","data":{"AWAQGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAABZAAAAAAACAAAAAEBSAA":"BwAAAExTS1OSAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzJfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IF90bXBfMF9pblBvc2l0aW9uLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAAC3AgAAaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGYgZGlzdGFuY2VUb0lubmVyRWRnZSA9IGhhbGYoY2lyY2xlRWRnZS56ICogKGQgLSBjaXJjbGVFZGdlLncpKTsKCWhhbGYgaW5uZXJBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9Jbm5lckVkZ2UpOwoJZWRnZUFscGhhICo9IGlubmVyQWxwaGE7CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChlZGdlQWxwaGEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","HQJAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAABAAAAAAJBAMADCAB4QAAAAAEAAAAAAHEAAAAAAAIAAAAAQGIAAAAAA":"BwAAAExTS1PtAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdmxvY2FsQ29vcmRfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZsb2NhbENvb3JkX1N0YWdlMCA9IGxvY2FsQ29vcmQ7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cn0KAAAAAQAAAAAAAAABAAAAsAMAAHVuaWZvcm0gZmxvYXQ0IHVpbm5lclJlY3RfU3RhZ2UxX2MwOwp1bmlmb3JtIGhhbGYyIHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlckV4dGVybmFsT0VTIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJZmxvYXQyIGR4eTAgPSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5MVCAtIHNrX0ZyYWdDb29yZC54eTsKCWZsb2F0MiBkeHkxID0gc2tfRnJhZ0Nvb3JkLnh5IC0gdWlubmVyUmVjdF9TdGFnZTFfYzAuUkI7CglmbG9hdDIgZHh5ID0gbWF4KG1heChkeHkwLCBkeHkxKSwgMC4wKTsKCWhhbGYgYWxwaGEgPSBoYWxmKHNhdHVyYXRlKHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzAueCAtIGxlbmd0aChkeHkpKSk7CglyZXR1cm4gX2lucHV0ICogYWxwaGE7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7CglmbG9hdDIgdGV4Q29vcmQ7Cgl0ZXhDb29yZCA9IHZsb2NhbENvb3JkX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9ICgoc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdGV4Q29vcmQpICogaGFsZjQoMSkpKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","HQQAAAAAAAGAAAAAAIWP57ZDH37P7777777QCAAAAAAAAAAASABQAAAAEAAAAAAAEEBQAAA":"BwAAAExTS1PcAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7CmZsYXQgb3V0IGhhbGY0IHZjb2xvcl9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJc2tfUG9zaXRpb24gPSBwb3NpdGlvbi54eTAxOwp9CgAAAAAAAAAAAAAAAEUBAABmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgUAAABjb2xvcgAAAAEAAAAAAAAA","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAAAAHEAAAAAAAIAAAAAQGIAAAAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAIcCAABmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgR3JGaWxsUlJlY3RPcDo6UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJZmxvYXQgeF9wbHVzXzE9dmFyY2Nvb3JkX1N0YWdlMC54LCB5PXZhcmNjb29yZF9TdGFnZTAueTsKCWhhbGYgY292ZXJhZ2U7CglpZiAoMCA9PSB4X3BsdXNfMSkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKHkpOwoJfQoJZWxzZSAKCXsKCQlmbG9hdCBmbiA9IHhfcGx1c18xICogKHhfcGx1c18xIC0gMik7CgkJZm4gPSBmbWEoeSx5LCBmbik7CgkJZmxvYXQgZm53aWR0aCA9IGZ3aWR0aChmbik7CgkJY292ZXJhZ2UgPSAuNSAtIGhhbGYoZm4vZm53aWR0aCk7CgkJY292ZXJhZ2UgPSBjbGFtcChjb3ZlcmFnZSwgMCwgMSk7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChjb3ZlcmFnZSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAIAAAADgAAAHJhZGlpX3NlbGVjdG9yAAAZAAAAY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cwAAABUAAABhYV9ibG9hdF9hbmRfY292ZXJhZ2UAAAAEAAAAc2tldwkAAAB0cmFuc2xhdGUAAAAHAAAAcmFkaWlfeAAHAAAAcmFkaWlfeQAFAAAAY29sb3IAAAABAAAAAAAAAA==","HQJAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAABAAAAAABBAMAEQAMAAAABAAAAAAABBAMAAA":"BwAAAExTS1PtAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdmxvY2FsQ29vcmRfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZsb2NhbENvb3JkX1N0YWdlMCA9IGxvY2FsQ29vcmQ7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAAAA2wEAAHVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7CglmbG9hdDIgdGV4Q29vcmQ7Cgl0ZXhDb29yZCA9IHZsb2NhbENvb3JkX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9ICgoc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdGV4Q29vcmQpICogaGFsZjQoMSkpKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAABZAAAAAAACAAAAAEBSAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAmAgAAaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIBRAA5ZQUCGQFAAAMAAAAAAAAAAAAAA4QAAAAAABAAAAACAZAAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAACnBQAAY29uc3QgaW50IGtGaWxsQldfU3RhZ2UxX2MwID0gMDsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCA9IDI7CmNvbnN0IGludCBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzAgPSAzOwp1bmlmb3JtIGZsb2F0NCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwOwppbiBmbG9hdDQgdmluQ2lyY2xlRWRnZV9TdGFnZTA7CmluIGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJaGFsZjQgX3RtcF8wX2luQ29sb3IgPSBfaW5wdXQ7CgloYWxmIGNvdmVyYWdlOwoJaWYgKGludCgxKSA9PSBrRmlsbEJXX1N0YWdlMV9jMCB8fCBpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoYWxsKGdyZWF0ZXJUaGFuKGZsb2F0NChza19GcmFnQ29vcmQueHksIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAuencpLCBmbG9hdDQodXJlY3RVbmlmb3JtX1N0YWdlMV9jMC54eSwgc2tfRnJhZ0Nvb3JkLnh5KSkpID8gMSA6IDApOwoJfQoJZWxzZSAKCXsKCQloYWxmNCBkaXN0czQgPSBjbGFtcChoYWxmNCgxLjAsIDEuMCwgLTEuMCwgLTEuMCkgKiBoYWxmNChza19GcmFnQ29vcmQueHl4eSAtIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzApLCAwLjAsIDEuMCk7CgkJaGFsZjIgZGlzdHMyID0gKGRpc3RzNC54eSArIGRpc3RzNC56dykgLSAxLjA7CgkJY292ZXJhZ2UgPSBkaXN0czIueCAqIGRpc3RzMi55OwoJfQoJaWYgKGludCgxKSA9PSBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSAxLjAgLSBjb3ZlcmFnZTsKCX0KCXJldHVybiBoYWxmNChfaW5wdXQgKiBjb3ZlcmFnZSk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIENpcmNsZUdlb21ldHJ5UHJvY2Vzc29yCglmbG9hdDQgY2lyY2xlRWRnZTsKCWNpcmNsZUVkZ2UgPSB2aW5DaXJjbGVFZGdlX1N0YWdlMDsKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZpbkNvbG9yX1N0YWdlMDsKCWZsb2F0IGQgPSBsZW5ndGgoY2lyY2xlRWRnZS54eSk7CgloYWxmIGRpc3RhbmNlVG9PdXRlckVkZ2UgPSBoYWxmKGNpcmNsZUVkZ2UueiAqICgxLjAgLSBkKSk7CgloYWxmIGVkZ2VBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9PdXRlckVkZ2UpOwoJaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoZWRnZUFscGhhKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgABAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAAwAAABpbkNpcmNsZUVkZ2UBAAAAAAAAAA==","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBAYAAAAAAAAAIAAAACRRAAAAAAAAAQAAAABIECAAAACAAAAAZQIEBSAAAAAAAAAAAJAAYAAAACAAAAAAACCAY":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAACVBQAAdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMF9jMF9jMDsKdW5pZm9ybSBoYWxmMiB1XzBfSW5jcmVtZW50X1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBoYWxmNCB1XzFfS2VybmVsX1N0YWdlMV9jMF9jMFs0XTsKdW5pZm9ybSBoYWxmNCB1XzJfT2Zmc2V0c19TdGFnZTFfYzBfYzBbNF07CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMTsKaW4gZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoaGFsZjQgX2lucHV0LCBmbG9hdDIgX2Nvb3JkcykgCnsKCXJldHVybiBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxLCBfY29vcmRzKTsKfQpoYWxmNCBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwKGhhbGY0IF9pbnB1dCwgZmxvYXQyIF9jb29yZHMpIAp7CglyZXR1cm4gVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoX2lucHV0LCBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMF9jMF9jMCkgKiBfY29vcmRzLnh5MSk7Cn0KaGFsZjQgR2F1c3NpYW5Db252b2x1dGlvbl9TdGFnZTFfYzBfYzAoaGFsZjQgX2lucHV0KSAKewoJaGFsZjQgXzNfY29sb3IgPSBoYWxmNCgwLjApOwoJZmxvYXQyIF80X2Nvb3JkID0gdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwoJZm9yIChpbnQgXzVfaSA9IDA7IChfNV9pIDwgMTMpOyBfNV9pKyspIChfM19jb2xvciArPSAoTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMF9jMChfaW5wdXQsIChfNF9jb29yZCArIGZsb2F0MigodV8yX09mZnNldHNfU3RhZ2UxX2MwX2MwWyhfNV9pIC8gNCldWyhfNV9pICYgMyldICogdV8wX0luY3JlbWVudF9TdGFnZTFfYzBfYzApKSkpICogdV8xX0tlcm5lbF9TdGFnZTFfYzBfYzBbKF81X2kgLyA0KV1bKF81X2kgJiAzKV0pKTsKCXJldHVybiBfM19jb2xvcjsKfQpoYWxmNCBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCXJldHVybiBHYXVzc2lhbkNvbnZvbHV0aW9uX1N0YWdlMV9jMF9jMChfaW5wdXQpOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMCA9IGhhbGY0KDEpOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IE1hdHJpeEVmZmVjdF9TdGFnZTFfYzAob3V0cHV0Q29sb3JfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRfU3RhZ2UxICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24KAAAAbG9jYWxDb29yZAAAAQAAAAAAAAA=","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIBRAA5ZQUCGQFAAAMAAIAAAAAAAAAAA4QAAAAAABAAAAACAZAAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAACnBQAAY29uc3QgaW50IGtGaWxsQldfU3RhZ2UxX2MwID0gMDsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCA9IDI7CmNvbnN0IGludCBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzAgPSAzOwp1bmlmb3JtIGZsb2F0NCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwOwppbiBmbG9hdDQgdmluQ2lyY2xlRWRnZV9TdGFnZTA7CmluIGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJaGFsZjQgX3RtcF8wX2luQ29sb3IgPSBfaW5wdXQ7CgloYWxmIGNvdmVyYWdlOwoJaWYgKGludCgzKSA9PSBrRmlsbEJXX1N0YWdlMV9jMCB8fCBpbnQoMykgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoYWxsKGdyZWF0ZXJUaGFuKGZsb2F0NChza19GcmFnQ29vcmQueHksIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAuencpLCBmbG9hdDQodXJlY3RVbmlmb3JtX1N0YWdlMV9jMC54eSwgc2tfRnJhZ0Nvb3JkLnh5KSkpID8gMSA6IDApOwoJfQoJZWxzZSAKCXsKCQloYWxmNCBkaXN0czQgPSBjbGFtcChoYWxmNCgxLjAsIDEuMCwgLTEuMCwgLTEuMCkgKiBoYWxmNChza19GcmFnQ29vcmQueHl4eSAtIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzApLCAwLjAsIDEuMCk7CgkJaGFsZjIgZGlzdHMyID0gKGRpc3RzNC54eSArIGRpc3RzNC56dykgLSAxLjA7CgkJY292ZXJhZ2UgPSBkaXN0czIueCAqIGRpc3RzMi55OwoJfQoJaWYgKGludCgzKSA9PSBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDMpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSAxLjAgLSBjb3ZlcmFnZTsKCX0KCXJldHVybiBoYWxmNChfaW5wdXQgKiBjb3ZlcmFnZSk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIENpcmNsZUdlb21ldHJ5UHJvY2Vzc29yCglmbG9hdDQgY2lyY2xlRWRnZTsKCWNpcmNsZUVkZ2UgPSB2aW5DaXJjbGVFZGdlX1N0YWdlMDsKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZpbkNvbG9yX1N0YWdlMDsKCWZsb2F0IGQgPSBsZW5ndGgoY2lyY2xlRWRnZS54eSk7CgloYWxmIGRpc3RhbmNlVG9PdXRlckVkZ2UgPSBoYWxmKGNpcmNsZUVkZ2UueiAqICgxLjAgLSBkKSk7CgloYWxmIGVkZ2VBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9PdXRlckVkZ2UpOwoJaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoZWRnZUFscGhhKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgABAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAAwAAABpbkNpcmNsZUVkZ2UBAAAAAAAAAA==","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAA2IAOAAACAAAAAAASABQAAAAEAAAAAAAEEBQ":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAAFMEAAB1bmlmb3JtIGZsb2F0NCB1aW5uZXJSZWN0X1N0YWdlMV9jMDsKdW5pZm9ybSBoYWxmMiB1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdDIgZHh5MCA9IHVpbm5lclJlY3RfU3RhZ2UxX2MwLkxUIC0gc2tfRnJhZ0Nvb3JkLnh5OwoJZmxvYXQyIGR4eTEgPSBza19GcmFnQ29vcmQueHkgLSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5SQjsKCWZsb2F0MiBkeHkgPSBtYXgobWF4KGR4eTAsIGR4eTEpLCAwLjApOwoJaGFsZiBhbHBoYSA9IGhhbGYoc2F0dXJhdGUodXJhZGl1c1BsdXNIYWxmX1N0YWdlMV9jMC54IC0gbGVuZ3RoKGR4eSkpKTsKCXJldHVybiBfaW5wdXQgKiBhbHBoYTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgR3JGaWxsUlJlY3RPcDo6UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJZmxvYXQgeF9wbHVzXzE9dmFyY2Nvb3JkX1N0YWdlMC54LCB5PXZhcmNjb29yZF9TdGFnZTAueTsKCWhhbGYgY292ZXJhZ2U7CglpZiAoMCA9PSB4X3BsdXNfMSkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKHkpOwoJfQoJZWxzZSAKCXsKCQlmbG9hdCBmbiA9IHhfcGx1c18xICogKHhfcGx1c18xIC0gMik7CgkJZm4gPSBmbWEoeSx5LCBmbik7CgkJZmxvYXQgZm53aWR0aCA9IGZ3aWR0aChmbik7CgkJY292ZXJhZ2UgPSAuNSAtIGhhbGYoZm4vZm53aWR0aCk7CgkJY292ZXJhZ2UgPSBjbGFtcChjb3ZlcmFnZSwgMCwgMSk7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChjb3ZlcmFnZSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKG91dHB1dENvdmVyYWdlX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0X1N0YWdlMTsKCX0KfQoAAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAACAAAAA4AAAByYWRpaV9zZWxlY3RvcgAAGQAAAGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHMAAAAVAAAAYWFfYmxvYXRfYW5kX2NvdmVyYWdlAAAABAAAAHNrZXcJAAAAdHJhbnNsYXRlAAAABwAAAHJhZGlpX3gABwAAAHJhZGlpX3kABQAAAGNvbG9yAAAAAQAAAAAAAAA=","C4QAAAAAMAAAAABAYAROFA2CAIAAAABAAAAAAAAAAAACAMMAPNMJARQBAAADAAAAAAAAAAAAIAHAAAAAQAAAAAAAQQGAAAAA":"BwAAAExTS1M2AgAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQyIHVBdGxhc1NpemVJbnZfU3RhZ2UwOwppbiBmbG9hdDIgaW5Qb3NpdGlvbjsKaW4gaGFsZjQgaW5Db2xvcjsKaW4gdXNob3J0MiBpblRleHR1cmVDb29yZHM7Cm91dCBmbG9hdDIgdlRleHR1cmVDb29yZHNfU3RhZ2UwOwpmbGF0IG91dCBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBCaXRtYXBUZXh0CglpbnQgdGV4SWR4ID0gMDsKCWZsb2F0MiB1bm9ybVRleENvb3JkcyA9IGZsb2F0MihpblRleHR1cmVDb29yZHMueCwgaW5UZXh0dXJlQ29vcmRzLnkpOwoJdlRleHR1cmVDb29yZHNfU3RhZ2UwID0gdW5vcm1UZXhDb29yZHMgKiB1QXRsYXNTaXplSW52X1N0YWdlMDsKCXZUZXhJbmRleF9TdGFnZTAgPSBmbG9hdCh0ZXhJZHgpOwoJdmluQ29sb3JfU3RhZ2UwID0gaW5Db2xvcjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IGluUG9zaXRpb24ueHkwMTsKfQoAAAEAAAAAAAAAAQAAAHoFAABjb25zdCBpbnQga0ZpbGxCV19TdGFnZTFfYzAgPSAwOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwID0gMjsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCA9IDM7CnVuaWZvcm0gZmxvYXQ0IHVyZWN0VW5pZm9ybV9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZUZXh0dXJlQ29vcmRzX1N0YWdlMDsKZmxhdCBpbiBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF90bXBfMF9pbkNvbG9yID0gX2lucHV0OwoJaGFsZiBjb3ZlcmFnZTsKCWlmIChpbnQoMSkgPT0ga0ZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKGFsbChncmVhdGVyVGhhbihmbG9hdDQoc2tfRnJhZ0Nvb3JkLnh5LCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwLnp3KSwgZmxvYXQ0KHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAueHksIHNrX0ZyYWdDb29yZC54eSkpKSA/IDEgOiAwKTsKCX0KCWVsc2UgCgl7CgkJaGFsZjQgZGlzdHM0ID0gY2xhbXAoaGFsZjQoMS4wLCAxLjAsIC0xLjAsIC0xLjApICogaGFsZjQoc2tfRnJhZ0Nvb3JkLnh5eHkgLSB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwKSwgMC4wLCAxLjApOwoJCWhhbGYyIGRpc3RzMiA9IChkaXN0czQueHkgKyBkaXN0czQuencpIC0gMS4wOwoJCWNvdmVyYWdlID0gZGlzdHMyLnggKiBkaXN0czIueTsKCX0KCWlmIChpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwIHx8IGludCgxKSA9PSBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzApIAoJewoJCWNvdmVyYWdlID0gMS4wIC0gY292ZXJhZ2U7Cgl9CglyZXR1cm4gaGFsZjQoX2lucHV0ICogY292ZXJhZ2UpOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBCaXRtYXBUZXh0CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CgloYWxmNCB0ZXhDb2xvcjsKCXsKCQl0ZXhDb2xvciA9IHNhbXBsZSh1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTAsIHZUZXh0dXJlQ29vcmRzX1N0YWdlMCkucnJycjsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IHRleENvbG9yOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBSZWN0X1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAAABAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAA8AAABpblRleHR1cmVDb29yZHMAAQAAAAAAAAA=","BUAAQAAAAQAAAAABC3777777777QAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1PDAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBEZWZhdWx0R2VvbWV0cnlQcm9jZXNzb3IKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IF90bXBfMV9pblBvc2l0aW9uLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAD8BAAB1bmlmb3JtIGhhbGY0IHVDb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBEZWZhdWx0R2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHVDb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAKAAAAaW5Qb3NpdGlvbgAAAQAAAAAAAAA=","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBACAABAAAAAMYECAZAAEAAAAAAAAEQAMAAAABAAAAAAABBAMAAAAA":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADsAwAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMDsKdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxOwppbiBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBUZXh0dXJlRWZmZWN0X1N0YWdlMV9jMF9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdDIgaW5Db29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZC54ID0gY2xhbXAoc3Vic2V0Q29vcmQueCwgdWNsYW1wX1N0YWdlMV9jMF9jMC54LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwLnopOwoJY2xhbXBlZENvb3JkLnkgPSBzdWJzZXRDb29yZC55OwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0X1N0YWdlMSAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACAAAAHBvc2l0aW9uCgAAAGxvY2FsQ29vcmQAAAEAAAAAAAAA","CIRQAAAABCYIR6AIWAMMAAAAAAAAAAAAABAA4AAAACAAAAAAACCAYAAAAA":"BwAAAExTS1NWAgAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDIgaW5FbGxpcHNlT2Zmc2V0OwppbiBmbG9hdDQgaW5FbGxpcHNlUmFkaWk7Cm91dCBmbG9hdDIgdkVsbGlwc2VPZmZzZXRzX1N0YWdlMDsKb3V0IGZsb2F0NCB2RWxsaXBzZVJhZGlpX1N0YWdlMDsKb3V0IGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgRWxsaXBzZUdlb21ldHJ5UHJvY2Vzc29yCgl2RWxsaXBzZU9mZnNldHNfU3RhZ2UwID0gaW5FbGxpcHNlT2Zmc2V0OwoJdkVsbGlwc2VSYWRpaV9TdGFnZTAgPSBpbkVsbGlwc2VSYWRpaTsKCXZpbkNvbG9yX1N0YWdlMCA9IGluQ29sb3I7CglmbG9hdDIgX3RtcF8wX2luUG9zaXRpb24gPSBpblBvc2l0aW9uOwoJZmxvYXQyIF90bXBfMV9pblBvc2l0aW9uID0gdWxvY2FsTWF0cml4X1N0YWdlMC54eiAqIGluUG9zaXRpb24gKyB1bG9jYWxNYXRyaXhfU3RhZ2UwLnl3OwoJc2tfUG9zaXRpb24gPSBfdG1wXzBfaW5Qb3NpdGlvbi54eTAxOwp9CgAAAAAAAAAAAAAAAAAAxgMAAGluIGZsb2F0MiB2RWxsaXBzZU9mZnNldHNfU3RhZ2UwOwppbiBmbG9hdDQgdkVsbGlwc2VSYWRpaV9TdGFnZTA7CmluIGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIEVsbGlwc2VHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJZmxvYXQyIG9mZnNldCA9IHZFbGxpcHNlT2Zmc2V0c19TdGFnZTAueHk7CglvZmZzZXQgKj0gdkVsbGlwc2VSYWRpaV9TdGFnZTAueHk7CglmbG9hdCB0ZXN0ID0gZG90KG9mZnNldCwgb2Zmc2V0KSAtIDEuMDsKCWZsb2F0MiBncmFkID0gMi4wKm9mZnNldCp2RWxsaXBzZVJhZGlpX1N0YWdlMC54eTsKCWZsb2F0IGdyYWRfZG90ID0gZG90KGdyYWQsIGdyYWQpOwoJZ3JhZF9kb3QgPSBtYXgoZ3JhZF9kb3QsIDEuMTc1NWUtMzgpOwoJZmxvYXQgaW52bGVuID0gaW52ZXJzZXNxcnQoZ3JhZF9kb3QpOwoJZmxvYXQgZWRnZUFscGhhID0gc2F0dXJhdGUoMC41LXRlc3QqaW52bGVuKTsKCW9mZnNldCA9IHZFbGxpcHNlT2Zmc2V0c19TdGFnZTAueHkqdkVsbGlwc2VSYWRpaV9TdGFnZTAuenc7Cgl0ZXN0ID0gZG90KG9mZnNldCwgb2Zmc2V0KSAtIDEuMDsKCWdyYWQgPSAyLjAqb2Zmc2V0KnZFbGxpcHNlUmFkaWlfU3RhZ2UwLnp3OwoJZ3JhZF9kb3QgPSBkb3QoZ3JhZCwgZ3JhZCk7CglpbnZsZW4gPSBpbnZlcnNlc3FydChncmFkX2RvdCk7CgllZGdlQWxwaGEgKj0gc2F0dXJhdGUoMC41K3Rlc3QqaW52bGVuKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGhhbGYoZWRnZUFscGhhKSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAABAAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAPAAAAaW5FbGxpcHNlT2Zmc2V0AA4AAABpbkVsbGlwc2VSYWRpaQAAAQAAAAAAAAA=","C4QAAAAAMAAAAABAYAROFA2CAIAAAABAAAAAAAAAAAACABUQA4AAAEAAAAAABEADAAAAAIAAAAAAAIIDAAAA":"BwAAAExTS1M2AgAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQyIHVBdGxhc1NpemVJbnZfU3RhZ2UwOwppbiBmbG9hdDIgaW5Qb3NpdGlvbjsKaW4gaGFsZjQgaW5Db2xvcjsKaW4gdXNob3J0MiBpblRleHR1cmVDb29yZHM7Cm91dCBmbG9hdDIgdlRleHR1cmVDb29yZHNfU3RhZ2UwOwpmbGF0IG91dCBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBCaXRtYXBUZXh0CglpbnQgdGV4SWR4ID0gMDsKCWZsb2F0MiB1bm9ybVRleENvb3JkcyA9IGZsb2F0MihpblRleHR1cmVDb29yZHMueCwgaW5UZXh0dXJlQ29vcmRzLnkpOwoJdlRleHR1cmVDb29yZHNfU3RhZ2UwID0gdW5vcm1UZXhDb29yZHMgKiB1QXRsYXNTaXplSW52X1N0YWdlMDsKCXZUZXhJbmRleF9TdGFnZTAgPSBmbG9hdCh0ZXhJZHgpOwoJdmluQ29sb3JfU3RhZ2UwID0gaW5Db2xvcjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IGluUG9zaXRpb24ueHkwMTsKfQoAAAEAAAAAAAAAAQAAAMUDAAB1bmlmb3JtIGZsb2F0NCB1aW5uZXJSZWN0X1N0YWdlMV9jMDsKdW5pZm9ybSBoYWxmMiB1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwOwp1bmlmb3JtIHNhbXBsZXIyRCB1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTA7CmluIGZsb2F0MiB2VGV4dHVyZUNvb3Jkc19TdGFnZTA7CmZsYXQgaW4gZmxvYXQgdlRleEluZGV4X1N0YWdlMDsKaW4gaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdDIgZHh5MCA9IHVpbm5lclJlY3RfU3RhZ2UxX2MwLkxUIC0gc2tfRnJhZ0Nvb3JkLnh5OwoJZmxvYXQyIGR4eTEgPSBza19GcmFnQ29vcmQueHkgLSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5SQjsKCWZsb2F0MiBkeHkgPSBtYXgobWF4KGR4eTAsIGR4eTEpLCAwLjApOwoJaGFsZiBhbHBoYSA9IGhhbGYoc2F0dXJhdGUodXJhZGl1c1BsdXNIYWxmX1N0YWdlMV9jMC54IC0gbGVuZ3RoKGR4eSkpKTsKCXJldHVybiBfaW5wdXQgKiBhbHBoYTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgQml0bWFwVGV4dAoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJaGFsZjQgdGV4Q29sb3I7Cgl7CgkJdGV4Q29sb3IgPSBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwLCB2VGV4dHVyZUNvb3Jkc19TdGFnZTApLnJycnI7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSB0ZXhDb2xvcjsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAMAAAAKAAAAaW5Qb3NpdGlvbgAABwAAAGluQ29sb3IADwAAAGluVGV4dHVyZUNvb3JkcwABAAAAAAAAAA==","HSQACAAAAAGAAAAAAIWAAKRCH37P6BZQ737QCAAAAAAAAAAASABQAAAAEAAAAAAAEEBQAAA":"BwAAAExTS1OlAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQgY292ZXJhZ2U7CmluIGhhbGY0IGNvbG9yOwppbiBmbG9hdDQgZ2VvbVN1YnNldDsKZmxhdCBvdXQgaGFsZjQgdmNvbG9yX1N0YWdlMDsKb3V0IGZsb2F0IHZjb3ZlcmFnZV9TdGFnZTA7CmZsYXQgb3V0IGZsb2F0NCB2Z2VvbVN1YnNldF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQyIHBvc2l0aW9uID0gcG9zaXRpb24ueHk7Cgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7Cgl2Y292ZXJhZ2VfU3RhZ2UwID0gY292ZXJhZ2U7Cgl2Z2VvbVN1YnNldF9TdGFnZTAgPSBnZW9tU3Vic2V0OwoJc2tfUG9zaXRpb24gPSBwb3NpdGlvbi54eTAxOwp9CgAAAAEAAAAAAAAAAQAAAL0CAABmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0IHZjb3ZlcmFnZV9TdGFnZTA7CmZsYXQgaW4gZmxvYXQ0IHZnZW9tU3Vic2V0X1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmNvbG9yX1N0YWdlMDsKCWZsb2F0IGNvdmVyYWdlID0gdmNvdmVyYWdlX1N0YWdlMDsKCWZsb2F0NCBnZW9TdWJzZXQ7CglnZW9TdWJzZXQgPSB2Z2VvbVN1YnNldF9TdGFnZTA7CgloYWxmNCBkaXN0czQgPSBjbGFtcChoYWxmNCgxLCAxLCAtMSwgLTEpICogaGFsZjQoc2tfRnJhZ0Nvb3JkLnh5eHkgLSBnZW9TdWJzZXQpLCAwLCAxKTsKCWhhbGYyIGRpc3RzMiA9IGRpc3RzNC54eSArIGRpc3RzNC56dyAtIDE7CgloYWxmIHN1YnNldENvdmVyYWdlID0gZGlzdHMyLnggKiBkaXN0czIueTsKCWNvdmVyYWdlID0gbWluKGNvdmVyYWdlLCBzdWJzZXRDb3ZlcmFnZSk7CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChoYWxmKGNvdmVyYWdlKSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAQAAAAIAAAAcG9zaXRpb24IAAAAY292ZXJhZ2UFAAAAY29sb3IAAAAKAAAAZ2VvbVN1YnNldAAAAQAAAAAAAAA=","E5YQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAAAAHEAAAAAAAIAAAAAQGIAAAAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDA7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAK0CAABmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgR3JGaWxsUlJlY3RPcDo6UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJZmxvYXQgeF9wbHVzXzE9dmFyY2Nvb3JkX1N0YWdlMC54LCB5PXZhcmNjb29yZF9TdGFnZTAueTsKCWhhbGYgY292ZXJhZ2U7CglpZiAoMCA9PSB4X3BsdXNfMSkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKHkpOwoJfQoJZWxzZSAKCXsKCQlmbG9hdCBmbiA9IHhfcGx1c18xICogKHhfcGx1c18xIC0gMik7CgkJZm4gPSBmbWEoeSx5LCBmbik7CgkJZmxvYXQgZm53aWR0aCA9IGZ3aWR0aChmbik7CgkJY292ZXJhZ2UgPSAuNSAtIGhhbGYoZm4vZm53aWR0aCk7CgkJY292ZXJhZ2UgPSBjbGFtcChjb3ZlcmFnZSwgMCwgMSk7Cgl9Cgljb3ZlcmFnZSA9IChjb3ZlcmFnZSA+PSAuNSkgPyAxIDogMDsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGNvdmVyYWdlKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAACAAAAA4AAAByYWRpaV9zZWxlY3RvcgAAGQAAAGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHMAAAAVAAAAYWFfYmxvYXRfYW5kX2NvdmVyYWdlAAAABAAAAHNrZXcJAAAAdHJhbnNsYXRlAAAABwAAAHJhZGlpX3gABwAAAHJhZGlpX3kABQAAAGNvbG9yAAAAAQAAAAAAAAA=","C4QAAAAAMAAAAABAYAROFA2CAIAAAABAAAAAAAAAAAAAAOIAAAAAAAQAAAABAMQA":"BwAAAExTS1M2AgAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQyIHVBdGxhc1NpemVJbnZfU3RhZ2UwOwppbiBmbG9hdDIgaW5Qb3NpdGlvbjsKaW4gaGFsZjQgaW5Db2xvcjsKaW4gdXNob3J0MiBpblRleHR1cmVDb29yZHM7Cm91dCBmbG9hdDIgdlRleHR1cmVDb29yZHNfU3RhZ2UwOwpmbGF0IG91dCBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBCaXRtYXBUZXh0CglpbnQgdGV4SWR4ID0gMDsKCWZsb2F0MiB1bm9ybVRleENvb3JkcyA9IGZsb2F0MihpblRleHR1cmVDb29yZHMueCwgaW5UZXh0dXJlQ29vcmRzLnkpOwoJdlRleHR1cmVDb29yZHNfU3RhZ2UwID0gdW5vcm1UZXhDb29yZHMgKiB1QXRsYXNTaXplSW52X1N0YWdlMDsKCXZUZXhJbmRleF9TdGFnZTAgPSBmbG9hdCh0ZXhJZHgpOwoJdmluQ29sb3JfU3RhZ2UwID0gaW5Db2xvcjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IGluUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAAPkBAAB1bmlmb3JtIHNhbXBsZXIyRCB1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTA7CmluIGZsb2F0MiB2VGV4dHVyZUNvb3Jkc19TdGFnZTA7CmZsYXQgaW4gZmxvYXQgdlRleEluZGV4X1N0YWdlMDsKaW4gaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgQml0bWFwVGV4dAoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJaGFsZjQgdGV4Q29sb3I7Cgl7CgkJdGV4Q29sb3IgPSBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwLCB2VGV4dHVyZUNvb3Jkc19TdGFnZTApLnJycnI7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSB0ZXhDb2xvcjsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAPAAAAaW5UZXh0dXJlQ29vcmRzAAEAAAAAAAAA","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAA2IAOAAAQB5VRECGAEAAAMAAAAAEAAAAABAIZYA6C2SFCAAAAAGAAAAAAAAAAAEQAMAAAABAAAAAAABBAMAAAAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAAMUHAABjb25zdCBpbnQga0ZpbGxCV19TdGFnZTFfYzBfYzAgPSAwOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwX2MwID0gMjsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMF9jMCA9IDM7CnVuaWZvcm0gZmxvYXQ0IHVyZWN0VW5pZm9ybV9TdGFnZTFfYzBfYzA7CnVuaWZvcm0gZmxvYXQ0IHVpbm5lclJlY3RfU3RhZ2UxX2MwOwp1bmlmb3JtIGhhbGYyIHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzA7CmZsYXQgaW4gaGFsZjQgdmNvbG9yX1N0YWdlMDsKaW4gZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFJlY3RfU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF90bXBfMF9pbkNvbG9yID0gX2lucHV0OwoJaGFsZiBjb3ZlcmFnZTsKCWlmIChpbnQoMSkgPT0ga0ZpbGxCV19TdGFnZTFfYzBfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMF9jMCkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKGFsbChncmVhdGVyVGhhbihmbG9hdDQoc2tfRnJhZ0Nvb3JkLnh5LCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwX2MwLnp3KSwgZmxvYXQ0KHVyZWN0VW5pZm9ybV9TdGFnZTFfYzBfYzAueHksIHNrX0ZyYWdDb29yZC54eSkpKSA/IDEgOiAwKTsKCX0KCWVsc2UgCgl7CgkJaGFsZjQgZGlzdHM0ID0gY2xhbXAoaGFsZjQoMS4wLCAxLjAsIC0xLjAsIC0xLjApICogaGFsZjQoc2tfRnJhZ0Nvb3JkLnh5eHkgLSB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwX2MwKSwgMC4wLCAxLjApOwoJCWhhbGYyIGRpc3RzMiA9IChkaXN0czQueHkgKyBkaXN0czQuencpIC0gMS4wOwoJCWNvdmVyYWdlID0gZGlzdHMyLnggKiBkaXN0czIueTsKCX0KCWlmIChpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwX2MwIHx8IGludCgxKSA9PSBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzBfYzApIAoJewoJCWNvdmVyYWdlID0gMS4wIC0gY292ZXJhZ2U7Cgl9CglyZXR1cm4gaGFsZjQoX2lucHV0ICogY292ZXJhZ2UpOwp9CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIFJlY3RfU3RhZ2UxX2MwX2MwKF9pbnB1dCkgKiBhbHBoYTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgR3JGaWxsUlJlY3RPcDo6UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJZmxvYXQgeF9wbHVzXzE9dmFyY2Nvb3JkX1N0YWdlMC54LCB5PXZhcmNjb29yZF9TdGFnZTAueTsKCWhhbGYgY292ZXJhZ2U7CglpZiAoMCA9PSB4X3BsdXNfMSkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKHkpOwoJfQoJZWxzZSAKCXsKCQlmbG9hdCBmbiA9IHhfcGx1c18xICogKHhfcGx1c18xIC0gMik7CgkJZm4gPSBmbWEoeSx5LCBmbik7CgkJZmxvYXQgZm53aWR0aCA9IGZ3aWR0aChmbik7CgkJY292ZXJhZ2UgPSAuNSAtIGhhbGYoZm4vZm53aWR0aCk7CgkJY292ZXJhZ2UgPSBjbGFtcChjb3ZlcmFnZSwgMCwgMSk7Cgl9CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChjb3ZlcmFnZSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKG91dHB1dENvdmVyYWdlX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0X1N0YWdlMTsKCX0KfQoAAAABAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAIAAAADgAAAHJhZGlpX3NlbGVjdG9yAAAZAAAAY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cwAAABUAAABhYV9ibG9hdF9hbmRfY292ZXJhZ2UAAAAEAAAAc2tldwkAAAB0cmFuc2xhdGUAAAAHAAAAcmFkaWlfeAAHAAAAcmFkaWlfeQAFAAAAY29sb3IAAAABAAAAAAAAAA==","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIAGGADYAAAQAAAAAAAQAOAAAABAAAAAAABBAMAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAAAIBAAAdW5pZm9ybSBmbG9hdDQgdWlubmVyUmVjdF9TdGFnZTFfYzA7CnVuaWZvcm0gaGFsZjIgdXJhZGl1c1BsdXNIYWxmX1N0YWdlMV9jMDsKaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJYWxwaGEgPSAxLjAgLSBhbHBoYTsKCXJldHVybiBfaW5wdXQgKiBhbHBoYTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgQ2lyY2xlR2VvbWV0cnlQcm9jZXNzb3IKCWZsb2F0NCBjaXJjbGVFZGdlOwoJY2lyY2xlRWRnZSA9IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJZmxvYXQgZCA9IGxlbmd0aChjaXJjbGVFZGdlLnh5KTsKCWhhbGYgZGlzdGFuY2VUb091dGVyRWRnZSA9IGhhbGYoY2lyY2xlRWRnZS56ICogKDEuMCAtIGQpKTsKCWhhbGYgZWRnZUFscGhhID0gc2F0dXJhdGUoZGlzdGFuY2VUb091dGVyRWRnZSk7CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChlZGdlQWxwaGEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAFIAA2AAAAAAAAAQAAAACUAQACAAAAABQIMACCAYAAAAAAAAAAAADSAAAAAAAEAAAAAIDEAAAAA":"BwAAAExTS1NSAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzBfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzNfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfM19TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMF9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAAAAAEQDAAB1bmlmb3JtIGZsb2F0M3gzIHVtYXRyaXhfU3RhZ2UxX2MwX2MwOwp1bmlmb3JtIHNhbXBsZXIyRCB1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTE7CmluIGZsb2F0MiB2VHJhbnNmb3JtZWRDb29yZHNfM19TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCXJldHVybiBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxLCB2VHJhbnNmb3JtZWRDb29yZHNfM19TdGFnZTApOwp9CmhhbGY0IE1hdHJpeEVmZmVjdF9TdGFnZTFfYzBfYzAoaGFsZjQgX2lucHV0KSAKewoJcmV0dXJuIFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwKF9pbnB1dCk7Cn0KaGFsZjQgQmxlbmRfU3RhZ2UxX2MwKGhhbGY0IF9zcmMsIGhhbGY0IF9kc3QpIAp7CgkvLyBCbGVuZCBtb2RlOiBNb2R1bGF0ZQoJcmV0dXJuIGJsZW5kX21vZHVsYXRlKE1hdHJpeEVmZmVjdF9TdGFnZTFfYzBfYzAoX3NyYyksIF9zcmMpOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMCA9IGhhbGY0KDEpOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IEJsZW5kX1N0YWdlMV9jMChvdXRwdXRDb2xvcl9TdGFnZTAsIGhhbGY0KDEpKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRfU3RhZ2UxICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24KAAAAbG9jYWxDb29yZAAAAQAAAAAAAAA=","BWIASAAAAQAAAAABCYIR7777AAOAAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1NPAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBoYWxmIGluQ292ZXJhZ2U7Cm91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBEZWZhdWx0R2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IGNvbG9yID0gaW5Db2xvcjsKCWNvbG9yID0gY29sb3IgKiBpbkNvdmVyYWdlOwoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQyIF90bXBfMV9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAOgEAAGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBEZWZhdWx0R2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAAoAAABpbkNvdmVyYWdlAAABAAAAAAAAAA==","HRJAAAAAAAMAAAAAARMPZ72HPQCFR7H7777QGAAAAACAAAAAACCAYABAA4AAAACAAAAAAACCAYAAA":"BwAAAExTS1MzAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7CmluIGZsb2F0MiBsb2NhbENvb3JkOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7Cgl2bG9jYWxDb29yZF9TdGFnZTAgPSBsb2NhbENvb3JkOwoJc2tfUG9zaXRpb24gPSBwb3NpdGlvbi54eTAxOwp9CgAAAAAAAAAAAAAAAAAcAgAAdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2bG9jYWxDb29yZF9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7CglmbG9hdDIgdGV4Q29vcmQ7Cgl0ZXhDb29yZCA9IHZsb2NhbENvb3JkX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9ICgoc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdGV4Q29vcmQpICogb3V0cHV0Q29sb3JfU3RhZ2UwKSk7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAgAAABwb3NpdGlvbgUAAABjb2xvcgAAAAoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","AWTQGAAAQAAIXCEPAEGACDYBR7777737AAAAAAAAAAAABZAAAAAAACAAAAAEBSAA":"BwAAAExTS1OeAgAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwppbiBoYWxmMyBpbkNsaXBQbGFuZTsKaW4gaGFsZjMgaW5Jc2VjdFBsYW5lOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjMgdmluQ2xpcFBsYW5lX1N0YWdlMDsKb3V0IGhhbGYzIHZpbklzZWN0UGxhbmVfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5DbGlwUGxhbmVfU3RhZ2UwID0gaW5DbGlwUGxhbmU7Cgl2aW5Jc2VjdFBsYW5lX1N0YWdlMCA9IGluSXNlY3RQbGFuZTsKCXZpbkNvbG9yX1N0YWdlMCA9IGluQ29sb3I7CglmbG9hdDIgX3RtcF8wX2luUG9zaXRpb24gPSBpblBvc2l0aW9uOwoJZmxvYXQyIF90bXBfMV9pblBvc2l0aW9uID0gdWxvY2FsTWF0cml4X1N0YWdlMC54eiAqIGluUG9zaXRpb24gKyB1bG9jYWxNYXRyaXhfU3RhZ2UwLnl3OwoJc2tfUG9zaXRpb24gPSBfdG1wXzBfaW5Qb3NpdGlvbi54eTAxOwp9CgAAAAAAAAAAAAAAAAAAKQQAAGluIGZsb2F0NCB2aW5DaXJjbGVFZGdlX1N0YWdlMDsKaW4gaGFsZjMgdmluQ2xpcFBsYW5lX1N0YWdlMDsKaW4gaGFsZjMgdmluSXNlY3RQbGFuZV9TdGFnZTA7CmluIGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIENpcmNsZUdlb21ldHJ5UHJvY2Vzc29yCglmbG9hdDQgY2lyY2xlRWRnZTsKCWNpcmNsZUVkZ2UgPSB2aW5DaXJjbGVFZGdlX1N0YWdlMDsKCWhhbGYzIGNsaXBQbGFuZTsKCWNsaXBQbGFuZSA9IHZpbkNsaXBQbGFuZV9TdGFnZTA7CgloYWxmMyBpc2VjdFBsYW5lOwoJaXNlY3RQbGFuZSA9IHZpbklzZWN0UGxhbmVfU3RhZ2UwOwoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJZmxvYXQgZCA9IGxlbmd0aChjaXJjbGVFZGdlLnh5KTsKCWhhbGYgZGlzdGFuY2VUb091dGVyRWRnZSA9IGhhbGYoY2lyY2xlRWRnZS56ICogKDEuMCAtIGQpKTsKCWhhbGYgZWRnZUFscGhhID0gc2F0dXJhdGUoZGlzdGFuY2VUb091dGVyRWRnZSk7CgloYWxmIGRpc3RhbmNlVG9Jbm5lckVkZ2UgPSBoYWxmKGNpcmNsZUVkZ2UueiAqIChkIC0gY2lyY2xlRWRnZS53KSk7CgloYWxmIGlubmVyQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvSW5uZXJFZGdlKTsKCWVkZ2VBbHBoYSAqPSBpbm5lckFscGhhOwoJaGFsZiBjbGlwID0gaGFsZihzYXR1cmF0ZShjaXJjbGVFZGdlLnogKiBkb3QoY2lyY2xlRWRnZS54eSwgY2xpcFBsYW5lLnh5KSArIGNsaXBQbGFuZS56KSk7CgljbGlwICo9IGhhbGYoc2F0dXJhdGUoY2lyY2xlRWRnZS56ICogZG90KGNpcmNsZUVkZ2UueHksIGlzZWN0UGxhbmUueHkpICsgaXNlY3RQbGFuZS56KSk7CgllZGdlQWxwaGEgKj0gY2xpcDsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAUAAAAKAAAAaW5Qb3NpdGlvbgAABwAAAGluQ29sb3IADAAAAGluQ2lyY2xlRWRnZQsAAABpbkNsaXBQbGFuZQAMAAAAaW5Jc2VjdFBsYW5lAQAAAAAAAAA=","GABQAAAAAELBCHYCDYAAAAAAAEAAAACAIQAABSABAAAAAEAAAAAIBEABAA":"BwAAAExTS1NkAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBoYWxmMyBpblNoYWRvd1BhcmFtczsKb3V0IGhhbGYzIHZpblNoYWRvd1BhcmFtc19TdGFnZTA7Cm91dCBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIFJSZWN0U2hhZG93Cgl2aW5TaGFkb3dQYXJhbXNfU3RhZ2UwID0gaW5TaGFkb3dQYXJhbXM7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAABPAgAAdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwOwppbiBoYWxmMyB2aW5TaGFkb3dQYXJhbXNfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBSUmVjdFNoYWRvdwoJaGFsZjMgc2hhZG93UGFyYW1zOwoJc2hhZG93UGFyYW1zID0gdmluU2hhZG93UGFyYW1zX1N0YWdlMDsKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZpbkNvbG9yX1N0YWdlMDsKCWhhbGYgZCA9IGxlbmd0aChzaGFkb3dQYXJhbXMueHkpOwoJZmxvYXQyIHV2ID0gZmxvYXQyKHNoYWRvd1BhcmFtcy56ICogKDEuMCAtIGQpLCAwLjUpOwoJaGFsZiBmYWN0b3IgPSBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwLCB1dikuMDAwci5hOwoJaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoZmFjdG9yKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAMAAAAKAAAAaW5Qb3NpdGlvbgAABwAAAGluQ29sb3IADgAAAGluU2hhZG93UGFyYW1zAAABAAAAAAAAAA==","HQQACAAAAAGAAAAAAIWP57ZDH37P7777777QCAAAAAAAAAAAMIAHSAAAAAAQAAAAAA4QAAAAAABAAAAACAZAAAAAAA":"BwAAAExTS1PXAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7Cm91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZjb2xvcl9TdGFnZTAgPSBjb2xvcjsKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKfQoAAQAAAAAAAAABAAAADAMAAHVuaWZvcm0gZmxvYXQ0IHVpbm5lclJlY3RfU3RhZ2UxX2MwOwp1bmlmb3JtIGhhbGYyIHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzA7CmluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIF9pbnB1dCAqIGFscGhhOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24FAAAAY29sb3IAAAABAAAAAAAAAA==","HRIAAAAAAAMAAAAAARMPZ72HPQCFR7H7777QGAAAAAAAAAAAIQCQAAACAAAAAZQIAAAAAAAAAAAAAABAA4AAAACAAAAAAACCAYAAAAA":"BwAAAExTS1OSAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7CmluIGZsb2F0MiBsb2NhbENvb3JkOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cgl7CgkJdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwID0gZmxvYXQzeDIodW1hdHJpeF9TdGFnZTFfYzApICogbG9jYWxDb29yZC54eTE7Cgl9Cn0KAAAAAAAAAAAAAAAAAADmAgAAdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMDsKdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCXJldHVybiBzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxLCB2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTApLnJycnI7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IE1hdHJpeEVmZmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAgAAABwb3NpdGlvbgUAAABjb2xvcgAAAAoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","BWABSAAAAQAAAAABC3777777AAOAAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1OIAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gaGFsZjQgdUNvbG9yX1N0YWdlMDsKaW4gZmxvYXQyIGluUG9zaXRpb247CmluIGhhbGYgaW5Db3ZlcmFnZTsKb3V0IGhhbGY0IHZjb2xvcl9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIERlZmF1bHRHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgY29sb3IgPSB1Q29sb3JfU3RhZ2UwOwoJY29sb3IgPSBjb2xvciAqIGluQ292ZXJhZ2U7Cgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7CglmbG9hdDIgX3RtcF8xX2luUG9zaXRpb24gPSBpblBvc2l0aW9uOwoJZmxvYXQyIF90bXBfM19pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAA6AQAAaW4gaGFsZjQgdmNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIERlZmF1bHRHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmNvbG9yX1N0YWdlMDsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAKAAAAaW5Qb3NpdGlvbgAACgAAAGluQ292ZXJhZ2UAAAEAAAAAAAAA","AWQAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIAGCADYAAAQAAAAAAAQAOAAAABAAAAAAABBAMAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAADyAwAAdW5pZm9ybSBmbG9hdDQgdWlubmVyUmVjdF9TdGFnZTFfYzA7CnVuaWZvcm0gaGFsZjIgdXJhZGl1c1BsdXNIYWxmX1N0YWdlMV9jMDsKaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIF9pbnB1dCAqIGFscGhhOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKG91dHB1dENvdmVyYWdlX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0X1N0YWdlMTsKCX0KfQoAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAMAAAAKAAAAaW5Qb3NpdGlvbgAABwAAAGluQ29sb3IADAAAAGluQ2lyY2xlRWRnZQEAAAAAAAAA","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBAYAAAAAAQAAIAAAACRRAAAAABAAAQAAAABIECAEAACAAAAAZQIEBSAAIAAAAAAAAJAAYAAAACAAAAAAACCAY":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADpBgAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMF9jMF9jMDsKdW5pZm9ybSBoYWxmMiB1XzBfSW5jcmVtZW50X1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBoYWxmNCB1XzFfS2VybmVsX1N0YWdlMV9jMF9jMFs0XTsKdW5pZm9ybSBoYWxmNCB1XzJfT2Zmc2V0c19TdGFnZTFfYzBfYzBbNF07CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMTsKaW4gZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoaGFsZjQgX2lucHV0LCBmbG9hdDIgX2Nvb3JkcykgCnsKCWZsb2F0MiBpbkNvb3JkID0gX2Nvb3JkczsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZC54ID0gY2xhbXAoc3Vic2V0Q29vcmQueCwgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMC54LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwX2MwX2MwLnopOwoJY2xhbXBlZENvb3JkLnkgPSBzdWJzZXRDb29yZC55OwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMF9jMChoYWxmNCBfaW5wdXQsIGZsb2F0MiBfY29vcmRzKSAKewoJcmV0dXJuIFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwX2MwKF9pbnB1dCwgZmxvYXQzeDIodW1hdHJpeF9TdGFnZTFfYzBfYzBfYzApICogX2Nvb3Jkcy54eTEpOwp9CmhhbGY0IEdhdXNzaWFuQ29udm9sdXRpb25fU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF8zX2NvbG9yID0gaGFsZjQoMC4wKTsKCWZsb2F0MiBfNF9jb29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZvciAoaW50IF81X2kgPSAwOyAoXzVfaSA8IDEzKTsgXzVfaSsrKSAoXzNfY29sb3IgKz0gKE1hdHJpeEVmZmVjdF9TdGFnZTFfYzBfYzBfYzAoX2lucHV0LCAoXzRfY29vcmQgKyBmbG9hdDIoKHVfMl9PZmZzZXRzX1N0YWdlMV9jMF9jMFsoXzVfaSAvIDQpXVsoXzVfaSAmIDMpXSAqIHVfMF9JbmNyZW1lbnRfU3RhZ2UxX2MwX2MwKSkpKSAqIHVfMV9LZXJuZWxfU3RhZ2UxX2MwX2MwWyhfNV9pIC8gNCldWyhfNV9pICYgMyldKSk7CglyZXR1cm4gXzNfY29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gR2F1c3NpYW5Db252b2x1dGlvbl9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0X1N0YWdlMSAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACAAAAHBvc2l0aW9uCgAAAGxvY2FsQ29vcmQAAAEAAAAAAAAA","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAAYIAMAAACAAAAAAASABQAAAAEAAAAAAAEEBQ":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAALUEAAB1bmlmb3JtIGZsb2F0NCB1aW5uZXJSZWN0X1N0YWdlMV9jMDsKdW5pZm9ybSBoYWxmMiB1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdCBkeDAgPSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5MIC0gc2tfRnJhZ0Nvb3JkLng7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChmbG9hdDIobWF4KGR4MCwgZHh5MS54KSwgZHh5MS55KSwgMC4wKTsKCWhhbGYgdG9wQWxwaGEgPSBoYWxmKHNhdHVyYXRlKHNrX0ZyYWdDb29yZC55IC0gdWlubmVyUmVjdF9TdGFnZTFfYzAuVCkpOwoJaGFsZiBhbHBoYSA9IHRvcEFscGhhICogaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIF9pbnB1dCAqIGFscGhhOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBHckZpbGxSUmVjdE9wOjpQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7CglmbG9hdCB4X3BsdXNfMT12YXJjY29vcmRfU3RhZ2UwLngsIHk9dmFyY2Nvb3JkX1N0YWdlMC55OwoJaGFsZiBjb3ZlcmFnZTsKCWlmICgwID09IHhfcGx1c18xKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoeSk7Cgl9CgllbHNlIAoJewoJCWZsb2F0IGZuID0geF9wbHVzXzEgKiAoeF9wbHVzXzEgLSAyKTsKCQlmbiA9IGZtYSh5LHksIGZuKTsKCQlmbG9hdCBmbndpZHRoID0gZndpZHRoKGZuKTsKCQljb3ZlcmFnZSA9IC41IC0gaGFsZihmbi9mbndpZHRoKTsKCQljb3ZlcmFnZSA9IGNsYW1wKGNvdmVyYWdlLCAwLCAxKTsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGNvdmVyYWdlKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAgAAAAOAAAAcmFkaWlfc2VsZWN0b3IAABkAAABjb3JuZXJfYW5kX3JhZGl1c19vdXRzZXRzAAAAFQAAAGFhX2Jsb2F0X2FuZF9jb3ZlcmFnZQAAAAQAAABza2V3CQAAAHRyYW5zbGF0ZQAAAAcAAAByYWRpaV94AAcAAAByYWRpaV95AAUAAABjb2xvcgAAAAEAAAAAAAAA","F6IAAAAAAIAAAAABCYBR6AAAAAAAAAAAADEACAAAAAIAAAAAQCIACAAAAA":"BwAAAExTS1MQAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkhhaXJRdWFkRWRnZTsKb3V0IGhhbGY0IHZIYWlyUXVhZEVkZ2VfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkCgl2SGFpclF1YWRFZGdlX1N0YWdlMCA9IGluSGFpclF1YWRFZGdlOwoJZmxvYXQyIF90bXBfMV9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAAA7AwAAdW5pZm9ybSBoYWxmNCB1Q29sb3JfU3RhZ2UwOwp1bmlmb3JtIGhhbGYgdUNvdmVyYWdlX1N0YWdlMDsKaW4gaGFsZjQgdkhhaXJRdWFkRWRnZV9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB1Q29sb3JfU3RhZ2UwOwoJaGFsZiBlZGdlQWxwaGE7CgloYWxmMiBkdXZkeCA9IGhhbGYyKGRGZHgodkhhaXJRdWFkRWRnZV9TdGFnZTAueHkpKTsKCWhhbGYyIGR1dmR5ID0gaGFsZjIoZEZkeSh2SGFpclF1YWRFZGdlX1N0YWdlMC54eSkpOwoJaGFsZjIgZ0YgPSBoYWxmMigyLjAgKiB2SGFpclF1YWRFZGdlX1N0YWdlMC54ICogZHV2ZHgueCAtIGR1dmR4LnksICAgICAgICAgICAgICAgMi4wICogdkhhaXJRdWFkRWRnZV9TdGFnZTAueCAqIGR1dmR5LnggLSBkdXZkeS55KTsKCWVkZ2VBbHBoYSA9IGhhbGYodkhhaXJRdWFkRWRnZV9TdGFnZTAueCAqIHZIYWlyUXVhZEVkZ2VfU3RhZ2UwLnggLSB2SGFpclF1YWRFZGdlX1N0YWdlMC55KTsKCWVkZ2VBbHBoYSA9IHNxcnQoZWRnZUFscGhhICogZWRnZUFscGhhIC8gZG90KGdGLCBnRikpOwoJZWRnZUFscGhhID0gbWF4KDEuMCAtIGVkZ2VBbHBoYSwgMC4wKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KHVDb3ZlcmFnZV9TdGFnZTAgKiBlZGdlQWxwaGEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAoAAABpblBvc2l0aW9uAAAOAAAAaW5IYWlyUXVhZEVkZ2UAAAEAAAAAAAAA","HQQACAAAAAGAAAAAAIWP57ZDH37P7777777QCAAAAAAAAAAASABQAAAAEAAAAAAAEEBQAAA":"BwAAAExTS1PXAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7Cm91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZjb2xvcl9TdGFnZTAgPSBjb2xvcjsKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAQAEAAGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgUAAABjb2xvcgAAAAEAAAAAAAAA","AWQQGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAABZAAAAAAACAAAAAEBSAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAC3AgAAaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGYgZGlzdGFuY2VUb0lubmVyRWRnZSA9IGhhbGYoY2lyY2xlRWRnZS56ICogKGQgLSBjaXJjbGVFZGdlLncpKTsKCWhhbGYgaW5uZXJBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9Jbm5lckVkZ2UpOwoJZWRnZUFscGhhICo9IGlubmVyQWxwaGE7CgloYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNChlZGdlQWxwaGEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","HQQAAAAAAAGAAAAAAIWP57ZDH37P7777777QCAAAAAAAAAAAMIAHSAAAAAAQAAAAAA4QAAAAAABAAAAACAZAAAAAAA":"BwAAAExTS1PcAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gaGFsZjQgY29sb3I7CmZsYXQgb3V0IGhhbGY0IHZjb2xvcl9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJc2tfUG9zaXRpb24gPSBwb3NpdGlvbi54eTAxOwp9CgEAAAAAAAAAAQAAABEDAAB1bmlmb3JtIGZsb2F0NCB1aW5uZXJSZWN0X1N0YWdlMV9jMDsKdW5pZm9ybSBoYWxmMiB1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IENpcmN1bGFyUlJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBkeHkwID0gdWlubmVyUmVjdF9TdGFnZTFfYzAuTFQgLSBza19GcmFnQ29vcmQueHk7CglmbG9hdDIgZHh5MSA9IHNrX0ZyYWdDb29yZC54eSAtIHVpbm5lclJlY3RfU3RhZ2UxX2MwLlJCOwoJZmxvYXQyIGR4eSA9IG1heChtYXgoZHh5MCwgZHh5MSksIDAuMCk7CgloYWxmIGFscGhhID0gaGFsZihzYXR1cmF0ZSh1cmFkaXVzUGx1c0hhbGZfU3RhZ2UxX2MwLnggLSBsZW5ndGgoZHh5KSkpOwoJcmV0dXJuIF9pbnB1dCAqIGFscGhhOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24FAAAAY29sb3IAAAABAAAAAAAAAA==","HQJAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAABAAAAAABBAMADCAB4QAAAAAEAAAAAAHEAAAAAAAIAAAAAQGIAAAAAA":"BwAAAExTS1PtAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdmxvY2FsQ29vcmRfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZsb2NhbENvb3JkX1N0YWdlMCA9IGxvY2FsQ29vcmQ7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cn0KAAAAAQAAAAAAAAABAAAApwMAAHVuaWZvcm0gZmxvYXQ0IHVpbm5lclJlY3RfU3RhZ2UxX2MwOwp1bmlmb3JtIGhhbGYyIHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgQ2lyY3VsYXJSUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJZmxvYXQyIGR4eTAgPSB1aW5uZXJSZWN0X1N0YWdlMV9jMC5MVCAtIHNrX0ZyYWdDb29yZC54eTsKCWZsb2F0MiBkeHkxID0gc2tfRnJhZ0Nvb3JkLnh5IC0gdWlubmVyUmVjdF9TdGFnZTFfYzAuUkI7CglmbG9hdDIgZHh5ID0gbWF4KG1heChkeHkwLCBkeHkxKSwgMC4wKTsKCWhhbGYgYWxwaGEgPSBoYWxmKHNhdHVyYXRlKHVyYWRpdXNQbHVzSGFsZl9TdGFnZTFfYzAueCAtIGxlbmd0aChkeHkpKSk7CglyZXR1cm4gX2lucHV0ICogYWxwaGE7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7CglmbG9hdDIgdGV4Q29vcmQ7Cgl0ZXhDb29yZCA9IHZsb2NhbENvb3JkX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9ICgoc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdGV4Q29vcmQpICogaGFsZjQoMSkpKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBDaXJjdWxhclJSZWN0X1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24KAAAAbG9jYWxDb29yZAAAAQAAAAAAAAA=","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAGEQMJHBTJAAQAADQAAAAAAAAAAAEADQAAAAIAAAAAAAIIDAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAAK0FAABjb25zdCBpbnQga0ZpbGxBQV9TdGFnZTFfYzAgPSAxOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwID0gMjsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCA9IDM7CnVuaWZvcm0gZmxvYXQ0IHVjaXJjbGVfU3RhZ2UxX2MwOwpmbGF0IGluIGhhbGY0IHZjb2xvcl9TdGFnZTA7CmluIGZsb2F0MiB2YXJjY29vcmRfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBDaXJjbGVfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF90bXBfMF9pbkNvbG9yID0gX2lucHV0OwoJaGFsZiBkOwoJaWYgKGludCgzKSA9PSBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDMpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJZCA9IGhhbGYoKGxlbmd0aCgodWNpcmNsZV9TdGFnZTFfYzAueHkgLSBza19GcmFnQ29vcmQueHkpICogdWNpcmNsZV9TdGFnZTFfYzAudykgLSAxLjApICogdWNpcmNsZV9TdGFnZTFfYzAueik7Cgl9CgllbHNlIAoJewoJCWQgPSBoYWxmKCgxLjAgLSBsZW5ndGgoKHVjaXJjbGVfU3RhZ2UxX2MwLnh5IC0gc2tfRnJhZ0Nvb3JkLnh5KSAqIHVjaXJjbGVfU3RhZ2UxX2MwLncpKSAqIHVjaXJjbGVfU3RhZ2UxX2MwLnopOwoJfQoJaWYgKGludCgzKSA9PSBrRmlsbEFBX1N0YWdlMV9jMCB8fCBpbnQoMykgPT0ga0ludmVyc2VGaWxsQUFfU3RhZ2UxX2MwKSAKCXsKCQlyZXR1cm4gaGFsZjQoX2lucHV0ICogc2F0dXJhdGUoZCkpOwoJfQoJZWxzZSAKCXsKCQlyZXR1cm4gaGFsZjQoZCA+IDAuNSA/IF9pbnB1dCA6IGhhbGY0KDAuMCkpOwoJfQp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBHckZpbGxSUmVjdE9wOjpQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7CglmbG9hdCB4X3BsdXNfMT12YXJjY29vcmRfU3RhZ2UwLngsIHk9dmFyY2Nvb3JkX1N0YWdlMC55OwoJaGFsZiBjb3ZlcmFnZTsKCWlmICgwID09IHhfcGx1c18xKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoeSk7Cgl9CgllbHNlIAoJewoJCWZsb2F0IGZuID0geF9wbHVzXzEgKiAoeF9wbHVzXzEgLSAyKTsKCQlmbiA9IGZtYSh5LHksIGZuKTsKCQlmbG9hdCBmbndpZHRoID0gZndpZHRoKGZuKTsKCQljb3ZlcmFnZSA9IC41IC0gaGFsZihmbi9mbndpZHRoKTsKCQljb3ZlcmFnZSA9IGNsYW1wKGNvdmVyYWdlLCAwLCAxKTsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGNvdmVyYWdlKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQ2lyY2xlX1N0YWdlMV9jMChvdXRwdXRDb3ZlcmFnZV9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dF9TdGFnZTE7Cgl9Cn0KAAAAAQAAAAEAAAABAAAAAAAAAAAAAAAAAAAACAAAAA4AAAByYWRpaV9zZWxlY3RvcgAAGQAAAGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHMAAAAVAAAAYWFfYmxvYXRfYW5kX2NvdmVyYWdlAAAABAAAAHNrZXcJAAAAdHJhbnNsYXRlAAAABwAAAHJhZGlpX3gABwAAAHJhZGlpX3kABQAAAGNvbG9yAAAAAQAAAAAAAAA=","AWAAGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAABZAAAAAAACAAAAAEBSAA":"BwAAAExTS1OSAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzJfaW5Qb3NpdGlvbiA9IGluUG9zaXRpb247Cglza19Qb3NpdGlvbiA9IF90bXBfMF9pblBvc2l0aW9uLnh5MDE7Cn0KAAAAAAAAAAAAAAAAAAAmAgAAaW4gZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJZmxvYXQ0IGNpcmNsZUVkZ2U7CgljaXJjbGVFZGdlID0gdmluQ2lyY2xlRWRnZV9TdGFnZTA7CgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2aW5Db2xvcl9TdGFnZTA7CglmbG9hdCBkID0gbGVuZ3RoKGNpcmNsZUVkZ2UueHkpOwoJaGFsZiBkaXN0YW5jZVRvT3V0ZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoMS4wIC0gZCkpOwoJaGFsZiBlZGdlQWxwaGEgPSBzYXR1cmF0ZShkaXN0YW5jZVRvT3V0ZXJFZGdlKTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAwAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgAMAAAAaW5DaXJjbGVFZGdlAQAAAAAAAAA=","HMMAAAAABCYIR6AYYAAAAAAAAAAAAACABYAAAAEAAAAAAAEEBQAA":"BwAAAExTS1NPAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5RdWFkRWRnZTsKb3V0IGZsb2F0NCB2UXVhZEVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkRWRnZQoJdlF1YWRFZGdlX1N0YWdlMCA9IGluUXVhZEVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoAAQAAAAAAAAABAAAAaQMAAGluIGZsb2F0NCB2UXVhZEVkZ2VfU3RhZ2UwOwppbiBoYWxmNCB2aW5Db2xvcl9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBRdWFkRWRnZQoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdmluQ29sb3JfU3RhZ2UwOwoJaGFsZiBlZGdlQWxwaGE7CgloYWxmMiBkdXZkeCA9IGhhbGYyKGRGZHgodlF1YWRFZGdlX1N0YWdlMC54eSkpOwoJaGFsZjIgZHV2ZHkgPSBoYWxmMihkRmR5KHZRdWFkRWRnZV9TdGFnZTAueHkpKTsKCWlmICh2UXVhZEVkZ2VfU3RhZ2UwLnogPiAwLjAgJiYgdlF1YWRFZGdlX1N0YWdlMC53ID4gMC4wKSAKCXsKCQllZGdlQWxwaGEgPSBoYWxmKG1pbihtaW4odlF1YWRFZGdlX1N0YWdlMC56LCB2UXVhZEVkZ2VfU3RhZ2UwLncpICsgMC41LCAxLjApKTsKCX0KCWVsc2UgCgl7CgkJaGFsZjIgZ0YgPSBoYWxmMihoYWxmKDIuMCp2UXVhZEVkZ2VfU3RhZ2UwLngqZHV2ZHgueCAtIGR1dmR4LnkpLCAgICAgICAgICAgICAgICAgaGFsZigyLjAqdlF1YWRFZGdlX1N0YWdlMC54KmR1dmR5LnggLSBkdXZkeS55KSk7CgkJZWRnZUFscGhhID0gaGFsZih2UXVhZEVkZ2VfU3RhZ2UwLngqdlF1YWRFZGdlX1N0YWdlMC54IC0gdlF1YWRFZGdlX1N0YWdlMC55KTsKCQllZGdlQWxwaGEgPSBzYXR1cmF0ZSgwLjUgLSBlZGdlQWxwaGEgLyBsZW5ndGgoZ0YpKTsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAMAAAAKAAAAaW5Qb3NpdGlvbgAABwAAAGluQ29sb3IACgAAAGluUXVhZEVkZ2UAAAEAAAAAAAAA","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBACAIBAAAAAMYECAZAAEAQAAAAAAEQAMAAAABAAAAAAABBAMAAAAA":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADJAwAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMDsKdW5pZm9ybSBzYW1wbGVyMkQgdVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UxOwppbiBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwpoYWxmNCBUZXh0dXJlRWZmZWN0X1N0YWdlMV9jMF9jMChoYWxmNCBfaW5wdXQpIAp7CglmbG9hdDIgaW5Db29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZCA9IGNsYW1wKHN1YnNldENvb3JkLCB1Y2xhbXBfU3RhZ2UxX2MwX2MwLnh5LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwLnp3KTsKCWhhbGY0IHRleHR1cmVDb2xvciA9IHNhbXBsZSh1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTEsIGNsYW1wZWRDb29yZCk7CglyZXR1cm4gdGV4dHVyZUNvbG9yOwp9CmhhbGY0IE1hdHJpeEVmZmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJcmV0dXJuIFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwKF9pbnB1dCk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gTWF0cml4RWZmZWN0X1N0YWdlMV9jMChvdXRwdXRDb2xvcl9TdGFnZTApOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dF9TdGFnZTEgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","BUIBQAAAAQAAAAABCYIR7777777QAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1NGAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwpvdXQgaGFsZjQgdmNvbG9yX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgRGVmYXVsdEdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBjb2xvciA9IGluQ29sb3I7Cgl2Y29sb3JfU3RhZ2UwID0gY29sb3I7CglmbG9hdDIgX3RtcF8xX2luUG9zaXRpb24gPSBpblBvc2l0aW9uOwoJZmxvYXQyIF90bXBfM19pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAADoBAABpbiBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgRGVmYXVsdEdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB2Y29sb3JfU3RhZ2UwOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAoAAABpblBvc2l0aW9uAAAHAAAAaW5Db2xvcgABAAAAAAAAAA==","AWQQGAAAQAAIXCEPAGGP777777777737AAAAAAAAAAAIBRAA5ZQUCGQFAAAMAAAAAAAAAAAAAA4QAAAAAABAAAAACAZAAAAA":"BwAAAExTS1PoAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQ0IHVsb2NhbE1hdHJpeF9TdGFnZTA7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmNCBpbkNvbG9yOwppbiBmbG9hdDQgaW5DaXJjbGVFZGdlOwpvdXQgZmxvYXQ0IHZpbkNpcmNsZUVkZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgdmluQ29sb3JfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBDaXJjbGVHZW9tZXRyeVByb2Nlc3NvcgoJdmluQ2lyY2xlRWRnZV9TdGFnZTAgPSBpbkNpcmNsZUVkZ2U7Cgl2aW5Db2xvcl9TdGFnZTAgPSBpbkNvbG9yOwoJZmxvYXQyIF90bXBfMF9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCWZsb2F0MiBfdG1wXzFfaW5Qb3NpdGlvbiA9IHVsb2NhbE1hdHJpeF9TdGFnZTAueHogKiBpblBvc2l0aW9uICsgdWxvY2FsTWF0cml4X1N0YWdlMC55dzsKCXNrX1Bvc2l0aW9uID0gX3RtcF8wX2luUG9zaXRpb24ueHkwMTsKfQoBAAAAAAAAAAEAAAA4BgAAY29uc3QgaW50IGtGaWxsQldfU3RhZ2UxX2MwID0gMDsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCA9IDI7CmNvbnN0IGludCBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzAgPSAzOwp1bmlmb3JtIGZsb2F0NCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwOwppbiBmbG9hdDQgdmluQ2lyY2xlRWRnZV9TdGFnZTA7CmluIGhhbGY0IHZpbkNvbG9yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgUmVjdF9TdGFnZTFfYzAoaGFsZjQgX2lucHV0KSAKewoJaGFsZjQgX3RtcF8wX2luQ29sb3IgPSBfaW5wdXQ7CgloYWxmIGNvdmVyYWdlOwoJaWYgKGludCgxKSA9PSBrRmlsbEJXX1N0YWdlMV9jMCB8fCBpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoYWxsKGdyZWF0ZXJUaGFuKGZsb2F0NChza19GcmFnQ29vcmQueHksIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAuencpLCBmbG9hdDQodXJlY3RVbmlmb3JtX1N0YWdlMV9jMC54eSwgc2tfRnJhZ0Nvb3JkLnh5KSkpID8gMSA6IDApOwoJfQoJZWxzZSAKCXsKCQloYWxmNCBkaXN0czQgPSBjbGFtcChoYWxmNCgxLjAsIDEuMCwgLTEuMCwgLTEuMCkgKiBoYWxmNChza19GcmFnQ29vcmQueHl4eSAtIHVyZWN0VW5pZm9ybV9TdGFnZTFfYzApLCAwLjAsIDEuMCk7CgkJaGFsZjIgZGlzdHMyID0gKGRpc3RzNC54eSArIGRpc3RzNC56dykgLSAxLjA7CgkJY292ZXJhZ2UgPSBkaXN0czIueCAqIGRpc3RzMi55OwoJfQoJaWYgKGludCgxKSA9PSBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSAxLjAgLSBjb3ZlcmFnZTsKCX0KCXJldHVybiBoYWxmNChfaW5wdXQgKiBjb3ZlcmFnZSk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIENpcmNsZUdlb21ldHJ5UHJvY2Vzc29yCglmbG9hdDQgY2lyY2xlRWRnZTsKCWNpcmNsZUVkZ2UgPSB2aW5DaXJjbGVFZGdlX1N0YWdlMDsKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZpbkNvbG9yX1N0YWdlMDsKCWZsb2F0IGQgPSBsZW5ndGgoY2lyY2xlRWRnZS54eSk7CgloYWxmIGRpc3RhbmNlVG9PdXRlckVkZ2UgPSBoYWxmKGNpcmNsZUVkZ2UueiAqICgxLjAgLSBkKSk7CgloYWxmIGVkZ2VBbHBoYSA9IHNhdHVyYXRlKGRpc3RhbmNlVG9PdXRlckVkZ2UpOwoJaGFsZiBkaXN0YW5jZVRvSW5uZXJFZGdlID0gaGFsZihjaXJjbGVFZGdlLnogKiAoZCAtIGNpcmNsZUVkZ2UudykpOwoJaGFsZiBpbm5lckFscGhhID0gc2F0dXJhdGUoZGlzdGFuY2VUb0lubmVyRWRnZSk7CgllZGdlQWxwaGEgKj0gaW5uZXJBbHBoYTsKCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGVkZ2VBbHBoYSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IFJlY3RfU3RhZ2UxX2MwKG91dHB1dENvdmVyYWdlX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0X1N0YWdlMTsKCX0KfQoBAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAADAAAACgAAAGluUG9zaXRpb24AAAcAAABpbkNvbG9yAAwAAABpbkNpcmNsZUVkZ2UBAAAAAAAAAA==","HQJAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAABAAAAAABBAMAASANBMYOMDCQAAAAADAAAAAAAAAAAOIAAAAAAAQAAAABAMQAA":"BwAAAExTS1PtAAAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdmxvY2FsQ29vcmRfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXZsb2NhbENvb3JkX1N0YWdlMCA9IGxvY2FsQ29vcmQ7Cglza19Qb3NpdGlvbiA9IHBvc2l0aW9uLnh5MDE7Cn0KAAAAAQAAAAAAAAABAAAAAQUAAGNvbnN0IGludCBrRmlsbEFBX1N0YWdlMV9jMCA9IDE7CmNvbnN0IGludCBrSW52ZXJzZUZpbGxCV19TdGFnZTFfYzAgPSAyOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQUFfU3RhZ2UxX2MwID0gMzsKdW5pZm9ybSBmbG9hdDQgdWNpcmNsZV9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZsb2NhbENvb3JkX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgQ2lyY2xlX1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CgloYWxmNCBfdG1wXzBfaW5Db2xvciA9IF9pbnB1dDsKCWhhbGYgZDsKCWlmIChpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwIHx8IGludCgxKSA9PSBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzApIAoJewoJCWQgPSBoYWxmKChsZW5ndGgoKHVjaXJjbGVfU3RhZ2UxX2MwLnh5IC0gc2tfRnJhZ0Nvb3JkLnh5KSAqIHVjaXJjbGVfU3RhZ2UxX2MwLncpIC0gMS4wKSAqIHVjaXJjbGVfU3RhZ2UxX2MwLnopOwoJfQoJZWxzZSAKCXsKCQlkID0gaGFsZigoMS4wIC0gbGVuZ3RoKCh1Y2lyY2xlX1N0YWdlMV9jMC54eSAtIHNrX0ZyYWdDb29yZC54eSkgKiB1Y2lyY2xlX1N0YWdlMV9jMC53KSkgKiB1Y2lyY2xlX1N0YWdlMV9jMC56KTsKCX0KCWlmIChpbnQoMSkgPT0ga0ZpbGxBQV9TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCkgCgl7CgkJcmV0dXJuIGhhbGY0KF9pbnB1dCAqIHNhdHVyYXRlKGQpKTsKCX0KCWVsc2UgCgl7CgkJcmV0dXJuIGhhbGY0KGQgPiAwLjUgPyBfaW5wdXQgOiBoYWxmNCgwLjApKTsKCX0KfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWZsb2F0MiB0ZXhDb29yZDsKCXRleENvb3JkID0gdmxvY2FsQ29vcmRfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gKChzYW1wbGUodVRleHR1cmVTYW1wbGVyXzBfU3RhZ2UwLCB0ZXhDb29yZCkgKiBoYWxmNCgxKSkpOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7CgloYWxmNCBvdXRwdXRfU3RhZ2UxOwoJb3V0cHV0X1N0YWdlMSA9IENpcmNsZV9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAIAAAAcG9zaXRpb24KAAAAbG9jYWxDb29yZAAAAQAAAAAAAAA=","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAVIBAYAAAAAAACAIAAAACRRAAAAAAAEAQAAAABIECAAAQCAAAAAZQIEBSAAABAAAAAAAJAAYAAAACAAAAAAACCAY":"BwAAAExTS1NMAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzJfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfMl9TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAADpBgAAdW5pZm9ybSBmbG9hdDQgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMDsKdW5pZm9ybSBmbG9hdDN4MyB1bWF0cml4X1N0YWdlMV9jMF9jMF9jMDsKdW5pZm9ybSBoYWxmMiB1XzBfSW5jcmVtZW50X1N0YWdlMV9jMF9jMDsKdW5pZm9ybSBoYWxmNCB1XzFfS2VybmVsX1N0YWdlMV9jMF9jMFs0XTsKdW5pZm9ybSBoYWxmNCB1XzJfT2Zmc2V0c19TdGFnZTFfYzBfYzBbNF07CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMTsKaW4gZmxvYXQyIHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKb3V0IGhhbGY0IHNrX0ZyYWdDb2xvcjsKaGFsZjQgVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzBfYzAoaGFsZjQgX2lucHV0LCBmbG9hdDIgX2Nvb3JkcykgCnsKCWZsb2F0MiBpbkNvb3JkID0gX2Nvb3JkczsKCWZsb2F0MiBzdWJzZXRDb29yZDsKCXN1YnNldENvb3JkLnggPSBpbkNvb3JkLng7CglzdWJzZXRDb29yZC55ID0gaW5Db29yZC55OwoJZmxvYXQyIGNsYW1wZWRDb29yZDsKCWNsYW1wZWRDb29yZC54ID0gc3Vic2V0Q29vcmQueDsKCWNsYW1wZWRDb29yZC55ID0gY2xhbXAoc3Vic2V0Q29vcmQueSwgdWNsYW1wX1N0YWdlMV9jMF9jMF9jMF9jMC55LCB1Y2xhbXBfU3RhZ2UxX2MwX2MwX2MwX2MwLncpOwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMF9jMChoYWxmNCBfaW5wdXQsIGZsb2F0MiBfY29vcmRzKSAKewoJcmV0dXJuIFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwX2MwKF9pbnB1dCwgZmxvYXQzeDIodW1hdHJpeF9TdGFnZTFfYzBfYzBfYzApICogX2Nvb3Jkcy54eTEpOwp9CmhhbGY0IEdhdXNzaWFuQ29udm9sdXRpb25fU3RhZ2UxX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF8zX2NvbG9yID0gaGFsZjQoMC4wKTsKCWZsb2F0MiBfNF9jb29yZCA9IHZUcmFuc2Zvcm1lZENvb3Jkc18yX1N0YWdlMDsKCWZvciAoaW50IF81X2kgPSAwOyAoXzVfaSA8IDEzKTsgXzVfaSsrKSAoXzNfY29sb3IgKz0gKE1hdHJpeEVmZmVjdF9TdGFnZTFfYzBfYzBfYzAoX2lucHV0LCAoXzRfY29vcmQgKyBmbG9hdDIoKHVfMl9PZmZzZXRzX1N0YWdlMV9jMF9jMFsoXzVfaSAvIDQpXVsoXzVfaSAmIDMpXSAqIHVfMF9JbmNyZW1lbnRfU3RhZ2UxX2MwX2MwKSkpKSAqIHVfMV9LZXJuZWxfU3RhZ2UxX2MwX2MwWyhfNV9pIC8gNCldWyhfNV9pICYgMyldKSk7CglyZXR1cm4gXzNfY29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gR2F1c3NpYW5Db252b2x1dGlvbl9TdGFnZTFfYzBfYzAoX2lucHV0KTsKfQp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgUXVhZFBlckVkZ2VBQUdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTAgPSBoYWxmNCgxKTsKCWNvbnN0IGhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KDEpOwoJaGFsZjQgb3V0cHV0X1N0YWdlMTsKCW91dHB1dF9TdGFnZTEgPSBNYXRyaXhFZmZlY3RfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0X1N0YWdlMSAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACAAAAHBvc2l0aW9uCgAAAGxvY2FsQ29vcmQAAAEAAAAAAAAA","HQIAAAAAAAGAAAAAAIWP577774BSZ7X7777QCAAAAAAAAAAAFIAA2AAAAAAQCAQAAAACUEQQCAAAAABQIMACCAYAAEAQAAAAAAADSAAAAAAAEAAAAAIDEAAAAA":"BwAAAExTS1NSAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQzeDMgdW1hdHJpeF9TdGFnZTFfYzBfYzA7CmluIGZsb2F0MiBwb3NpdGlvbjsKaW4gZmxvYXQyIGxvY2FsQ29vcmQ7Cm91dCBmbG9hdDIgdlRyYW5zZm9ybWVkQ29vcmRzXzNfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBRdWFkUGVyRWRnZUFBR2VvbWV0cnlQcm9jZXNzb3IKCXNrX1Bvc2l0aW9uID0gcG9zaXRpb24ueHkwMTsKCXsKCQl2VHJhbnNmb3JtZWRDb29yZHNfM19TdGFnZTAgPSBmbG9hdDN4Mih1bWF0cml4X1N0YWdlMV9jMF9jMCkgKiBsb2NhbENvb3JkLnh5MTsKCX0KfQoAAAAAAAAAAAAAAAAAAGwEAAB1bmlmb3JtIGZsb2F0NCB1Y2xhbXBfU3RhZ2UxX2MwX2MwX2MwOwp1bmlmb3JtIGZsb2F0M3gzIHVtYXRyaXhfU3RhZ2UxX2MwX2MwOwp1bmlmb3JtIHNhbXBsZXIyRCB1VGV4dHVyZVNhbXBsZXJfMF9TdGFnZTE7CmluIGZsb2F0MiB2VHJhbnNmb3JtZWRDb29yZHNfM19TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFRleHR1cmVFZmZlY3RfU3RhZ2UxX2MwX2MwX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWZsb2F0MiBpbkNvb3JkID0gdlRyYW5zZm9ybWVkQ29vcmRzXzNfU3RhZ2UwOwoJZmxvYXQyIHN1YnNldENvb3JkOwoJc3Vic2V0Q29vcmQueCA9IGluQ29vcmQueDsKCXN1YnNldENvb3JkLnkgPSBpbkNvb3JkLnk7CglmbG9hdDIgY2xhbXBlZENvb3JkOwoJY2xhbXBlZENvb3JkID0gY2xhbXAoc3Vic2V0Q29vcmQsIHVjbGFtcF9TdGFnZTFfYzBfYzBfYzAueHksIHVjbGFtcF9TdGFnZTFfYzBfYzBfYzAuencpOwoJaGFsZjQgdGV4dHVyZUNvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMSwgY2xhbXBlZENvb3JkKTsKCXJldHVybiB0ZXh0dXJlQ29sb3I7Cn0KaGFsZjQgTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMChoYWxmNCBfaW5wdXQpIAp7CglyZXR1cm4gVGV4dHVyZUVmZmVjdF9TdGFnZTFfYzBfYzBfYzAoX2lucHV0KTsKfQpoYWxmNCBCbGVuZF9TdGFnZTFfYzAoaGFsZjQgX3NyYywgaGFsZjQgX2RzdCkgCnsKCS8vIEJsZW5kIG1vZGU6IE1vZHVsYXRlCglyZXR1cm4gYmxlbmRfbW9kdWxhdGUoTWF0cml4RWZmZWN0X1N0YWdlMV9jMF9jMChfc3JjKSwgX3NyYyk7Cn0Kdm9pZCBtYWluKCkgCnsKCS8vIFN0YWdlIDAsIFF1YWRQZXJFZGdlQUFHZW9tZXRyeVByb2Nlc3NvcgoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwID0gaGFsZjQoMSk7Cgljb25zdCBoYWxmNCBvdXRwdXRDb3ZlcmFnZV9TdGFnZTAgPSBoYWxmNCgxKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gQmxlbmRfU3RhZ2UxX2MwKG91dHB1dENvbG9yX1N0YWdlMCwgaGFsZjQoMSkpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dF9TdGFnZTEgKiBvdXRwdXRDb3ZlcmFnZV9TdGFnZTA7Cgl9Cn0KAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAgAAAAgAAABwb3NpdGlvbgoAAABsb2NhbENvb3JkAAABAAAAAAAAAA==","BWAAQAAAAQAAAAABC3777777AAOAAAAAAAAAAAAAZAAQAAAACAAAAAEASAAQAAAA":"BwAAAExTS1MWAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0MiBpblBvc2l0aW9uOwppbiBoYWxmIGluQ292ZXJhZ2U7Cm91dCBoYWxmIHZpbkNvdmVyYWdlX1N0YWdlMDsKdm9pZCBtYWluKCkgCnsKCS8vIFByaW1pdGl2ZSBQcm9jZXNzb3IgRGVmYXVsdEdlb21ldHJ5UHJvY2Vzc29yCglmbG9hdDIgX3RtcF8xX2luUG9zaXRpb24gPSBpblBvc2l0aW9uOwoJdmluQ292ZXJhZ2VfU3RhZ2UwID0gaW5Db3ZlcmFnZTsKCXNrX1Bvc2l0aW9uID0gX3RtcF8xX2luUG9zaXRpb24ueHkwMTsKfQoAAAAAAAAAAAAAAAAAAIkBAAB1bmlmb3JtIGhhbGY0IHVDb2xvcl9TdGFnZTA7CmluIGhhbGYgdmluQ292ZXJhZ2VfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgRGVmYXVsdEdlb21ldHJ5UHJvY2Vzc29yCgloYWxmNCBvdXRwdXRDb2xvcl9TdGFnZTA7CglvdXRwdXRDb2xvcl9TdGFnZTAgPSB1Q29sb3JfU3RhZ2UwOwoJaGFsZiBhbHBoYSA9IDEuMDsKCWFscGhhID0gdmluQ292ZXJhZ2VfU3RhZ2UwOwoJaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoYWxwaGEpOwoJewoJCS8vIFhmZXIgUHJvY2Vzc29yOiBQb3J0ZXIgRHVmZgoJCXNrX0ZyYWdDb2xvciA9IG91dHB1dENvbG9yX1N0YWdlMCAqIG91dHB1dENvdmVyYWdlX1N0YWdlMDsKCX0KfQoAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAACAAAACgAAAGluUG9zaXRpb24AAAoAAABpbkNvdmVyYWdlAAABAAAAAAAAAA==","E5QQAAAAMAAGGADDACRQAAAAMAACHQDCABRQAI7CAMAAAABAGGAHWWEQIYAQAABQAAAAAAAAAAAEADQAAAAIAAAAAAAIIDAA":"BwAAAExTS1PHCwAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CmluIGZsb2F0NCByYWRpaV9zZWxlY3RvcjsKaW4gZmxvYXQ0IGNvcm5lcl9hbmRfcmFkaXVzX291dHNldHM7CmluIGZsb2F0NCBhYV9ibG9hdF9hbmRfY292ZXJhZ2U7CmluIGZsb2F0NCBza2V3OwppbiBmbG9hdDIgdHJhbnNsYXRlOwppbiBmbG9hdDQgcmFkaWlfeDsKaW4gZmxvYXQ0IHJhZGlpX3k7CmluIGhhbGY0IGNvbG9yOwpmbGF0IG91dCBoYWxmNCB2Y29sb3JfU3RhZ2UwOwpvdXQgZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7CnZvaWQgbWFpbigpIAp7CgkvLyBQcmltaXRpdmUgUHJvY2Vzc29yIEdyRmlsbFJSZWN0T3A6OlByb2Nlc3NvcgoJdmNvbG9yX1N0YWdlMCA9IGNvbG9yOwoJZmxvYXQgYWFfYmxvYXRfbXVsdGlwbGllciA9IDE7CglmbG9hdDIgY29ybmVyID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy54eTsKCWZsb2F0MiByYWRpdXNfb3V0c2V0ID0gY29ybmVyX2FuZF9yYWRpdXNfb3V0c2V0cy56dzsKCWZsb2F0MiBhYV9ibG9hdF9kaXJlY3Rpb24gPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UueHk7CglmbG9hdCBpc19saW5lYXJfY292ZXJhZ2UgPSBhYV9ibG9hdF9hbmRfY292ZXJhZ2UudzsKCWZsb2F0MiBwaXhlbGxlbmd0aCA9IGludmVyc2VzcXJ0KGZsb2F0Mihkb3Qoc2tldy54eiwgc2tldy54eiksIGRvdChza2V3Lnl3LCBza2V3Lnl3KSkpOwoJZmxvYXQ0IG5vcm1hbGl6ZWRfYXhpc19kaXJzID0gc2tldyAqIHBpeGVsbGVuZ3RoLnh5eHk7CglmbG9hdDIgYXhpc3dpZHRocyA9IChhYnMobm9ybWFsaXplZF9heGlzX2RpcnMueHkpICsgYWJzKG5vcm1hbGl6ZWRfYXhpc19kaXJzLnp3KSk7CglmbG9hdDIgYWFfYmxvYXRyYWRpdXMgPSBheGlzd2lkdGhzICogcGl4ZWxsZW5ndGggKiAuNTsKCWZsb2F0NCByYWRpaV9hbmRfbmVpZ2hib3JzID0gcmFkaWlfc2VsZWN0b3IqIGZsb2F0NHg0KHJhZGlpX3gsIHJhZGlpX3ksIHJhZGlpX3gueXh3eiwgcmFkaWlfeS53enl4KTsKCWZsb2F0MiByYWRpaSA9IHJhZGlpX2FuZF9uZWlnaGJvcnMueHk7CglmbG9hdDIgbmVpZ2hib3JfcmFkaWkgPSByYWRpaV9hbmRfbmVpZ2hib3JzLnp3OwoJZmxvYXQgY292ZXJhZ2VfbXVsdGlwbGllciA9IDE7CglpZiAoYW55KGdyZWF0ZXJUaGFuKGFhX2Jsb2F0cmFkaXVzLCBmbG9hdDIoMSkpKSkgCgl7CgkJY29ybmVyID0gbWF4KGFicyhjb3JuZXIpLCBhYV9ibG9hdHJhZGl1cykgKiBzaWduKGNvcm5lcik7CgkJY292ZXJhZ2VfbXVsdGlwbGllciA9IDEgLyAobWF4KGFhX2Jsb2F0cmFkaXVzLngsIDEpICogbWF4KGFhX2Jsb2F0cmFkaXVzLnksIDEpKTsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCX0KCWZsb2F0IGNvdmVyYWdlID0gYWFfYmxvYXRfYW5kX2NvdmVyYWdlLno7CglpZiAoYW55KGxlc3NUaGFuKHJhZGlpLCBhYV9ibG9hdHJhZGl1cyAqIDEuNSkpKSAKCXsKCQlyYWRpaSA9IGZsb2F0MigwKTsKCQlhYV9ibG9hdF9kaXJlY3Rpb24gPSBzaWduKGNvcm5lcik7CgkJaWYgKGNvdmVyYWdlID4gLjUpIAoJCXsKCQkJYWFfYmxvYXRfZGlyZWN0aW9uID0gLWFhX2Jsb2F0X2RpcmVjdGlvbjsKCQl9CgkJaXNfbGluZWFyX2NvdmVyYWdlID0gMTsKCX0KCWVsc2UgCgl7CgkJcmFkaWkgPSBjbGFtcChyYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJbmVpZ2hib3JfcmFkaWkgPSBjbGFtcChuZWlnaGJvcl9yYWRpaSwgcGl4ZWxsZW5ndGggKiAxLjUsIDIgLSBwaXhlbGxlbmd0aCAqIDEuNSk7CgkJZmxvYXQyIHNwYWNpbmcgPSAyIC0gcmFkaWkgLSBuZWlnaGJvcl9yYWRpaTsKCQlmbG9hdDIgZXh0cmFfcGFkID0gbWF4KHBpeGVsbGVuZ3RoICogLjA2MjUgLSBzcGFjaW5nLCBmbG9hdDIoMCkpOwoJCXJhZGlpIC09IGV4dHJhX3BhZCAqIC41OwoJfQoJZmxvYXQyIGFhX291dHNldCA9IGFhX2Jsb2F0X2RpcmVjdGlvbiAqIGFhX2Jsb2F0cmFkaXVzICogYWFfYmxvYXRfbXVsdGlwbGllcjsKCWZsb2F0MiB2ZXJ0ZXhwb3MgPSBjb3JuZXIgKyByYWRpdXNfb3V0c2V0ICogcmFkaWkgKyBhYV9vdXRzZXQ7CglpZiAoY292ZXJhZ2UgPiAuNSkgCgl7CgkJaWYgKGFhX2Jsb2F0X2RpcmVjdGlvbi54ICE9IDAgJiYgdmVydGV4cG9zLnggKiBjb3JuZXIueCA8IDApIAoJCXsKCQkJZmxvYXQgYmFja3NldCA9IGFicyh2ZXJ0ZXhwb3MueCk7CgkJCXZlcnRleHBvcy54ID0gMDsKCQkJdmVydGV4cG9zLnkgKz0gYmFja3NldCAqIHNpZ24oY29ybmVyLnkpICogcGl4ZWxsZW5ndGgueS9waXhlbGxlbmd0aC54OwoJCQljb3ZlcmFnZSA9IChjb3ZlcmFnZSAtIC41KSAqIGFicyhjb3JuZXIueCkgLyAoYWJzKGNvcm5lci54KSArIGJhY2tzZXQpICsgLjU7CgkJfQoJCWlmIChhYV9ibG9hdF9kaXJlY3Rpb24ueSAhPSAwICYmIHZlcnRleHBvcy55ICogY29ybmVyLnkgPCAwKSAKCQl7CgkJCWZsb2F0IGJhY2tzZXQgPSBhYnModmVydGV4cG9zLnkpOwoJCQl2ZXJ0ZXhwb3MueSA9IDA7CgkJCXZlcnRleHBvcy54ICs9IGJhY2tzZXQgKiBzaWduKGNvcm5lci54KSAqIHBpeGVsbGVuZ3RoLngvcGl4ZWxsZW5ndGgueTsKCQkJY292ZXJhZ2UgPSAoY292ZXJhZ2UgLSAuNSkgKiBhYnMoY29ybmVyLnkpIC8gKGFicyhjb3JuZXIueSkgKyBiYWNrc2V0KSArIC41OwoJCX0KCX0KCWZsb2F0MngyIHNrZXdtYXRyaXggPSBmbG9hdDJ4Mihza2V3Lnh5LCBza2V3Lnp3KTsKCWZsb2F0MiBkZXZjb29yZCA9IHZlcnRleHBvcyAqIHNrZXdtYXRyaXggKyB0cmFuc2xhdGU7CglpZiAoMCAhPSBpc19saW5lYXJfY292ZXJhZ2UpIAoJewoJCXZhcmNjb29yZF9TdGFnZTAueHkgPSBmbG9hdDIoMCwgY292ZXJhZ2UgKiBjb3ZlcmFnZV9tdWx0aXBsaWVyKTsKCX0KCWVsc2UgCgl7CgkJZmxvYXQyIGFyY2Nvb3JkID0gMSAtIGFicyhyYWRpdXNfb3V0c2V0KSArIGFhX291dHNldC9yYWRpaSAqIGNvcm5lcjsKCQl2YXJjY29vcmRfU3RhZ2UwLnh5ID0gZmxvYXQyKGFyY2Nvb3JkLngrMSwgYXJjY29vcmQueSk7Cgl9Cglza19Qb3NpdGlvbiA9IGRldmNvb3JkLnh5MDE7Cn0KAAEAAAAAAAAAAQAAAAgGAABjb25zdCBpbnQga0ZpbGxCV19TdGFnZTFfYzAgPSAwOwpjb25zdCBpbnQga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwID0gMjsKY29uc3QgaW50IGtJbnZlcnNlRmlsbEFBX1N0YWdlMV9jMCA9IDM7CnVuaWZvcm0gZmxvYXQ0IHVyZWN0VW5pZm9ybV9TdGFnZTFfYzA7CmZsYXQgaW4gaGFsZjQgdmNvbG9yX1N0YWdlMDsKaW4gZmxvYXQyIHZhcmNjb29yZF9TdGFnZTA7Cm91dCBoYWxmNCBza19GcmFnQ29sb3I7CmhhbGY0IFJlY3RfU3RhZ2UxX2MwKGhhbGY0IF9pbnB1dCkgCnsKCWhhbGY0IF90bXBfMF9pbkNvbG9yID0gX2lucHV0OwoJaGFsZiBjb3ZlcmFnZTsKCWlmIChpbnQoMSkgPT0ga0ZpbGxCV19TdGFnZTFfYzAgfHwgaW50KDEpID09IGtJbnZlcnNlRmlsbEJXX1N0YWdlMV9jMCkgCgl7CgkJY292ZXJhZ2UgPSBoYWxmKGFsbChncmVhdGVyVGhhbihmbG9hdDQoc2tfRnJhZ0Nvb3JkLnh5LCB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwLnp3KSwgZmxvYXQ0KHVyZWN0VW5pZm9ybV9TdGFnZTFfYzAueHksIHNrX0ZyYWdDb29yZC54eSkpKSA/IDEgOiAwKTsKCX0KCWVsc2UgCgl7CgkJaGFsZjQgZGlzdHM0ID0gY2xhbXAoaGFsZjQoMS4wLCAxLjAsIC0xLjAsIC0xLjApICogaGFsZjQoc2tfRnJhZ0Nvb3JkLnh5eHkgLSB1cmVjdFVuaWZvcm1fU3RhZ2UxX2MwKSwgMC4wLCAxLjApOwoJCWhhbGYyIGRpc3RzMiA9IChkaXN0czQueHkgKyBkaXN0czQuencpIC0gMS4wOwoJCWNvdmVyYWdlID0gZGlzdHMyLnggKiBkaXN0czIueTsKCX0KCWlmIChpbnQoMSkgPT0ga0ludmVyc2VGaWxsQldfU3RhZ2UxX2MwIHx8IGludCgxKSA9PSBrSW52ZXJzZUZpbGxBQV9TdGFnZTFfYzApIAoJewoJCWNvdmVyYWdlID0gMS4wIC0gY292ZXJhZ2U7Cgl9CglyZXR1cm4gaGFsZjQoX2lucHV0ICogY292ZXJhZ2UpOwp9CnZvaWQgbWFpbigpIAp7CgkvLyBTdGFnZSAwLCBHckZpbGxSUmVjdE9wOjpQcm9jZXNzb3IKCWhhbGY0IG91dHB1dENvbG9yX1N0YWdlMDsKCW91dHB1dENvbG9yX1N0YWdlMCA9IHZjb2xvcl9TdGFnZTA7CglmbG9hdCB4X3BsdXNfMT12YXJjY29vcmRfU3RhZ2UwLngsIHk9dmFyY2Nvb3JkX1N0YWdlMC55OwoJaGFsZiBjb3ZlcmFnZTsKCWlmICgwID09IHhfcGx1c18xKSAKCXsKCQljb3ZlcmFnZSA9IGhhbGYoeSk7Cgl9CgllbHNlIAoJewoJCWZsb2F0IGZuID0geF9wbHVzXzEgKiAoeF9wbHVzXzEgLSAyKTsKCQlmbiA9IGZtYSh5LHksIGZuKTsKCQlmbG9hdCBmbndpZHRoID0gZndpZHRoKGZuKTsKCQljb3ZlcmFnZSA9IC41IC0gaGFsZihmbi9mbndpZHRoKTsKCQljb3ZlcmFnZSA9IGNsYW1wKGNvdmVyYWdlLCAwLCAxKTsKCX0KCWhhbGY0IG91dHB1dENvdmVyYWdlX1N0YWdlMCA9IGhhbGY0KGNvdmVyYWdlKTsKCWhhbGY0IG91dHB1dF9TdGFnZTE7CglvdXRwdXRfU3RhZ2UxID0gUmVjdF9TdGFnZTFfYzAob3V0cHV0Q292ZXJhZ2VfU3RhZ2UwKTsKCXsKCQkvLyBYZmVyIFByb2Nlc3NvcjogUG9ydGVyIER1ZmYKCQlza19GcmFnQ29sb3IgPSBvdXRwdXRDb2xvcl9TdGFnZTAgKiBvdXRwdXRfU3RhZ2UxOwoJfQp9CgEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAgAAAAOAAAAcmFkaWlfc2VsZWN0b3IAABkAAABjb3JuZXJfYW5kX3JhZGl1c19vdXRzZXRzAAAAFQAAAGFhX2Jsb2F0X2FuZF9jb3ZlcmFnZQAAAAQAAABza2V3CQAAAHRyYW5zbGF0ZQAAAAcAAAByYWRpaV94AAcAAAByYWRpaV95AAUAAABjb2xvcgAAAAEAAAAAAAAA","C4SAAAAAMAAAAABAYDRP7H2CAIAAAABAAAAAAABAMQAAAOIAAAAAAAQAAAABAMQA":"BwAAAExTS1PtAQAAdW5pZm9ybSBmbG9hdDQgc2tfUlRBZGp1c3Q7CnVuaWZvcm0gZmxvYXQyIHVBdGxhc1NpemVJbnZfU3RhZ2UwOwppbiBmbG9hdDIgaW5Qb3NpdGlvbjsKaW4gdXNob3J0MiBpblRleHR1cmVDb29yZHM7Cm91dCBmbG9hdDIgdlRleHR1cmVDb29yZHNfU3RhZ2UwOwpmbGF0IG91dCBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwp2b2lkIG1haW4oKSAKewoJLy8gUHJpbWl0aXZlIFByb2Nlc3NvciBCaXRtYXBUZXh0CglpbnQgdGV4SWR4ID0gMDsKCWZsb2F0MiB1bm9ybVRleENvb3JkcyA9IGZsb2F0MihpblRleHR1cmVDb29yZHMueCwgaW5UZXh0dXJlQ29vcmRzLnkpOwoJdlRleHR1cmVDb29yZHNfU3RhZ2UwID0gdW5vcm1UZXhDb29yZHMgKiB1QXRsYXNTaXplSW52X1N0YWdlMDsKCXZUZXhJbmRleF9TdGFnZTAgPSBmbG9hdCh0ZXhJZHgpOwoJZmxvYXQyIF90bXBfMV9pblBvc2l0aW9uID0gaW5Qb3NpdGlvbjsKCXNrX1Bvc2l0aW9uID0gaW5Qb3NpdGlvbi54eTAxOwp9CgAAAAAAAAAAAAAAAAAAADACAAB1bmlmb3JtIGhhbGY0IHVDb2xvcl9TdGFnZTA7CnVuaWZvcm0gc2FtcGxlcjJEIHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMDsKaW4gZmxvYXQyIHZUZXh0dXJlQ29vcmRzX1N0YWdlMDsKZmxhdCBpbiBmbG9hdCB2VGV4SW5kZXhfU3RhZ2UwOwpvdXQgaGFsZjQgc2tfRnJhZ0NvbG9yOwp2b2lkIG1haW4oKSAKewoJLy8gU3RhZ2UgMCwgQml0bWFwVGV4dAoJaGFsZjQgb3V0cHV0Q29sb3JfU3RhZ2UwOwoJb3V0cHV0Q29sb3JfU3RhZ2UwID0gdUNvbG9yX1N0YWdlMDsKCWhhbGY0IHRleENvbG9yOwoJewoJCXRleENvbG9yID0gc2FtcGxlKHVUZXh0dXJlU2FtcGxlcl8wX1N0YWdlMCwgdlRleHR1cmVDb29yZHNfU3RhZ2UwKTsKCX0KCW91dHB1dENvbG9yX1N0YWdlMCA9IG91dHB1dENvbG9yX1N0YWdlMCAqIHRleENvbG9yOwoJY29uc3QgaGFsZjQgb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwID0gaGFsZjQoMSk7Cgl7CgkJLy8gWGZlciBQcm9jZXNzb3I6IFBvcnRlciBEdWZmCgkJc2tfRnJhZ0NvbG9yID0gb3V0cHV0Q29sb3JfU3RhZ2UwICogb3V0cHV0Q292ZXJhZ2VfU3RhZ2UwOwoJfQp9CgAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAKAAAAaW5Qb3NpdGlvbgAADwAAAGluVGV4dHVyZUNvb3JkcwABAAAAAAAAAA=="}} \ No newline at end of file diff --git a/test/fake/android_app_service.dart b/test/fake/android_app_service.dart new file mode 100644 index 000000000..9ec20dcd7 --- /dev/null +++ b/test/fake/android_app_service.dart @@ -0,0 +1,9 @@ +import 'package:aves/services/android_app_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeAndroidAppService extends Fake implements AndroidAppService { + @override + Future> getPackages() => SynchronousFuture({}); +} diff --git a/test/fake/media_file_service.dart b/test/fake/media_file_service.dart index 5d2933b66..8af17306d 100644 --- a/test/fake/media_file_service.dart +++ b/test/fake/media_file_service.dart @@ -1,21 +1,29 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/media/media_file_service.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'media_store_service.dart'; class FakeMediaFileService extends Fake implements MediaFileService { @override - Future> rename(AvesEntry entry, String newName) { + Stream rename( + Iterable entries, { + required String newName, + }) { final contentId = FakeMediaStoreService.nextContentId; - return SynchronousFuture({ - 'uri': 'content://media/external/images/media/$contentId', - 'contentId': contentId, - 'path': '${entry.directory}/$newName', - 'displayName': newName, - 'title': newName.substring(0, newName.length - entry.extension!.length), - 'dateModifiedSecs': FakeMediaStoreService.dateSecs, - }); + final entry = entries.first; + return Stream.value(MoveOpEvent( + success: true, + uri: entry.uri, + newFields: { + 'uri': 'content://media/external/images/media/$contentId', + 'contentId': contentId, + 'path': '${entry.directory}/$newName', + 'displayName': newName, + 'title': newName.substring(0, newName.length - entry.extension!.length), + 'dateModifiedSecs': FakeMediaStoreService.dateSecs, + }, + )); } } diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index 8702860dd..3447dbecb 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -49,7 +49,6 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { success: true, uri: entry.uri, newFields: { - 'deletedSource': true, 'uri': 'content://media/external/images/media/$newContentId', 'contentId': newContentId, 'path': entry.path!.replaceFirst(sourceAlbum, destinationAlbum), diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index 2506f8813..b7f160fb4 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -39,6 +39,9 @@ class FakeMetadataDb extends Fake implements MetadataDb { @override Future> loadAddresses() => SynchronousFuture([]); + @override + Future saveAddresses(Set addresses) => SynchronousFuture(null); + @override Future updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null); diff --git a/test/fake/metadata_fetch_service.dart b/test/fake/metadata_fetch_service.dart index 990918444..556a75bf1 100644 --- a/test/fake/metadata_fetch_service.dart +++ b/test/fake/metadata_fetch_service.dart @@ -5,6 +5,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; class FakeMetadataFetchService extends Fake implements MetadataFetchService { + final Map _metaMap = {}; + + void setUp(AvesEntry entry, CatalogMetadata metadata) => _metaMap[entry] = metadata; + @override - Future getCatalogMetadata(AvesEntry entry, {bool background = false}) => SynchronousFuture(null); + Future getCatalogMetadata(AvesEntry entry, {bool background = false}) => SynchronousFuture(_metaMap[entry]); } diff --git a/test/geo/format_test.dart b/test/geo/format_test.dart deleted file mode 100644 index 4c97054ab..000000000 --- a/test/geo/format_test.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:aves/geo/format.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:test/test.dart'; - -void main() { - test('Decimal degrees to DMS (sexagesimal)', () { - expect(toDMS(LatLng(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam - expect(toDMS(LatLng(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund - expect(toDMS(LatLng(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo - expect(toDMS(LatLng(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio - expect(toDMS(LatLng(0, 0)), ['0° 0′ 0.00″ N', '0° 0′ 0.00″ E']); - }); -} diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 373321c06..2c8d6c9a7 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -4,10 +4,14 @@ import 'package:aves/model/availability.dart'; import 'package:aves/model/covers.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/metadata/address.dart'; +import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/media_store_source.dart'; +import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/device_service.dart'; import 'package:aves/services/media/media_file_service.dart'; @@ -19,8 +23,10 @@ import 'package:aves/services/window_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; import 'package:path/path.dart' as p; +import '../fake/android_app_service.dart'; import '../fake/availability.dart'; import '../fake/device_service.dart'; import '../fake/media_file_service.dart'; @@ -36,12 +42,20 @@ void main() { const sourceAlbum = '${FakeStorageService.primaryPath}Pictures/source'; const destinationAlbum = '${FakeStorageService.primaryPath}Pictures/destination'; + const aTag = 'sometag'; + final australiaLatLng = LatLng(-26, 141); + const australiaAddress = AddressDetails( + countryCode: 'AU', + countryName: 'AUS', + ); + setUp(() async { // specify Posix style path context for consistent behaviour when running tests on Windows getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); getIt.registerLazySingleton(() => FakeAvesAvailability()); getIt.registerLazySingleton(() => FakeMetadataDb()); + getIt.registerLazySingleton(() => FakeAndroidAppService()); getIt.registerLazySingleton(() => FakeDeviceService()); getIt.registerLazySingleton(() => FakeMediaFileService()); getIt.registerLazySingleton(() => FakeMediaStoreService()); @@ -50,7 +64,8 @@ void main() { getIt.registerLazySingleton(() => FakeStorageService()); getIt.registerLazySingleton(() => FakeWindowService()); - await settings.init(); + await settings.init(monitorPlatformSettings: false); + settings.canUseAnalysisService = false; }); tearDown(() async { @@ -71,6 +86,57 @@ void main() { return source; } + test('album/country/tag hidden on launch when their items are hidden by entry prop', () async { + settings.hiddenFilters = {const AlbumFilter(testAlbum, 'whatever')}; + + final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + (metadataFetchService as FakeMetadataFetchService).setUp( + image1, + CatalogMetadata( + contentId: image1.contentId, + xmpSubjects: aTag, + latitude: australiaLatLng.latitude, + longitude: australiaLatLng.longitude, + ), + ); + + final source = await _initSource(); + expect(source.rawAlbums.length, 0); + expect(source.sortedCountries.length, 0); + expect(source.sortedTags.length, 0); + }); + + test('album/country/tag hidden on launch when their items are hidden by metadata', () async { + settings.hiddenFilters = {TagFilter(aTag)}; + + final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + (metadataFetchService as FakeMetadataFetchService).setUp( + image1, + CatalogMetadata( + contentId: image1.contentId, + xmpSubjects: aTag, + latitude: australiaLatLng.latitude, + longitude: australiaLatLng.longitude, + ), + ); + expect(image1.xmpSubjects, []); + + final source = await _initSource(); + expect(image1.xmpSubjects, [aTag]); + expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId)); + + expect(source.visibleEntries.length, 0); + expect(source.rawAlbums.length, 0); + expect(source.sortedCountries.length, 0); + expect(source.sortedTags.length, 0); + }); + test('add/remove favourite entry', () async { final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); (mediaStoreService as FakeMediaStoreService).entries = { diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart index f8a8ed2ad..af979fad6 100644 --- a/test/model/filters_test.dart +++ b/test/model/filters_test.dart @@ -1,36 +1,74 @@ import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../fake/media_store_service.dart'; +import '../fake/storage_service.dart'; + void main() { + setUp(() async { + // specify Posix style path context for consistent behaviour when running tests on Windows + getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); + }); + + tearDown(() async { + await getIt.reset(); + }); + test('Filter serialization', () { CollectionFilter? jsonRoundTrip(filter) => CollectionFilter.fromJson(filter.toJson()); const album = AlbumFilter('path/to/album', 'album'); expect(album, jsonRoundTrip(album)); + final bounds = CoordinateFilter(LatLng(29.979167, 28.223615), LatLng(36.451000, 31.134167)); + expect(bounds, jsonRoundTrip(bounds)); + const fav = FavouriteFilter.instance; expect(fav, jsonRoundTrip(fav)); final location = LocationFilter(LocationLevel.country, 'France${LocationFilter.locationSeparator}FR'); expect(location, jsonRoundTrip(location)); - final type = TypeFilter.sphericalVideo; - expect(type, jsonRoundTrip(type)); - final mime = MimeFilter.video; expect(mime, jsonRoundTrip(mime)); + final path = PathFilter('/some/path/'); + expect(path, jsonRoundTrip(path)); + final query = QueryFilter('some query'); expect(query, jsonRoundTrip(query)); final tag = TagFilter('some tag'); expect(tag, jsonRoundTrip(tag)); + + final type = TypeFilter.sphericalVideo; + expect(type, jsonRoundTrip(type)); + }); + + test('Path filter', () { + const rootAlbum = '${FakeStorageService.primaryPath}Pictures/test'; + const subAlbum = '${FakeStorageService.primaryPath}Pictures/test/sub'; + const siblingAlbum = '${FakeStorageService.primaryPath}Pictures/test sibling'; + + final rootImage = FakeMediaStoreService.newImage(rootAlbum, 'image1'); + final subImage = FakeMediaStoreService.newImage(subAlbum, 'image1'); + final siblingImage = FakeMediaStoreService.newImage(siblingAlbum, 'image1'); + + final path = PathFilter('$rootAlbum/'); + expect(path.test(rootImage), true); + expect(path.test(subImage), true); + expect(path.test(siblingImage), false); }); } diff --git a/test/utils/geo_utils_test.dart b/test/utils/geo_utils_test.dart index 0b2875ab9..29e242bed 100644 --- a/test/utils/geo_utils_test.dart +++ b/test/utils/geo_utils_test.dart @@ -3,8 +3,16 @@ import 'package:latlong2/latlong.dart'; import 'package:test/test.dart'; void main() { + test('Decimal degrees to DMS (sexagesimal)', () { + expect(GeoUtils.toDMS(LatLng(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam + expect(GeoUtils.toDMS(LatLng(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund + expect(GeoUtils.toDMS(LatLng(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo + expect(GeoUtils.toDMS(LatLng(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio + expect(GeoUtils.toDMS(LatLng(0, 0)), ['0° 0′ 0.00″ N', '0° 0′ 0.00″ E']); + }); + test('bounds center', () { - expect(getLatLngCenter([LatLng(10, 30), LatLng(30, 50)]), LatLng(20.28236664671092, 39.351653000319956)); - expect(getLatLngCenter([LatLng(10, -179), LatLng(30, 179)]), LatLng(20.00279344048298, -179.9358157370226)); + expect(GeoUtils.getLatLngCenter([LatLng(10, 30), LatLng(30, 50)]), LatLng(20.28236664671092, 39.351653000319956)); + expect(GeoUtils.getLatLngCenter([LatLng(10, -179), LatLng(30, 179)]), LatLng(20.00279344048298, -179.9358157370226)); }); } diff --git a/test_driver/driver_app.dart b/test_driver/driver_app.dart index 3b2bf575c..735978f0c 100644 --- a/test_driver/driver_app.dart +++ b/test_driver/driver_app.dart @@ -26,7 +26,7 @@ void main() { } Future configureAndLaunch() async { - await settings.init(); + await settings.init(monitorPlatformSettings: false); settings ..keepScreenOn = KeepScreenOn.always ..hasAcceptedTerms = false diff --git a/test_driver/driver_app_test.dart b/test_driver/driver_app_test.dart index 2b5bc11b1..13725a133 100644 --- a/test_driver/driver_app_test.dart +++ b/test_driver/driver_app_test.dart @@ -34,6 +34,7 @@ void main() { visitSettings(); sortCollection(); groupCollection(); + visitMap(); selectFirstAlbum(); searchAlbum(); showViewer(); @@ -119,6 +120,30 @@ void groupCollection() { }); } +void visitMap() { + test('[collection] visit map', () async { + await driver.tap(find.byValueKey('appbar-menu-button')); + await driver.waitUntilNoTransientCallbacks(); + + await driver.tap(find.byValueKey('menu-map')); + // wait for heavy Google map initialization + await Future.delayed(const Duration(seconds: 3)); + + final mapView = find.byValueKey('map_view'); + + print('* hide overlay'); + await driver.tap(mapView); + await Future.delayed(const Duration(seconds: 2)); + + print('* show overlay'); + await driver.tap(mapView); + await Future.delayed(const Duration(seconds: 2)); + + await pressDeviceBackButton(); + await driver.waitUntilNoTransientCallbacks(); + }); +} + void selectFirstAlbum() { test('[collection] select first album', () async { await driver.tap(find.byValueKey('appbar-leading-button')); @@ -183,7 +208,7 @@ void goToNextImage() { void toggleOverlay() { test('[viewer] toggle overlay', () async { - final imageView = find.byValueKey('imageview'); + final imageView = find.byValueKey('image_view'); print('* hide overlay'); await driver.tap(imageView); @@ -197,7 +222,7 @@ void toggleOverlay() { void zoom() { test('[viewer] zoom cycle', () async { - final imageView = find.byValueKey('imageview'); + final imageView = find.byValueKey('image_view'); await driver.doubleTap(imageView); await Future.delayed(const Duration(seconds: 1)); @@ -244,7 +269,7 @@ void showInfoMetadata() { void scrollOffImage() { test('[viewer] scroll off', () async { - await driver.scroll(find.byValueKey('imageview'), 0, 800, const Duration(milliseconds: 600)); + await driver.scroll(find.byValueKey('image_view'), 0, 800, const Duration(milliseconds: 600)); await Future.delayed(const Duration(seconds: 1)); }); } diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 517a469da..039c9cfcf 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,8 +1,6 @@ -Thanks for using Aves! -v1.5.3: -- faster launch -- UI changes for collection thumbnails and albums -- accessibility support for settings "time to take action" and "remove animations" -- open images and videos from the map -- remove metadata from images +Thanks for using Aves! In v1.5.4: +- modify files in the Download folder on Android 11 +- choose to rename, replace or skip when moving items with name conflict +- show images for a specific region from the Map page +- scanning many items is now happening in a service Full changelog available on GitHub \ No newline at end of file