diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AndroidVoiceNotePackager.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AndroidVoiceNotePackager.kt new file mode 100644 index 00000000000..0eb0c47eaaf --- /dev/null +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AndroidVoiceNotePackager.kt @@ -0,0 +1,270 @@ +package xyz.block.buzz.mobile + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaMuxer +import java.io.File +import java.nio.ByteBuffer +import java.util.UUID + +internal object AndroidVoiceNotePackager { + private const val videoMimeType = "video/avc" + private const val videoWidth = 16 + private const val videoHeight = 16 + private const val videoBitRate = 8_000 + private const val videoFrameRate = 1 + private const val dequeueTimeoutUs = 10_000L + private const val encoderTimeoutNs = 30_000_000_000L + private const val copyBufferSize = 1024 * 1024 + + fun packageForUpload( + sourcePath: String, + cacheDirectory: File, + ): String { + val source = File(sourcePath) + require(source.isFile) { "The recording could not be found." } + + val audio = findAudioTrack(sourcePath) + require(audio.durationUs > 0) { "The recording has no playable audio." } + require(audio.mimeType == MediaFormat.MIMETYPE_AUDIO_AAC) { + "The recording is not AAC audio." + } + + val videoFile = File(cacheDirectory, "${UUID.randomUUID()}-voice-note-video.mp4") + val outputFile = File(cacheDirectory, "${UUID.randomUUID()}.mp4") + try { + writeBlackVideoTrack(videoFile, audio.durationUs) + muxEnvelope( + audioPath = sourcePath, + audioTrackIndex = audio.index, + videoPath = videoFile.absolutePath, + outputPath = outputFile.absolutePath, + ) + return outputFile.absolutePath + } catch (error: Exception) { + outputFile.delete() + throw error + } finally { + videoFile.delete() + } + } + + private data class AudioTrack( + val index: Int, + val durationUs: Long, + val mimeType: String, + ) + + private fun findAudioTrack(sourcePath: String): AudioTrack { + val extractor = MediaExtractor() + try { + extractor.setDataSource(sourcePath) + for (index in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(index) + val mimeType = format.getString(MediaFormat.KEY_MIME) ?: continue + if (!mimeType.startsWith("audio/")) continue + val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) { + format.getLong(MediaFormat.KEY_DURATION) + } else { + 0L + } + return AudioTrack(index, durationUs, mimeType) + } + throw IllegalArgumentException("The recording does not contain an audio track.") + } finally { + extractor.release() + } + } + + private fun writeBlackVideoTrack( + outputFile: File, + durationUs: Long, + ) { + val codec = MediaCodec.createEncoderByType(videoMimeType) + var muxer: MediaMuxer? = null + try { + val colorFormat = codec.codecInfo + .getCapabilitiesForType(videoMimeType) + .colorFormats + .firstOrNull { + it == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible || + it == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar || + it == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar + } + ?: throw IllegalStateException("This device cannot create the voice note envelope.") + val format = MediaFormat.createVideoFormat( + videoMimeType, + videoWidth, + videoHeight, + ).apply { + setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat) + setInteger(MediaFormat.KEY_BIT_RATE, videoBitRate) + setInteger(MediaFormat.KEY_FRAME_RATE, videoFrameRate) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) + } + codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + codec.start() + + muxer = MediaMuxer( + outputFile.absolutePath, + MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4, + ) + val frame = blackYuv420Frame() + val frameTimesUs = longArrayOf(0L, durationUs) + val bufferInfo = MediaCodec.BufferInfo() + var nextFrame = 0 + var inputEnded = false + var outputEnded = false + var outputTrack = -1 + var muxerStarted = false + val encoderDeadlineNs = System.nanoTime() + encoderTimeoutNs + + while (!outputEnded) { + check(System.nanoTime() - encoderDeadlineNs < 0) { + "Timed out creating the voice note envelope." + } + if (!inputEnded) { + val inputIndex = codec.dequeueInputBuffer(dequeueTimeoutUs) + if (inputIndex >= 0) { + val inputBuffer = codec.getInputBuffer(inputIndex) + ?: throw IllegalStateException("Unable to create the video envelope.") + inputBuffer.clear() + if (nextFrame < frameTimesUs.size) { + inputBuffer.put(frame) + codec.queueInputBuffer( + inputIndex, + 0, + frame.size, + frameTimesUs[nextFrame], + 0, + ) + nextFrame += 1 + } else { + codec.queueInputBuffer( + inputIndex, + 0, + 0, + durationUs + 1, + MediaCodec.BUFFER_FLAG_END_OF_STREAM, + ) + inputEnded = true + } + } + } + + when (val outputIndex = codec.dequeueOutputBuffer(bufferInfo, dequeueTimeoutUs)) { + MediaCodec.INFO_TRY_AGAIN_LATER -> Unit + MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + check(!muxerStarted) { "The video encoder format changed twice." } + outputTrack = muxer.addTrack(codec.outputFormat) + muxer.start() + muxerStarted = true + } + else -> if (outputIndex >= 0) { + val outputBuffer = codec.getOutputBuffer(outputIndex) + ?: throw IllegalStateException("Unable to read the video envelope.") + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + bufferInfo.size = 0 + } + if (bufferInfo.size > 0) { + check(muxerStarted && outputTrack >= 0) { + "The video envelope has no output format." + } + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + muxer.writeSampleData(outputTrack, outputBuffer, bufferInfo) + } + outputEnded = + bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 + codec.releaseOutputBuffer(outputIndex, false) + } + } + } + + if (muxerStarted) muxer.stop() + } finally { + try { + codec.stop() + } catch (_: Exception) { + // Best-effort encoder cleanup after a packaging failure. + } + codec.release() + try { + muxer?.release() + } catch (_: Exception) { + // Best-effort muxer cleanup after a packaging failure. + } + } + } + + private fun blackYuv420Frame(): ByteArray { + val yPlaneSize = videoWidth * videoHeight + return ByteArray(yPlaneSize * 3 / 2) { index -> + if (index < yPlaneSize) 16 else 128.toByte() + } + } + + private fun muxEnvelope( + audioPath: String, + audioTrackIndex: Int, + videoPath: String, + outputPath: String, + ) { + val audioExtractor = MediaExtractor() + val videoExtractor = MediaExtractor() + var muxer: MediaMuxer? = null + try { + audioExtractor.setDataSource(audioPath) + audioExtractor.selectTrack(audioTrackIndex) + videoExtractor.setDataSource(videoPath) + val videoTrackIndex = (0 until videoExtractor.trackCount).firstOrNull { index -> + videoExtractor.getTrackFormat(index) + .getString(MediaFormat.KEY_MIME) + ?.startsWith("video/") == true + } ?: throw IllegalStateException("The video envelope has no video track.") + videoExtractor.selectTrack(videoTrackIndex) + + muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + val destinationVideoTrack = muxer.addTrack( + videoExtractor.getTrackFormat(videoTrackIndex), + ) + val destinationAudioTrack = muxer.addTrack( + audioExtractor.getTrackFormat(audioTrackIndex), + ) + muxer.start() + copyTrack(videoExtractor, muxer, destinationVideoTrack) + copyTrack(audioExtractor, muxer, destinationAudioTrack) + muxer.stop() + } finally { + audioExtractor.release() + videoExtractor.release() + try { + muxer?.release() + } catch (_: Exception) { + // Best-effort muxer cleanup after a packaging failure. + } + } + } + + private fun copyTrack( + extractor: MediaExtractor, + muxer: MediaMuxer, + destinationTrack: Int, + ) { + val buffer = ByteBuffer.allocate(copyBufferSize) + val bufferInfo = MediaCodec.BufferInfo() + while (true) { + buffer.clear() + val sampleSize = extractor.readSampleData(buffer, 0) + if (sampleSize < 0) return + bufferInfo.offset = 0 + bufferInfo.size = sampleSize + bufferInfo.presentationTimeUs = extractor.sampleTime + bufferInfo.flags = extractor.sampleFlags + muxer.writeSampleData(destinationTrack, buffer, bufferInfo) + extractor.advance() + } + } +} diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt index e5719da0b7c..66cf6059793 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt @@ -107,6 +107,9 @@ class MainActivity : FlutterFragmentActivity() { GENERATE_VIDEO_POSTER_METHOD -> { handleGenerateVideoPoster(call.arguments, result) } + PACKAGE_VOICE_NOTE_FOR_UPLOAD_METHOD -> { + handlePackageVoiceNoteForUpload(call.arguments, result) + } REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD -> { result.success(Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) } @@ -349,6 +352,33 @@ class MainActivity : FlutterFragmentActivity() { }.start() } + private fun handlePackageVoiceNoteForUpload( + arguments: Any?, + result: MethodChannel.Result, + ) { + val sourcePath = arguments as? String ?: run { + invalidArguments(result, "Expected source file path as String.") + return + } + + Thread { + try { + result.success( + AndroidVoiceNotePackager.packageForUpload( + sourcePath = sourcePath, + cacheDirectory = cacheDir, + ), + ) + } catch (error: Exception) { + result.error( + "transcode_failed", + "Unable to assemble voice note for upload.", + error.message, + ) + } + }.start() + } + private fun invalidArguments( result: MethodChannel.Result, message: String, @@ -362,6 +392,7 @@ class MainActivity : FlutterFragmentActivity() { private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg" private const val TRANSCODE_VIDEO_TO_MP4_METHOD = "transcodeVideoToMp4" private const val GENERATE_VIDEO_POSTER_METHOD = "generateVideoPoster" + private const val PACKAGE_VOICE_NOTE_FOR_UPLOAD_METHOD = "packageVoiceNoteForUpload" private const val REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD = "requiresLegacyMediaStoragePermission" } diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 491da241e8f..4a8911b5c46 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -3,6 +3,8 @@ PODS: - Flutter - app_links (6.4.1): - Flutter + - audio_session (0.0.1): + - Flutter - camera_avfoundation (0.0.1): - Flutter - connectivity_plus (0.0.1): @@ -45,6 +47,9 @@ PODS: - GTMSessionFetcher/Core (3.5.0) - image_picker_ios (0.0.1): - Flutter + - just_audio (0.0.1): + - Flutter + - FlutterMacOS - local_auth_darwin (0.0.1): - Flutter - FlutterMacOS @@ -86,6 +91,8 @@ PODS: - Flutter - FlutterMacOS - PromisesObjC (2.4.0) + - record_ios (1.2.1): + - Flutter - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -100,6 +107,7 @@ PODS: DEPENDENCIES: - app_badge_plus (from `.symlinks/plugins/app_badge_plus/ios`) - app_links (from `.symlinks/plugins/app_links/ios`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) @@ -108,11 +116,13 @@ DEPENDENCIES: - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) - google_mlkit_selfie_segmentation (from `.symlinks/plugins/google_mlkit_selfie_segmentation/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/darwin`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -139,6 +149,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/app_badge_plus/ios" app_links: :path: ".symlinks/plugins/app_links/ios" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" camera_avfoundation: :path: ".symlinks/plugins/camera_avfoundation/ios" connectivity_plus: @@ -155,6 +167,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/google_mlkit_selfie_segmentation/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" local_auth_darwin: :path: ".symlinks/plugins/local_auth_darwin/darwin" mobile_scanner: @@ -165,6 +179,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" photo_manager: :path: ".symlinks/plugins/photo_manager/darwin" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -177,6 +193,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_badge_plus: 09939f19a075cc742cc155d8ed85e6d8601f0104 app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a @@ -190,6 +207,7 @@ SPEC CHECKSUMS: GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6 @@ -203,6 +221,7 @@ SPEC CHECKSUMS: package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 photo_manager: 6ab48c2ce7ec21aa06d59e6cc049f0b6d9ba7f94 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + record_ios: 980fd386a97a35987d0fce3dfda4b26a38c90f4c share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 3061f92b6a8..21946f40772 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -29,6 +29,9 @@ 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; }; 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; }; 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; }; + 4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */; }; + 4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */; }; + 4A71C0232F4120100A17E01 /* NativeAttachmentPopoverMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */; }; 331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; }; 331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; }; 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; }; @@ -105,6 +108,9 @@ 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.swift; sourceTree = ""; }; 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSkinToneControl.swift; sourceTree = ""; }; 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGlassControl.swift; sourceTree = ""; }; + 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceNotePackager.swift; sourceTree = ""; }; + 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MP4Canonicalizer.swift; sourceTree = ""; }; + 4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverMenu.swift; sourceTree = ""; }; 331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; }; 331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -259,6 +265,9 @@ 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */, + 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */, + 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */, + 4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -583,6 +592,9 @@ 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */, 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */, + 4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */, + 4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */, + 4A71C0232F4120100A17E01 /* NativeAttachmentPopoverMenu.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index a770451b619..47c99eb9eab 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -625,6 +625,18 @@ import os.log return } transcodeVideoToMp4(sourcePath: sourcePath, result: result) + case "packageVoiceNoteForUpload": + guard let sourcePath = call.arguments as? String else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected source file path as String.", + details: nil + ) + ) + return + } + VoiceNotePackager.package(sourcePath: sourcePath, result: result) case "generateVideoPoster": guard let sourcePath = call.arguments as? String else { result( @@ -773,7 +785,7 @@ import os.log // Older Buzz relays mistook that playback-only box for metadata. Keep // its size and payload in a `free` box so chunk offsets stay valid and // uploads work before those relays receive the validator fix. - try Self.neutralizeSampleDependencyBoxes(at: outputURL) + try MP4Canonicalizer.neutralizeSampleDependencyBoxes(at: outputURL) result(outputURL.path) } catch { try? FileManager.default.removeItem(at: outputURL) @@ -883,76 +895,6 @@ import os.log } } } - - private static func neutralizeSampleDependencyBoxes(at url: URL) throws { - var data = try Data(contentsOf: url) - try neutralizeSampleDependencyBoxes(in: &data, start: 0, end: data.count) - try data.write(to: url, options: .atomic) - } - - private static func neutralizeSampleDependencyBoxes( - in data: inout Data, - start: Int, - end: Int - ) throws { - let containers: Set<[UInt8]> = [ - Array("moov".utf8), Array("trak".utf8), Array("mdia".utf8), - Array("minf".utf8), Array("stbl".utf8), Array("edts".utf8), - Array("dinf".utf8), Array("sinf".utf8), Array("schi".utf8), - ] - let sampleDependencyType = Array("sdtp".utf8) - let freeType = Array("free".utf8) - var offset = start - - while offset < end { - guard end - offset >= 8 else { throw invalidMp4BoxError() } - let compactSize = Int(readBigEndianUInt32(data, at: offset)) - var headerSize = 8 - let boxSize: Int - if compactSize == 1 { - guard end - offset >= 16 else { throw invalidMp4BoxError() } - let extendedSize = readBigEndianUInt64(data, at: offset + 8) - guard extendedSize <= UInt64(Int.max) else { throw invalidMp4BoxError() } - boxSize = Int(extendedSize) - headerSize = 16 - } else if compactSize == 0 { - boxSize = end - offset - } else { - boxSize = compactSize - } - - guard boxSize >= headerSize, offset + boxSize <= end else { - throw invalidMp4BoxError() - } - let type = Array(data[(offset + 4)..<(offset + 8)]) - if type == sampleDependencyType { - data.replaceSubrange((offset + 4)..<(offset + 8), with: freeType) - } else if containers.contains(type) { - try neutralizeSampleDependencyBoxes( - in: &data, - start: offset + headerSize, - end: offset + boxSize - ) - } - offset += boxSize - } - } - - private static func readBigEndianUInt32(_ data: Data, at offset: Int) -> UInt32 { - data[offset..<(offset + 4)].reduce(0) { ($0 << 8) | UInt32($1) } - } - - private static func readBigEndianUInt64(_ data: Data, at offset: Int) -> UInt64 { - data[offset..<(offset + 8)].reduce(0) { ($0 << 8) | UInt64($1) } - } - - private static func invalidMp4BoxError() -> NSError { - NSError( - domain: "BuzzVideoTranscode", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Invalid MP4 box structure."] - ) - } } extension BuzzPushNavigationTarget { diff --git a/mobile/ios/Runner/ConcentricSheetSurface.swift b/mobile/ios/Runner/ConcentricSheetSurface.swift index a1da7650a5f..6985cf440a3 100644 --- a/mobile/ios/Runner/ConcentricSheetSurface.swift +++ b/mobile/ios/Runner/ConcentricSheetSurface.swift @@ -31,6 +31,8 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView { private let rootView: UIView private let surfaceView: UIView private let channel: FlutterMethodChannel + private let corners: String + private let usesGlass: Bool init( frame: CGRect, @@ -42,15 +44,24 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView { let colorValue = (arguments?["color"] as? NSNumber)?.uint32Value ?? 0xFFFF_FFFF let backdropColorValue = (arguments?["backdropColor"] as? NSNumber)?.uint32Value let minimumRadius = (arguments?["minimumRadius"] as? NSNumber)?.doubleValue ?? 24 - let corners = arguments?["corners"] as? String ?? "all" + corners = arguments?["corners"] as? String ?? "all" + usesGlass = (arguments?["usesGlass"] as? NSNumber)?.boolValue == true rootView = UIView(frame: frame) - rootView.isOpaque = backdropColorValue != nil + rootView.isUserInteractionEnabled = false + rootView.isOpaque = !usesGlass && backdropColorValue != nil rootView.backgroundColor = backdropColorValue.map { Self.color(from: $0) } ?? .clear - surfaceView = UIView(frame: rootView.bounds) - surfaceView.isOpaque = true - surfaceView.backgroundColor = Self.color(from: colorValue) + if #available(iOS 26.0, *), usesGlass { + let effect = UIGlassEffect(style: .regular) + effect.isInteractive = false + surfaceView = UIVisualEffectView(effect: effect) + } else { + surfaceView = UIView() + } + surfaceView.frame = rootView.bounds + surfaceView.isUserInteractionEnabled = false + surfaceView.isOpaque = !usesGlass surfaceView.clipsToBounds = true surfaceView.layer.cornerCurve = .continuous surfaceView.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -61,52 +72,63 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView { binaryMessenger: messenger ) - if #available(iOS 26.0, *) { - let radius = UICornerRadius.containerConcentric(minimum: minimumRadius) - surfaceView.cornerConfiguration = - corners == "bottom" - ? .uniformBottomRadius( - radius, - topLeftRadius: nil, - topRightRadius: nil - ) - : .uniformCorners(radius: radius) - } else { - surfaceView.layer.cornerRadius = minimumRadius - if corners == "bottom" { - surfaceView.layer.maskedCorners = [ - .layerMinXMaxYCorner, - .layerMaxXMaxYCorner, - ] - } - } - super.init() + updateColors( + colorValue: colorValue, + backdropColorValue: backdropColorValue + ) + applyInterfaceStyle(from: arguments?["brightness"]) + applyCornerConfiguration(minimumRadius: minimumRadius) + channel.setMethodCallHandler { [weak self] call, result in - guard call.method == "updateColors" else { - result(FlutterMethodNotImplemented) + guard let self else { + result(nil) return } - guard - let arguments = call.arguments as? [String: Any], - let colorValue = arguments["color"] as? NSNumber - else { - result( - FlutterError( - code: "invalid_arguments", - message: "Expected a surface color.", - details: nil + switch call.method { + case "updateColors": + guard + let arguments = call.arguments as? [String: Any], + let colorValue = arguments["color"] as? NSNumber + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected a surface color.", + details: nil + ) ) + return + } + + self.updateColors( + colorValue: colorValue.uint32Value, + backdropColorValue: (arguments["backdropColor"] as? NSNumber)?.uint32Value ) - return + result(nil) + case "updateGeometry": + guard + let arguments = call.arguments as? [String: Any], + let minimumRadius = arguments["minimumRadius"] as? NSNumber + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected a minimum corner radius.", + details: nil + ) + ) + return + } + self.applyInterfaceStyle(from: arguments["brightness"]) + self.applyCornerConfiguration( + minimumRadius: minimumRadius.doubleValue + ) + result(nil) + default: + result(FlutterMethodNotImplemented) } - - self?.updateColors( - colorValue: colorValue.uint32Value, - backdropColorValue: (arguments["backdropColor"] as? NSNumber)?.uint32Value - ) - result(nil) } } @@ -119,11 +141,50 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView { } private func updateColors(colorValue: UInt32, backdropColorValue: UInt32?) { - surfaceView.backgroundColor = Self.color(from: colorValue) - rootView.isOpaque = backdropColorValue != nil + let surfaceColor = Self.color(from: colorValue) + if let glassView = surfaceView as? UIVisualEffectView { + glassView.backgroundColor = .clear + glassView.contentView.backgroundColor = surfaceColor.withAlphaComponent(0.12) + } else { + surfaceView.backgroundColor = surfaceColor + } + rootView.isOpaque = !usesGlass && backdropColorValue != nil rootView.backgroundColor = backdropColorValue.map { Self.color(from: $0) } ?? .clear } + private func applyCornerConfiguration(minimumRadius: CGFloat) { + if #available(iOS 26.0, *) { + let radius = UICornerRadius.containerConcentric(minimum: minimumRadius) + surfaceView.cornerConfiguration = + corners == "bottom" + ? .uniformBottomRadius( + radius, + topLeftRadius: nil, + topRightRadius: nil + ) + : .uniformCorners(radius: radius) + } else { + surfaceView.layer.cornerRadius = minimumRadius + if corners == "bottom" { + surfaceView.layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner, + ] + } + } + } + + private func applyInterfaceStyle(from value: Any?) { + switch value as? String { + case "dark": + rootView.overrideUserInterfaceStyle = .dark + case "light": + rootView.overrideUserInterfaceStyle = .light + default: + rootView.overrideUserInterfaceStyle = .unspecified + } + } + private static func color(from value: UInt32) -> UIColor { let alpha = CGFloat((value >> 24) & 0xFF) / 255 let red = CGFloat((value >> 16) & 0xFF) / 255 diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 544f2517bdb..5dfde907785 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -52,7 +52,7 @@ NSCameraUsageDescription Buzz uses the camera to take profile photos and animated avatars, attach photos to messages, and scan QR codes for device pairing. NSMicrophoneUsageDescription - Buzz needs microphone access so you can speak in Huddles. + Buzz needs microphone access so you can speak in Huddles and record voice notes. NSPhotoLibraryUsageDescription Buzz uses your photo library to select profile photos and images to attach to messages. NSPhotoLibraryAddUsageDescription diff --git a/mobile/ios/Runner/MP4Canonicalizer.swift b/mobile/ios/Runner/MP4Canonicalizer.swift new file mode 100644 index 00000000000..c6dd49a2683 --- /dev/null +++ b/mobile/ios/Runner/MP4Canonicalizer.swift @@ -0,0 +1,73 @@ +import Foundation + +enum MP4Canonicalizer { + static func neutralizeSampleDependencyBoxes(at url: URL) throws { + var data = try Data(contentsOf: url) + try neutralizeSampleDependencyBoxes(in: &data, start: 0, end: data.count) + try data.write(to: url, options: .atomic) + } + + private static func neutralizeSampleDependencyBoxes( + in data: inout Data, + start: Int, + end: Int + ) throws { + let containers: Set<[UInt8]> = [ + Array("moov".utf8), Array("trak".utf8), Array("mdia".utf8), + Array("minf".utf8), Array("stbl".utf8), Array("edts".utf8), + Array("dinf".utf8), Array("sinf".utf8), Array("schi".utf8), + ] + let sampleDependencyType = Array("sdtp".utf8) + let freeType = Array("free".utf8) + var offset = start + + while offset < end { + guard end - offset >= 8 else { throw invalidMp4BoxError() } + let compactSize = Int(readBigEndianUInt32(data, at: offset)) + var headerSize = 8 + let boxSize: Int + if compactSize == 1 { + guard end - offset >= 16 else { throw invalidMp4BoxError() } + let extendedSize = readBigEndianUInt64(data, at: offset + 8) + guard extendedSize <= UInt64(Int.max) else { throw invalidMp4BoxError() } + boxSize = Int(extendedSize) + headerSize = 16 + } else if compactSize == 0 { + boxSize = end - offset + } else { + boxSize = compactSize + } + + guard boxSize >= headerSize, offset + boxSize <= end else { + throw invalidMp4BoxError() + } + let type = Array(data[(offset + 4)..<(offset + 8)]) + if type == sampleDependencyType { + data.replaceSubrange((offset + 4)..<(offset + 8), with: freeType) + } else if containers.contains(type) { + try neutralizeSampleDependencyBoxes( + in: &data, + start: offset + headerSize, + end: offset + boxSize + ) + } + offset += boxSize + } + } + + private static func readBigEndianUInt32(_ data: Data, at offset: Int) -> UInt32 { + data[offset..<(offset + 4)].reduce(0) { ($0 << 8) | UInt32($1) } + } + + private static func readBigEndianUInt64(_ data: Data, at offset: Int) -> UInt64 { + data[offset..<(offset + 8)].reduce(0) { ($0 << 8) | UInt64($1) } + } + + private static func invalidMp4BoxError() -> NSError { + NSError( + domain: "BuzzVideoTranscode", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid MP4 box structure."] + ) + } +} diff --git a/mobile/ios/Runner/NativeAttachmentPopover.swift b/mobile/ios/Runner/NativeAttachmentPopover.swift index cc29de9cb32..f95d2866c49 100644 --- a/mobile/ios/Runner/NativeAttachmentPopover.swift +++ b/mobile/ios/Runner/NativeAttachmentPopover.swift @@ -49,7 +49,7 @@ final class NativeAttachmentPopoverViewController: private var isFinishing = false private var didNotifyDismissal = false private var keyboardDismissalOffset: CGFloat = 0 - private var menuStackHeightConstraint: NSLayoutConstraint? + var menuStackHeightConstraint: NSLayoutConstraint? var onDismiss: (() -> Void)? @@ -164,86 +164,6 @@ final class NativeAttachmentPopoverViewController: notifyDismissalIfNeeded() } - private func makeMenuView() -> UIView { - let container = UIView() - container.translatesAutoresizingMaskIntoConstraints = false - - let scrollView = UIScrollView() - scrollView.alwaysBounceVertical = false - scrollView.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(scrollView) - - let stack = UIStackView() - stack.axis = .vertical - stack.distribution = .fillEqually - stack.spacing = NativeAttachmentMenuLayout.itemSpacing - stack.translatesAutoresizingMaskIntoConstraints = false - scrollView.addSubview(stack) - let stackHeightConstraint = stack.heightAnchor.constraint( - equalToConstant: NativeAttachmentMenuLayout.itemsHeight( - compatibleWith: traitCollection - ) - ) - menuStackHeightConstraint = stackHeightConstraint - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: container.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - stack.leadingAnchor.constraint( - equalTo: scrollView.frameLayoutGuide.leadingAnchor, - constant: NativeAttachmentMenuLayout.contentPadding - ), - stack.trailingAnchor.constraint( - equalTo: scrollView.frameLayoutGuide.trailingAnchor, - constant: -NativeAttachmentMenuLayout.contentPadding - ), - stack.topAnchor.constraint( - equalTo: scrollView.contentLayoutGuide.topAnchor, - constant: NativeAttachmentMenuLayout.contentPadding - ), - stack.bottomAnchor.constraint( - equalTo: scrollView.contentLayoutGuide.bottomAnchor, - constant: -NativeAttachmentMenuLayout.contentPadding - ), - stackHeightConstraint, - ]) - - stack.addArrangedSubview( - makeNativeAttachmentMenuButton( - title: "Camera", - symbol: "camera", - action: { [weak self] in self?.showCamera() } - ) - ) - stack.addArrangedSubview( - makeNativeAttachmentMenuButton( - title: "Photos", - symbol: "photo.on.rectangle.angled", - action: { [weak self] in self?.showPhotos() } - ) - ) - stack.addArrangedSubview( - makeNativeAttachmentMenuButton( - title: "Video", - symbol: "video", - action: { [weak self] in - self?.finish(method: "pickVideo") - } - ) - ) - stack.addArrangedSubview( - makeNativeAttachmentMenuButton( - title: "Files", - symbol: "doc", - action: { [weak self] in - self?.finish(method: "pickFiles") - } - ) - ) - return container - } - private func updateMenuLayout() { menuStackHeightConstraint?.constant = NativeAttachmentMenuLayout.itemsHeight( @@ -254,7 +174,7 @@ final class NativeAttachmentPopoverViewController: } } - private func showPhotos() { + func showPhotos() { guard surface != .photos else { return } prepareForExpandedSurface() stopCamera() @@ -314,7 +234,7 @@ final class NativeAttachmentPopoverViewController: transition(to: .photos, content: container) } - private func showCamera() { + func showCamera() { guard surface != .camera else { return } prepareForExpandedSurface() removePhotoPicker() @@ -483,51 +403,6 @@ final class NativeAttachmentPopoverViewController: } } - private func makeGlassControl( - title: String?, - symbol: String?, - accessibilityLabel: String, - prominent: Bool = false, - action: @escaping () -> Void - ) -> UIButton { - var configuration = - prominent - ? UIButton.Configuration.prominentGlass() - : UIButton.Configuration.glass() - configuration.title = title - if prominent { - configuration.baseBackgroundColor = .black - } - if let symbol { - configuration.image = UIImage(systemName: symbol) - } - configuration.imagePadding = 8 - configuration.baseForegroundColor = .white - configuration.titleTextAttributesTransformer = - UIConfigurationTextAttributesTransformer { attributes in - var interAttributes = attributes - interAttributes.font = NativeAttachmentMenuTypography.font( - forTextStyle: .body - ) - return interAttributes - } - configuration.contentInsets = NSDirectionalEdgeInsets( - top: 11, - leading: 15, - bottom: 11, - trailing: 15 - ) - let button = UIButton( - configuration: configuration, - primaryAction: UIAction { _ in - UISelectionFeedbackGenerator().selectionChanged() - action() - } - ) - button.accessibilityLabel = accessibilityLabel - return button - } - private func makeCameraCaptureButton() -> UIButton { let button = UIButton( primaryAction: UIAction { [weak self] _ in @@ -1034,10 +909,11 @@ final class NativeAttachmentPopoverViewController: photoPickerViewController = nil } - private func finish( + func finish( method: String, arguments: Any? = nil, - temporaryPaths: [String] = [] + temporaryPaths: [String] = [], + notifyBeforeDismissal: Bool = false ) { guard !isFinishing else { Self.removeTemporaryFiles(temporaryPaths) @@ -1050,13 +926,20 @@ final class NativeAttachmentPopoverViewController: selectionTask?.cancel() selectionTask = nil stopCamera() + if notifyBeforeDismissal { + channel.invokeMethod(method, arguments: arguments) { _ in + Self.removeTemporaryFiles(temporaryPaths) + } + } dismiss(animated: true) { [weak self] in guard let self else { Self.removeTemporaryFiles(temporaryPaths) return } - self.channel.invokeMethod(method, arguments: arguments) { _ in - Self.removeTemporaryFiles(temporaryPaths) + if !notifyBeforeDismissal { + self.channel.invokeMethod(method, arguments: arguments) { _ in + Self.removeTemporaryFiles(temporaryPaths) + } } self.notifyDismissalIfNeeded() } diff --git a/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift index f59db1f84c4..0bb2cf949b7 100644 --- a/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift +++ b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift @@ -265,7 +265,7 @@ final class NativeAttachmentPopoverCoordinator: NSObject { } enum NativeAttachmentMenuLayout { - static let itemCount: CGFloat = 4 + static let itemCount: CGFloat = 5 static let contentPadding: CGFloat = 16 static let minimumItemHeight: CGFloat = 52 static let itemSpacing: CGFloat = 8 diff --git a/mobile/ios/Runner/NativeAttachmentPopoverMenu.swift b/mobile/ios/Runner/NativeAttachmentPopoverMenu.swift new file mode 100644 index 00000000000..9bd8fdaebbc --- /dev/null +++ b/mobile/ios/Runner/NativeAttachmentPopoverMenu.swift @@ -0,0 +1,142 @@ +import Flutter +import UIKit + +@available(iOS 26.0, *) +extension NativeAttachmentPopoverViewController { + func makeMenuView() -> UIView { + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + + let scrollView = UIScrollView() + scrollView.alwaysBounceVertical = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(scrollView) + + let stack = UIStackView() + stack.axis = .vertical + stack.distribution = .fillEqually + stack.spacing = NativeAttachmentMenuLayout.itemSpacing + stack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stack) + let stackHeightConstraint = stack.heightAnchor.constraint( + equalToConstant: NativeAttachmentMenuLayout.itemsHeight( + compatibleWith: traitCollection + ) + ) + menuStackHeightConstraint = stackHeightConstraint + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: container.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + stack.leadingAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.leadingAnchor, + constant: NativeAttachmentMenuLayout.contentPadding + ), + stack.trailingAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.trailingAnchor, + constant: -NativeAttachmentMenuLayout.contentPadding + ), + stack.topAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.topAnchor, + constant: NativeAttachmentMenuLayout.contentPadding + ), + stack.bottomAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.bottomAnchor, + constant: -NativeAttachmentMenuLayout.contentPadding + ), + stackHeightConstraint, + ]) + + stack.addArrangedSubview( + makeNativeAttachmentMenuButton( + title: "Camera", + symbol: "camera", + action: { [weak self] in self?.showCamera() } + ) + ) + stack.addArrangedSubview( + makeNativeAttachmentMenuButton( + title: "Photos", + symbol: "photo.on.rectangle.angled", + action: { [weak self] in self?.showPhotos() } + ) + ) + stack.addArrangedSubview( + makeNativeAttachmentMenuButton( + title: "Video", + symbol: "video", + action: { [weak self] in + self?.finish(method: "pickVideo") + } + ) + ) + stack.addArrangedSubview( + makeNativeAttachmentMenuButton( + title: "Voice note", + symbol: "mic", + action: { [weak self] in + self?.finish( + method: "recordVoiceNote", + notifyBeforeDismissal: true + ) + } + ) + ) + stack.addArrangedSubview( + makeNativeAttachmentMenuButton( + title: "Files", + symbol: "doc", + action: { [weak self] in + self?.finish(method: "pickFiles") + } + ) + ) + return container + } + + func makeGlassControl( + title: String?, + symbol: String?, + accessibilityLabel: String, + prominent: Bool = false, + action: @escaping () -> Void + ) -> UIButton { + var configuration = + prominent + ? UIButton.Configuration.prominentGlass() + : UIButton.Configuration.glass() + configuration.title = title + if prominent { + configuration.baseBackgroundColor = .black + } + if let symbol { + configuration.image = UIImage(systemName: symbol) + } + configuration.imagePadding = 8 + configuration.baseForegroundColor = .white + configuration.titleTextAttributesTransformer = + UIConfigurationTextAttributesTransformer { attributes in + var interAttributes = attributes + interAttributes.font = NativeAttachmentMenuTypography.font( + forTextStyle: .body + ) + return interAttributes + } + configuration.contentInsets = NSDirectionalEdgeInsets( + top: 11, + leading: 15, + bottom: 11, + trailing: 15 + ) + let button = UIButton( + configuration: configuration, + primaryAction: UIAction { _ in + UISelectionFeedbackGenerator().selectionChanged() + action() + } + ) + button.accessibilityLabel = accessibilityLabel + return button + } +} diff --git a/mobile/ios/Runner/VoiceNotePackager.swift b/mobile/ios/Runner/VoiceNotePackager.swift new file mode 100644 index 00000000000..8f5793d2394 --- /dev/null +++ b/mobile/ios/Runner/VoiceNotePackager.swift @@ -0,0 +1,365 @@ +import AVFoundation +import Flutter + +enum VoiceNotePackager { + static let videoEnvelopeTimeout: TimeInterval = 30 + static let exportTimeout: TimeInterval = 30 + + static func package( + sourcePath: String, + result: @escaping FlutterResult + ) { + let sourceAsset = AVURLAsset(url: URL(fileURLWithPath: sourcePath)) + guard let sourceAudio = sourceAsset.tracks(withMediaType: .audio).first else { + result( + FlutterError( + code: "transcode_failed", + message: "The recording does not contain an audio track.", + details: nil + ) + ) + return + } + + let duration = sourceAudio.timeRange.duration + guard duration.isValid, duration.isNumeric, CMTimeCompare(duration, .zero) > 0 else { + result( + FlutterError( + code: "transcode_failed", + message: "The recording has no playable audio.", + details: nil + ) + ) + return + } + + Self.makeVoiceNoteVideoTrack(duration: duration) { trackResult in + switch trackResult { + case let .failure(error): + result( + FlutterError( + code: "transcode_failed", + message: "Unable to prepare voice note for upload.", + details: error.localizedDescription + ) + ) + case let .success(videoURL): + exportVoiceNoteEnvelope( + sourceURL: URL(fileURLWithPath: sourcePath), + duration: duration, + videoURL: videoURL, + result: result + ) + } + } + } + + private static func makeVoiceNoteVideoTrack( + duration: CMTime, + completion: @escaping (Result) -> Void + ) { + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("mp4") + + do { + let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) + let input = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: 16, + AVVideoHeightKey: 16, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: 8000, + AVVideoExpectedSourceFrameRateKey: 1, + AVVideoMaxKeyFrameIntervalKey: 1, + ], + ] + ) + input.expectsMediaDataInRealTime = false + guard writer.canAdd(input) else { + throw NSError( + domain: "BuzzVoiceNote", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unable to create the video envelope."] + ) + } + writer.add(input) + let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: 16, + kCVPixelBufferHeightKey as String: 16, + ] + ) + guard writer.startWriting() else { + throw writer.error + ?? NSError( + domain: "BuzzVoiceNote", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Unable to start the video envelope."] + ) + } + writer.startSession(atSourceTime: .zero) + + let queue = DispatchQueue(label: "xyz.block.buzz.voice-note-envelope") + var completed = false + func complete(_ trackResult: Result) { + guard !completed else { return } + completed = true + if case .failure = trackResult { + writer.cancelWriting() + try? FileManager.default.removeItem(at: outputURL) + } + completion(trackResult) + } + queue.asyncAfter(deadline: .now() + videoEnvelopeTimeout) { + complete( + .failure( + NSError( + domain: "BuzzVoiceNote", + code: 9, + userInfo: [NSLocalizedDescriptionKey: "Video envelope generation timed out."] + ) + ) + ) + } + var appendedFrames = false + input.requestMediaDataWhenReady(on: queue) { + guard !completed, !appendedFrames, input.isReadyForMoreMediaData else { return } + appendedFrames = true + guard + let pool = adaptor.pixelBufferPool, + let buffer = Self.makeBlackPixelBuffer(pool: pool), + adaptor.append(buffer, withPresentationTime: .zero), + adaptor.append(buffer, withPresentationTime: duration) + else { + complete( + .failure( + writer.error + ?? NSError( + domain: "BuzzVoiceNote", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Unable to write the video envelope."] + ) + ) + ) + return + } + input.markAsFinished() + writer.endSession(atSourceTime: duration) + writer.finishWriting { + queue.async { + if writer.status == .completed { + complete(.success(outputURL)) + } else { + complete( + .failure( + writer.error + ?? NSError( + domain: "BuzzVoiceNote", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "Unable to finish the video envelope."] + ) + ) + ) + } + } + } + } + } catch { + try? FileManager.default.removeItem(at: outputURL) + completion(.failure(error)) + } + } + + private static func makeBlackPixelBuffer(pool: CVPixelBufferPool) -> CVPixelBuffer? { + var pixelBuffer: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBuffer) == kCVReturnSuccess, + let pixelBuffer + else { + return nil + } + CVPixelBufferLockBaseAddress(pixelBuffer, []) + if let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) { + memset(baseAddress, 0, CVPixelBufferGetDataSize(pixelBuffer)) + } + CVPixelBufferUnlockBaseAddress(pixelBuffer, []) + return pixelBuffer + } + + private static func exportVoiceNoteEnvelope( + sourceURL: URL, + duration: CMTime, + videoURL: URL, + result: @escaping FlutterResult + ) { + // Reload the recording here so its AVAsset stays alive for the entire + // composition insert. Keeping only an AVAssetTrack across the asynchronous + // video-envelope write can leave the track detached from its source asset + // on physical devices. + let sourceAsset = AVURLAsset(url: sourceURL) + let videoAsset = AVURLAsset(url: videoURL) + let composition = AVMutableComposition() + do { + guard + let sourceAudio = sourceAsset.tracks(withMediaType: .audio).first, + let sourceVideo = videoAsset.tracks(withMediaType: .video).first, + let destinationVideo = composition.addMutableTrack( + withMediaType: .video, + preferredTrackID: kCMPersistentTrackID_Invalid + ), + let destinationAudio = composition.addMutableTrack( + withMediaType: .audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + else { + throw NSError( + domain: "BuzzVoiceNote", + code: 5, + userInfo: [NSLocalizedDescriptionKey: "Unable to assemble the voice note envelope."] + ) + } + let sourceVideoRange = sourceVideo.timeRange + try destinationVideo.insertTimeRange(sourceVideoRange, of: sourceVideo, at: .zero) + destinationVideo.scaleTimeRange( + CMTimeRange(start: .zero, duration: sourceVideoRange.duration), + toDuration: duration + ) + try destinationAudio.insertTimeRange(sourceAudio.timeRange, of: sourceAudio, at: .zero) + } catch { + try? FileManager.default.removeItem(at: videoURL) + result( + FlutterError( + code: "transcode_failed", + message: "Unable to assemble voice note for upload.", + details: error.localizedDescription + ) + ) + return + } + + guard let exportSession = AVAssetExportSession( + asset: composition, + presetName: AVAssetExportPresetMediumQuality + ) else { + try? FileManager.default.removeItem(at: videoURL) + result( + FlutterError( + code: "transcode_failed", + message: "Unable to create voice note export session.", + details: nil + ) + ) + return + } + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("mp4") + exportSession.outputURL = outputURL + exportSession.outputFileType = .mp4 + exportSession.shouldOptimizeForNetworkUse = true + exportSession.metadata = [] + exportSession.metadataItemFilter = nil + let completionQueue = DispatchQueue(label: "xyz.block.buzz.voice-note-export") + let completion = VoiceNoteExportCompletion( + outputURL: outputURL, + videoURL: videoURL + ) + completionQueue.asyncAfter(deadline: .now() + exportTimeout) { + completion.timeout( + cancel: exportSession.cancelExport, + deliver: { + result( + FlutterError( + code: "transcode_failed", + message: "Voice note packaging timed out.", + details: nil + ) + ) + } + ) + } + exportSession.exportAsynchronously { + completionQueue.async { + completion.exportDidFinish( + succeeded: exportSession.status == .completed, + deliver: { + switch exportSession.status { + case .completed: + do { + try MP4Canonicalizer.neutralizeSampleDependencyBoxes(at: outputURL) + result(outputURL.path) + } catch { + try? FileManager.default.removeItem(at: outputURL) + result( + FlutterError( + code: "transcode_failed", + message: "Unable to canonicalize voice note.", + details: error.localizedDescription + ) + ) + } + default: + result( + FlutterError( + code: "transcode_failed", + message: "Voice note packaging failed.", + details: exportSession.error?.localizedDescription + ) + ) + } + } + ) + } + } + } +} + +/// Separates one-shot Flutter result delivery from asynchronous export cleanup. +/// +/// `cancelExport()` does not synchronously join AVFoundation's exporter. A late +/// terminal callback must therefore remove an output recreated after timeout, +/// even though the timeout already delivered the Flutter result. +final class VoiceNoteExportCompletion { + private let outputURL: URL + private let videoURL: URL + private let fileManager: FileManager + private var delivered = false + + init( + outputURL: URL, + videoURL: URL, + fileManager: FileManager = .default + ) { + self.outputURL = outputURL + self.videoURL = videoURL + self.fileManager = fileManager + } + + func timeout(cancel: () -> Void, deliver: () -> Void) { + guard !delivered else { return } + delivered = true + cancel() + remove(videoURL) + remove(outputURL) + deliver() + } + + func exportDidFinish(succeeded: Bool, deliver: () -> Void) { + remove(videoURL) + guard !delivered else { + remove(outputURL) + return + } + delivered = true + if !succeeded { remove(outputURL) } + deliver() + } + + private func remove(_ url: URL) { + try? fileManager.removeItem(at: url) + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 14c35dd21ec..272c3a2abad 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -8,6 +8,78 @@ import XCTest class RunnerTests: XCTestCase { + func testVoiceNotePackagingStagesHaveBoundedDeadlines() { + XCTAssertEqual(VoiceNotePackager.videoEnvelopeTimeout, 30) + XCTAssertEqual(VoiceNotePackager.exportTimeout, 30) + } + + func testTimedOutVoiceNoteExportCleansLateOutputWithoutRedelivering() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let outputURL = directory.appendingPathComponent("output.mp4") + let videoURL = directory.appendingPathComponent("envelope.mp4") + try Data([1]).write(to: outputURL) + try Data([2]).write(to: videoURL) + let completion = VoiceNoteExportCompletion( + outputURL: outputURL, + videoURL: videoURL + ) + var cancelCount = 0 + var deliveryCount = 0 + + completion.timeout( + cancel: { cancelCount += 1 }, + deliver: { deliveryCount += 1 } + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: outputURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: videoURL.path)) + + // AVFoundation may recreate the destination while cancellation settles. + try Data([3]).write(to: outputURL) + completion.exportDidFinish( + succeeded: false, + deliver: { deliveryCount += 1 } + ) + + XCTAssertEqual(cancelCount, 1) + XCTAssertEqual(deliveryCount, 1) + XCTAssertFalse(FileManager.default.fileExists(atPath: outputURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: videoURL.path)) + } + + func testSuccessfulVoiceNoteExportPreservesOutputForFlutter() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let outputURL = directory.appendingPathComponent("output.mp4") + let videoURL = directory.appendingPathComponent("envelope.mp4") + try Data([1]).write(to: outputURL) + try Data([2]).write(to: videoURL) + let completion = VoiceNoteExportCompletion( + outputURL: outputURL, + videoURL: videoURL + ) + var deliveryCount = 0 + + completion.exportDidFinish( + succeeded: true, + deliver: { deliveryCount += 1 } + ) + + XCTAssertEqual(deliveryCount, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: videoURL.path)) + } + func testPushAuthorizationStatusNamesCoverDisplayPermissionStates() { XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.notDetermined), "notDetermined") XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.denied), "denied") diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 66b880679f0..d87982bac23 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -21,6 +21,7 @@ import 'features/pairing/pairing_page.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; import 'features/channels/deep_link_dispatcher.dart'; +import 'features/channels/voice_note_recording.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; import 'features/profile/profile_edit_page.dart'; @@ -359,6 +360,7 @@ class App extends HookConsumerWidget { return MaterialApp( navigatorKey: _mobileRootNavigatorKey, + navigatorObservers: [voiceNoteRouteObserver], title: 'Buzz', theme: AppTheme.light( colorScheme: lightScheme, diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index b36c2d3f1c8..726528a23e6 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -18,11 +18,13 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/huddle/huddle_session.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/concentric_sheet_surface.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; @@ -42,6 +44,9 @@ import 'mentions/mention_candidates.dart'; import 'mentions/mention_candidates_provider.dart'; import 'mentions/mention_ranking.dart'; import 'photo_library.dart'; +import 'voice_note_attachment.dart'; +import 'voice_note_composer_recorder.dart'; +import 'voice_note_recording.dart'; part 'compose_bar/helpers.dart'; part 'compose_bar/agent_mention_labels.dart'; @@ -56,6 +61,7 @@ part 'compose_bar/ios_photo_picker.dart'; part 'compose_bar/ios_attachment_popover.dart'; part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; +part 'compose_bar/voice_note.dart'; part 'compose_bar/layout.dart'; part 'compose_bar/dock.dart'; part 'compose_bar/compose_bar_widget.dart'; diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index 84bf5b59032..97bd60579b5 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -1,5 +1,75 @@ part of '../compose_bar.dart'; +bool _rejectsNonVoiceAttachment( + _ComposerVoiceNote voiceNote, + List<_PendingAttachment> attachments, + ValueNotifier uploadError, +) { + if (!voiceNote.isPreparing && + voiceNote.recorder == null && + !attachments.any( + (attachment) => attachment.kind == _PendingAttachmentKind.voiceNote, + )) { + return false; + } + uploadError.value = voiceNote.recorder != null || voiceNote.isPreparing + ? 'Finish or discard the voice note before attaching a file.' + : 'A voice note must be the only attachment.'; + return true; +} + +bool _queueComposerAttachment( + XFile file, + _PendingAttachmentKind kind, + ObjectRef<_ComposerVoiceNote> voiceNote, + ValueNotifier> attachments, + ValueNotifier uploadError, + ObjectRef draftRevision, { + bool deleteAfterUse = false, +}) { + if (kind != _PendingAttachmentKind.voiceNote && + _rejectsNonVoiceAttachment( + voiceNote.value, + attachments.value, + uploadError, + )) { + return false; + } + draftRevision.value += 1; + uploadError.value = null; + attachments.value = [ + ...attachments.value, + _PendingAttachment(file: file, kind: kind, deleteAfterUse: deleteAfterUse), + ]; + return true; +} + +bool _queueComposerImages( + List images, + _ComposerVoiceNote voiceNote, + ValueNotifier> attachments, + ValueNotifier uploadError, + ObjectRef draftRevision, + bool deleteAfterUse, +) { + if (images.isEmpty || + _rejectsNonVoiceAttachment(voiceNote, attachments.value, uploadError)) { + return false; + } + draftRevision.value += 1; + uploadError.value = null; + attachments.value = [ + ...attachments.value, + for (final image in images) + _PendingAttachment( + file: image, + kind: _PendingAttachmentKind.image, + deleteAfterUse: deleteAfterUse, + ), + ]; + return true; +} + enum _AttachmentSurface { closed, menu, camera, photos } const _attachmentMenuWidth = 216.0; @@ -36,8 +106,8 @@ class _AttachmentMenuLayout { textPainter.dispose(); final contentHeight = (_attachmentMenuPadding * 2) + - (itemHeight * 4) + - (_attachmentMenuItemSpacing * 3); + (itemHeight * 5) + + (_attachmentMenuItemSpacing * 4); return _AttachmentMenuLayout( itemHeight: itemHeight, @@ -56,6 +126,7 @@ class _AttachmentSurfacePanel extends HookWidget { final VoidCallback onCamera; final VoidCallback onPhotos; final VoidCallback onVideo; + final VoidCallback onVoiceNote; final VoidCallback onFiles; final Future Function(XFile image) onCapture; final Future> Function() onPickAllPhotos; @@ -70,6 +141,7 @@ class _AttachmentSurfacePanel extends HookWidget { required this.onCamera, required this.onPhotos, required this.onVideo, + required this.onVoiceNote, required this.onFiles, required this.onCapture, required this.onPickAllPhotos, @@ -218,6 +290,7 @@ class _AttachmentSurfacePanel extends HookWidget { onCamera: onCamera, onPhotos: onPhotos, onVideo: onVideo, + onVoiceNote: onVoiceNote, onFiles: onFiles, ), ), @@ -269,7 +342,7 @@ class _ComposeDraftPayload { } } -enum _PendingAttachmentKind { image, video, file } +enum _PendingAttachmentKind { image, video, voiceNote, file } @immutable class _PendingAttachment { @@ -279,11 +352,15 @@ class _PendingAttachment { final XFile file; final _PendingAttachmentKind kind; final bool deleteAfterUse; + final Duration? duration; + final List waveform; _PendingAttachment({ required this.file, required this.kind, this.deleteAfterUse = false, + this.duration, + this.waveform = const [], }) : id = _nextId++; } @@ -329,16 +406,14 @@ void _removePendingAttachment( Future _retainAndQueueImages( BuildContext context, List images, - void Function(List, {bool deleteAfterUse}) queueImages, + bool Function(List, {bool deleteAfterUse}) queueImages, ) async { final retained = await retainTemporaryImages(images); - if (!context.mounted) { + if (!context.mounted || !queueImages(retained, deleteAfterUse: true)) { for (final image in retained) { await _deleteXFile(image); } - return; } - queueImages(retained, deleteAfterUse: true); } Future _uploadPendingAttachment( @@ -357,6 +432,12 @@ Future _uploadPendingAttachment( onProgress: onProgress, cancellationToken: cancellationToken, ), + _PendingAttachmentKind.voiceNote => service.uploadVoiceNote( + attachment.file, + duration: attachment.duration ?? Duration.zero, + onProgress: onProgress, + cancellationToken: cancellationToken, + ), _PendingAttachmentKind.file => service.uploadFile( attachment.file, onProgress: onProgress, @@ -444,6 +525,7 @@ class _AttachmentMenu extends StatelessWidget { final VoidCallback onCamera; final VoidCallback onPhotos; final VoidCallback onVideo; + final VoidCallback onVoiceNote; final VoidCallback onFiles; const _AttachmentMenu({ @@ -451,11 +533,19 @@ class _AttachmentMenu extends StatelessWidget { required this.onCamera, required this.onPhotos, required this.onVideo, + required this.onVoiceNote, required this.onFiles, }); @override Widget build(BuildContext context) { + final items = <(IconData, String, VoidCallback)>[ + (LucideIcons.camera, 'Camera', onCamera), + (LucideIcons.images, 'Photos', onPhotos), + (LucideIcons.video, 'Video', onVideo), + (LucideIcons.mic, 'Voice note', onVoiceNote), + (LucideIcons.file, 'Files', onFiles), + ]; return SizedBox( key: const ValueKey('attachment-menu'), width: _attachmentMenuWidth, @@ -466,16 +556,11 @@ class _AttachmentMenu extends StatelessWidget { physics: layout.isScrollable ? null : const NeverScrollableScrollPhysics(), - itemCount: 4, + itemCount: items.length, separatorBuilder: (_, _) => const SizedBox(height: _attachmentMenuItemSpacing), itemBuilder: (context, index) { - final (icon, label, onTap) = switch (index) { - 0 => (LucideIcons.camera, 'Camera', onCamera), - 1 => (LucideIcons.images, 'Photos', onPhotos), - 2 => (LucideIcons.video, 'Video', onVideo), - _ => (LucideIcons.file, 'Files', onFiles), - }; + final (icon, label, onTap) = items[index]; return _AttachmentMenuItem( height: layout.itemHeight, icon: icon, @@ -572,110 +657,126 @@ class _AttachmentStrip extends StatelessWidget { return SizedBox( height: thumbHeight, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: attachments.length, - separatorBuilder: (_, _) => const SizedBox(width: Grid.half), - itemBuilder: (context, index) { - final attachment = attachments[index]; - return Container( - key: ValueKey('compose-attachment:${attachment.id}'), - width: thumbWidth, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(Radii.md), - border: Border.all(color: context.colors.outlineVariant), - ), - child: Stack( - fit: StackFit.expand, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(Radii.md), - child: attachment.kind == _PendingAttachmentKind.video - ? ColoredBox( - color: Colors.black, - child: Center( - child: Icon( - LucideIcons.video, - color: Colors.white, - size: 24, + child: LayoutBuilder( + builder: (context, constraints) => ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: attachments.length, + separatorBuilder: (_, _) => const SizedBox(width: Grid.half), + itemBuilder: (context, index) { + final attachment = attachments[index]; + if (attachment.kind == _PendingAttachmentKind.voiceNote) { + return SizedBox( + width: constraints.maxWidth, + child: VoiceNoteAttachment.local( + path: attachment.file.path, + duration: attachment.duration ?? Duration.zero, + waveform: attachment.waveform, + onRemove: () => + _runComposerAction(() => onRemove(attachment.id)), + ), + ); + } + return Container( + key: ValueKey('compose-attachment:${attachment.id}'), + width: thumbWidth, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all(color: context.colors.outlineVariant), + ), + child: Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(Radii.md), + child: attachment.kind == _PendingAttachmentKind.video + ? ColoredBox( + color: Colors.black, + child: Center( + child: Icon( + LucideIcons.video, + color: Colors.white, + size: 24, + ), ), - ), - ) - : attachment.kind == _PendingAttachmentKind.image && - attachment.file.path.isNotEmpty - ? Image.file( - File(attachment.file.path), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => ColoredBox( - color: context.colors.surface, - child: Icon( - LucideIcons.image, - color: context.colors.onSurfaceVariant, + ) + : attachment.kind == _PendingAttachmentKind.image && + attachment.file.path.isNotEmpty + ? Image.file( + File(attachment.file.path), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => ColoredBox( + color: context.colors.surface, + child: Icon( + LucideIcons.image, + color: context.colors.onSurfaceVariant, + ), ), - ), - ) - : attachment.kind == _PendingAttachmentKind.image - ? _MemoryAttachmentImage(file: attachment.file) - : ColoredBox( - color: context.colors.surface, - child: Padding( - padding: const EdgeInsets.all(Grid.xxs), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - LucideIcons.file, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(height: Grid.quarter), - Text( - attachment.file.name.isEmpty - ? 'File' - : attachment.file.name, - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( + ) + : attachment.kind == _PendingAttachmentKind.image + ? _MemoryAttachmentImage(file: attachment.file) + : ColoredBox( + color: context.colors.surface, + child: Padding( + padding: const EdgeInsets.all(Grid.xxs), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.file, color: context.colors.onSurfaceVariant, ), - ), - ], + const SizedBox(height: Grid.quarter), + Text( + attachment.file.name.isEmpty + ? 'File' + : attachment.file.name, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + ), + ), + ], + ), ), ), + ), + Positioned( + top: Grid.quarter, + right: Grid.quarter, + child: SizedBox( + width: 24, + height: 24, + child: IconButton( + onPressed: () => + _runComposerAction(() => onRemove(attachment.id)), + tooltip: 'Remove attachment', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: context.colors.surface.withValues( + alpha: 0.92, + ), + minimumSize: const Size(24, 24), + maximumSize: const Size(24, 24), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - ), - Positioned( - top: Grid.quarter, - right: Grid.quarter, - child: SizedBox( - width: 24, - height: 24, - child: IconButton( - onPressed: () => - _runComposerAction(() => onRemove(attachment.id)), - tooltip: 'Remove attachment', - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: context.colors.surface.withValues( - alpha: 0.92, + icon: Icon( + LucideIcons.x, + size: 14, + color: context.colors.onSurface, ), - minimumSize: const Size(24, 24), - maximumSize: const Size(24, 24), - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: Icon( - LucideIcons.x, - size: 14, - color: context.colors.onSurface, ), ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ), ); } diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index b63731662cc..ca34ffd2a1b 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -6,8 +6,7 @@ class ComposeBar extends HookConsumerWidget { final String? hintText; final ComposeBarOnSend onSend; - /// Runs immediately before the editor requests focus, allowing a parent to - /// prepare focus-dependent layout (for example, following a thread tail). + /// Lets a parent prepare its layout before the editor requests focus. final VoidCallback? onFocusRequested; /// Parent-owned if set; otherwise internally created and disposed. @@ -39,14 +38,9 @@ class ComposeBar extends HookConsumerWidget { () => controller.text, ); useEffect(() => controller.dispose, [controller]); - // Draft identity is part of the effect key because an in-place account or - // community switch can leave this composer mounted. Reload that identity's - // draft so old text cannot be persisted into the new identity's store. final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId); final draftRevision = useRef(0); - final draftIdentity = - '${ref.watch(relayConfigProvider).baseUrl}' - ':${ref.watch(myPubkeyProvider) ?? 'anon'}'; + final draftIdentity = _composerDraftIdentity(ref); final isComposerExpanded = useState(false); final androidImeTransitionStarted = useState( defaultTargetPlatform != TargetPlatform.android, @@ -80,6 +74,18 @@ class ComposeBar extends HookConsumerWidget { final uploadProgress = useState(0.0); final uploadGeneration = useRef(0); final activeUploadCancellation = useRef(null); + final voiceNote = _useComposerVoiceNote( + context: context, + ref: ref, + focusNode: focusNode, + isComposerExpanded: isComposerExpanded, + showFormatting: showFormatting, + attachmentSurface: attachmentSurface, + uploadError: uploadError, + draftRevision: draftRevision, + attachments: attachments, + ); + final voiceNoteRef = useRef(voiceNote)..value = voiceNote; _useComposeDraftLifecycle( ref: ref, controller: controller, @@ -96,6 +102,7 @@ class ComposeBar extends HookConsumerWidget { attachmentSurface: attachmentSurface, uploadError: uploadError, iosAttachmentPopover: iosAttachmentPopover, + onDraftIdentityChanged: voiceNote.onDraftIdentityChanged, ); final clipboardHasImage = useState(false); final hasAttachments = attachments.value.isNotEmpty; @@ -136,13 +143,14 @@ class ComposeBar extends HookConsumerWidget { if (defaultTargetPlatform == TargetPlatform.android) { androidImeTransitionStarted.value = false; } + voiceNote.onKeyboardHidden(); collapseComposer(); focusNode.unfocus(); }, ); WidgetsBinding.instance.addObserver(observer); return () => WidgetsBinding.instance.removeObserver(observer); - }, [appView, focusNode]); + }, [appView, focusNode, voiceNote.isPreparing]); final resolvedHint = hintText ?? (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); @@ -606,23 +614,22 @@ class ComposeBar extends HookConsumerWidget { } } - final queueAttachment = useCallback(( - XFile file, - _PendingAttachmentKind kind, { - bool deleteAfterUse = false, - }) { - draftRevision.value += 1; - uploadError.value = null; - attachments.value = [ - ...attachments.value, - _PendingAttachment( - file: file, - kind: kind, - deleteAfterUse: deleteAfterUse, - ), - ]; - }, [draftRevision, uploadError, attachments]); - + final queueAttachment = useCallback( + ( + XFile file, + _PendingAttachmentKind kind, { + bool deleteAfterUse = false, + }) => _queueComposerAttachment( + file, + kind, + voiceNoteRef, + attachments, + uploadError, + draftRevision, + deleteAfterUse: deleteAfterUse, + ), + [voiceNoteRef, draftRevision, uploadError, attachments], + ); Future pickThenQueue({ required Future Function() pick, required _PendingAttachmentKind kind, @@ -639,20 +646,15 @@ class ComposeBar extends HookConsumerWidget { } } - void queueImages(List images, {bool deleteAfterUse = false}) { - if (images.isEmpty) return; - draftRevision.value += 1; - uploadError.value = null; - attachments.value = [ - ...attachments.value, - for (final image in images) - _PendingAttachment( - file: image, - kind: _PendingAttachmentKind.image, - deleteAfterUse: deleteAfterUse, - ), - ]; - } + bool queueImages(List images, {bool deleteAfterUse = false}) => + _queueComposerImages( + images, + voiceNote, + attachments, + uploadError, + draftRevision, + deleteAfterUse, + ); Future retainAndQueueImages(List images) => _retainAndQueueImages(context, images, queueImages); @@ -756,23 +758,18 @@ class ComposeBar extends HookConsumerWidget { focusNode.requestFocus(); } - // ----- Widget tree ---------------------------------------------------- - void chooseAttachment( Future Function() choose, { String? errorMessage, - }) { - attachmentSurface.value = _AttachmentSurface.closed; - unawaited(() async { - try { - await choose(); - } catch (error) { - if (context.mounted) { - uploadError.value = errorMessage ?? _formatUploadError(error); - } - } - }()); - } + }) => _rejectsNonVoiceAttachment(voiceNote, attachments.value, uploadError) + ? attachmentSurface.value = _AttachmentSurface.closed + : _chooseComposerAttachment( + context, + attachmentSurface, + uploadError, + choose, + errorMessage: errorMessage, + ); void toggleAttachments() { attachmentSurface.value = switch (attachmentSurface.value) { @@ -809,6 +806,7 @@ class ComposeBar extends HookConsumerWidget { kind: _PendingAttachmentKind.video, ); }), + onVoiceNote: voiceNote.start, onFiles: () => chooseAttachment(() { final service = ref.read(mediaUploadServiceProvider); return pickThenQueue( @@ -893,6 +891,7 @@ class ComposeBar extends HookConsumerWidget { kind: _PendingAttachmentKind.video, ); }), + onVoiceNote: voiceNote.start, onFiles: () => chooseAttachment(() { final service = ref.read(mediaUploadServiceProvider); return pickThenQueue( @@ -920,6 +919,7 @@ class ComposeBar extends HookConsumerWidget { final hasPendingUploads = uploadingCount.value > 0; return _ComposerDockFrame( expansionAnimation: composerExpansionController, + forceFullWidth: _voiceNoteFullWidth(voiceNote, attachments.value), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -943,6 +943,7 @@ class ComposeBar extends HookConsumerWidget { attachmentSurface.value = _AttachmentSurface.closed; }, child: _ComposeBarLayout( + voiceNoteRecorder: voiceNote.recorder, attachments: attachments.value, onRemoveAttachment: removeAttachment, uploadError: uploadError.value, diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart index 7a25f4cfe55..382a2b10b85 100644 --- a/mobile/lib/features/channels/compose_bar/dock.dart +++ b/mobile/lib/features/channels/compose_bar/dock.dart @@ -1,16 +1,48 @@ part of '../compose_bar.dart'; -class _ComposerDockFrame extends StatelessWidget { +class _ComposerDockFrame extends HookWidget { final Animation expansionAnimation; + final bool forceFullWidth; final Widget child; const _ComposerDockFrame({ required this.expansionAnimation, + required this.forceFullWidth, required this.child, }); @override Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final recordingTransition = useAnimationController( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 200), + reverseDuration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 140), + initialValue: forceFullWidth ? 1 : 0, + ); + useEffect(() { + if (forceFullWidth) { + recordingTransition.value = math.max( + recordingTransition.value, + expansionAnimation.value.clamp(0.0, 1.0), + ); + recordingTransition.forward(); + } else { + recordingTransition.reverse(); + } + return null; + }, [forceFullWidth, reducedMotion]); + const compactVerticalOffset = Grid.twelve + Grid.quarter; + final visibleBottomGutter = math.max( + Grid.twelve, + MediaQuery.viewPaddingOf(context).bottom + + Grid.xxs - + compactVerticalOffset, + ); + final recordingGutterDelta = visibleBottomGutter - Grid.twelve; final backdropHeight = mobileTabFooterBackdropHeight(context); return Stack( clipBehavior: Clip.none, @@ -34,16 +66,40 @@ class _ComposerDockFrame extends StatelessWidget { child: Align( alignment: Alignment.bottomCenter, child: AnimatedBuilder( - animation: expansionAnimation, + animation: Listenable.merge([ + expansionAnimation, + recordingTransition, + ]), child: child, builder: (context, child) { - final progress = expansionAnimation.value - .clamp(0.0, 1.0) - .toDouble(); - return FractionallySizedBox( - key: const ValueKey('composer-width-transition'), - widthFactor: 0.85 + 0.15 * progress, - child: child, + final widthProgress = math.max( + expansionAnimation.value.clamp(0.0, 1.0), + recordingTransition.value, + ); + final expansionProgress = expansionAnimation.value.clamp( + 0.0, + 1.0, + ); + final compactPositionProgress = 1 - expansionProgress; + return Transform.translate( + key: const ValueKey('composer-position-transition'), + offset: Offset( + 0, + compactVerticalOffset * compactPositionProgress, + ), + child: Padding( + key: const ValueKey('composer-recording-outer-gutter'), + padding: EdgeInsets.symmetric( + horizontal: forceFullWidth + ? recordingGutterDelta * compactPositionProgress + : 0, + ), + child: FractionallySizedBox( + key: const ValueKey('composer-width-transition'), + widthFactor: 0.85 + 0.15 * widthProgress, + child: child, + ), + ), ); }, ), diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index 288fad4bf1d..b712091b716 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -78,6 +78,7 @@ void _useComposeDraftLifecycle({ required ValueNotifier<_AttachmentSurface> attachmentSurface, required ValueNotifier uploadError, required _IOSAttachmentPopoverController iosAttachmentPopover, + required VoidCallback onDraftIdentityChanged, }) { final lastDraftIdentity = useRef(null); useEffect(() { @@ -88,6 +89,7 @@ void _useComposeDraftLifecycle({ final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey); if (identityChanged) { draftRevision.value += 1; + onDraftIdentityChanged(); uploadGeneration.value += 1; activeUploadCancellation.value?.cancel(); activeUploadCancellation.value = null; @@ -122,5 +124,5 @@ void _useComposeDraftLifecycle({ controller.addListener(persistDraft); return () => controller.removeListener(persistDraft); - }, [controller, draftKey, draftIdentity]); + }, [controller, draftKey, draftIdentity, onDraftIdentityChanged]); } diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 78d31e58198..fa20df6d168 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -1,5 +1,9 @@ part of '../compose_bar.dart'; +String _composerDraftIdentity(WidgetRef ref) => + '${ref.watch(relayConfigProvider).baseUrl}' + ':${ref.watch(myPubkeyProvider) ?? 'anon'}'; + void _useComposerFocusRestorer({ required ValueChanged? onChanged, required ValueNotifier isExpanded, @@ -86,6 +90,25 @@ void _dismissComposerKeyboard(FocusNode focusNode) { unawaited(SystemChannels.textInput.invokeMethod('TextInput.hide')); } +void _chooseComposerAttachment( + BuildContext context, + ValueNotifier<_AttachmentSurface> attachmentSurface, + ValueNotifier uploadError, + Future Function() choose, { + String? errorMessage, +}) { + attachmentSurface.value = _AttachmentSurface.closed; + unawaited(() async { + try { + await choose(); + } catch (error) { + if (context.mounted) { + uploadError.value = errorMessage ?? _formatUploadError(error); + } + } + }()); +} + Duration _composerMotionDuration( bool reducedMotion, _AttachmentSurface surface, @@ -168,6 +191,7 @@ Widget _composerAttachmentPanel({ required VoidCallback onCamera, required VoidCallback onPhotos, required VoidCallback onVideo, + required VoidCallback onVoiceNote, required VoidCallback onFiles, required Future Function(XFile image) onCapture, required Future> Function() onPickAllPhotos, @@ -185,6 +209,7 @@ Widget _composerAttachmentPanel({ onCamera: onCamera, onPhotos: onPhotos, onVideo: onVideo, + onVoiceNote: onVoiceNote, onFiles: onFiles, onCapture: onCapture, onPickAllPhotos: onPickAllPhotos, diff --git a/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart b/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart index 0a0c4f1a99a..bd4ba9e5de0 100644 --- a/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart +++ b/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart @@ -13,6 +13,7 @@ class _IOSAttachmentPopoverCallbacks { final Future Function(List photos) onChoosePhotos; final VoidCallback onAllPhotos; final VoidCallback onVideo; + final VoidCallback onVoiceNote; final VoidCallback onFiles; const _IOSAttachmentPopoverCallbacks({ @@ -20,6 +21,7 @@ class _IOSAttachmentPopoverCallbacks { required this.onChoosePhotos, required this.onAllPhotos, required this.onVideo, + required this.onVoiceNote, required this.onFiles, }); } @@ -44,6 +46,7 @@ class _IOSAttachmentPopoverCoordinator { required Future Function(List photos) onChoosePhotos, required VoidCallback onAllPhotos, required VoidCallback onVideo, + required VoidCallback onVoiceNote, required VoidCallback onFiles, }) async { if (defaultTargetPlatform != TargetPlatform.iOS) return false; @@ -61,6 +64,7 @@ class _IOSAttachmentPopoverCoordinator { onChoosePhotos: onChoosePhotos, onAllPhotos: onAllPhotos, onVideo: onVideo, + onVoiceNote: onVoiceNote, onFiles: onFiles, ); _ensureHandler(); @@ -155,6 +159,8 @@ class _IOSAttachmentPopoverCoordinator { callbacks?.onAllPhotos(); case 'pickVideo': callbacks?.onVideo(); + case 'recordVoiceNote': + callbacks?.onVoiceNote(); case 'pickFiles': callbacks?.onFiles(); case 'dismissed': @@ -184,6 +190,7 @@ class _IOSAttachmentPopoverController { required Future Function(List photos) onChoosePhotos, required VoidCallback onAllPhotos, required VoidCallback onVideo, + required VoidCallback onVoiceNote, required VoidCallback onFiles, }) => _iosAttachmentPopoverCoordinator.present( owner: this, @@ -192,6 +199,7 @@ class _IOSAttachmentPopoverController { onChoosePhotos: onChoosePhotos, onAllPhotos: onAllPhotos, onVideo: onVideo, + onVoiceNote: onVoiceNote, onFiles: onFiles, ); diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 3400edd546d..aff90b99364 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -1,6 +1,7 @@ part of '../compose_bar.dart'; -class _ComposeBarLayout extends StatelessWidget { +class _ComposeBarLayout extends HookWidget { + final Widget? voiceNoteRecorder; final List<_PendingAttachment> attachments; final ValueChanged onRemoveAttachment; final String? uploadError; @@ -29,6 +30,7 @@ class _ComposeBarLayout extends StatelessWidget { final bool isSending; const _ComposeBarLayout({ + required this.voiceNoteRecorder, required this.attachments, required this.onRemoveAttachment, required this.uploadError, @@ -59,15 +61,43 @@ class _ComposeBarLayout extends StatelessWidget { @override Widget build(BuildContext context) { - return _DragDownToDismissKeyboard(child: _buildBar(context)); + final recordingTransition = useAnimationController( + duration: motionDuration, + reverseDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 140), + initialValue: voiceNoteRecorder == null ? 0 : 1, + ); + useEffect(() { + if (voiceNoteRecorder == null) { + recordingTransition.reverse(); + } else { + recordingTransition.forward(); + } + return null; + }, [voiceNoteRecorder != null, motionDuration]); + return _DragDownToDismissKeyboard( + child: _buildBar(context, recordingTransition), + ); } - Widget _buildBar(BuildContext context) { + Widget _buildBar( + BuildContext context, + Animation recordingTransition, + ) { final trimmedDraft = controller.text.trim(); final collapsedText = trimmedDraft.isEmpty ? resolvedHint : trimmedDraft.replaceAll(RegExp(r'\s+'), ' '); - final content = Column( + final hasVoiceNoteAttachment = attachments.any( + (attachment) => attachment.kind == _PendingAttachmentKind.voiceNote, + ); + final composerContent = Column( + key: ValueKey( + hasVoiceNoteAttachment + ? 'composer-voice-note-preview-content' + : 'composer-standard-content', + ), mainAxisSize: MainAxisSize.min, children: [ if (attachments.isNotEmpty) ...[ @@ -214,25 +244,80 @@ class _ComposeBarLayout extends StatelessWidget { ), ], ); + final contentMotionDuration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 140); + final content = ClipRect( + child: AnimatedSwitcher( + key: const ValueKey('composer-content-morph'), + duration: contentMotionDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.bottomCenter, + children: [...previousChildren, ?currentChild], + ), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: 1, + child: child, + ), + ), + child: voiceNoteRecorder == null + ? composerContent + : KeyedSubtree( + key: const ValueKey('composer-voice-note-content'), + child: voiceNoteRecorder!, + ), + ), + ); return AnimatedBuilder( - animation: expansionAnimation, + animation: Listenable.merge([expansionAnimation, recordingTransition]), child: content, builder: (context, child) { final progress = expansionAnimation.value.clamp(0.0, 1.0).toDouble(); final composerRadius = Radii.dialog + Grid.quarter * (1 - progress); - return Container( + final radius = BorderRadius.lerp( + BorderRadius.circular(composerRadius), + BorderRadius.circular(Radii.full), + Curves.easeInOutCubic.transform(recordingTransition.value), + )!; + final usesIosConcentricSurface = + defaultTargetPlatform == TargetPlatform.iOS; + final voiceNoteInsetProgress = hasVoiceNoteAttachment + ? 1.0 + : recordingTransition.value; + final composer = Container( key: const ValueKey('composer-surface'), decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(composerRadius), + color: usesIosConcentricSurface + ? Colors.transparent + : context.colors.surfaceContainerHighest, + borderRadius: radius, border: Border.all( color: Colors.black.withValues(alpha: 0.04), width: 1, ), ), - padding: const EdgeInsets.all(Grid.xxs), + padding: EdgeInsets.all( + Grid.xxs + Grid.half * voiceNoteInsetProgress, + ), child: child, ); + if (!usesIosConcentricSurface) return composer; + return ConcentricSheetSurface( + key: const ValueKey('composer-ios-concentric-surface'), + enabled: true, + usesGlass: true, + color: context.colors.surfaceContainerHighest, + padding: EdgeInsets.zero, + providesSheetSurface: false, + minimumRadius: radius.topLeft.x, + contentClipRadius: radius.topLeft.x, + child: composer, + ); }, ); } diff --git a/mobile/lib/features/channels/compose_bar/voice_note.dart b/mobile/lib/features/channels/compose_bar/voice_note.dart new file mode 100644 index 00000000000..0d6c058d71f --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/voice_note.dart @@ -0,0 +1,108 @@ +part of '../compose_bar.dart'; + +class _ComposerVoiceNote { + const _ComposerVoiceNote({ + required this.start, + required this.onKeyboardHidden, + required this.onDraftIdentityChanged, + required ValueNotifier isPreparing, + required ValueNotifier isRecording, + required ValueChanged onRecorded, + }) : _isPreparing = isPreparing, + _isRecording = isRecording, + _onRecorded = onRecorded; + + final VoidCallback start; + final VoidCallback onKeyboardHidden; + final VoidCallback onDraftIdentityChanged; + final ValueNotifier _isPreparing; + final ValueNotifier _isRecording; + final ValueChanged _onRecorded; + + bool get isPreparing => _isPreparing.value; + bool get isRecording => _isRecording.value; + Widget? get recorder => isRecording + ? VoiceNoteComposerRecorder( + onCancel: () => _isRecording.value = false, + onRecorded: _onRecorded, + ) + : null; +} + +bool _voiceNoteFullWidth( + _ComposerVoiceNote voiceNote, + List<_PendingAttachment> attachments, +) => + voiceNote.isPreparing || + voiceNote.isRecording || + attachments.any((item) => item.kind == _PendingAttachmentKind.voiceNote); + +_ComposerVoiceNote _useComposerVoiceNote({ + required BuildContext context, + required WidgetRef ref, + required FocusNode focusNode, + required ValueNotifier isComposerExpanded, + required ValueNotifier showFormatting, + required ValueNotifier<_AttachmentSurface> attachmentSurface, + required ValueNotifier uploadError, + required ObjectRef draftRevision, + required ValueNotifier> attachments, +}) { + final isPreparing = useState(false); + final isRecording = useState(false); + + final resetForDraftIdentityChange = useCallback(() { + isPreparing.value = false; + isRecording.value = false; + }, [isPreparing, isRecording]); + + void beginRecording() { + if (!isPreparing.value) return; + isPreparing.value = false; + isRecording.value = true; + } + + void start() { + if (attachments.value.isNotEmpty) { + uploadError.value = 'A voice note must be the only attachment.'; + return; + } + attachmentSurface.value = _AttachmentSurface.closed; + showFormatting.value = false; + isComposerExpanded.value = false; + _dismissComposerKeyboard(focusNode); + if (ref.read(huddleSessionProvider).isInSession) { + uploadError.value = 'Leave the Huddle before recording a voice note.'; + return; + } + uploadError.value = null; + draftRevision.value += 1; + isPreparing.value = true; + if (View.of(context).viewInsets.bottom == 0) beginRecording(); + } + + void complete(VoiceNoteRecording recording) { + draftRevision.value += 1; + uploadError.value = null; + attachments.value = [ + ...attachments.value, + _PendingAttachment( + file: recording.file, + kind: _PendingAttachmentKind.voiceNote, + deleteAfterUse: true, + duration: recording.duration, + waveform: recording.waveform, + ), + ]; + isRecording.value = false; + } + + return _ComposerVoiceNote( + start: start, + onKeyboardHidden: beginRecording, + onDraftIdentityChanged: resetForDraftIdentityChange, + isPreparing: isPreparing, + isRecording: isRecording, + onRecorded: complete, + ); +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 0fb3022409b..c9aa72599fb 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -29,6 +29,7 @@ import 'channels_provider.dart'; import 'media_viewer_page.dart'; import 'message_content/link_normalizer.dart'; import 'message_media.dart'; +import 'voice_note_attachment.dart'; part 'message_content/media_carousel.dart'; part 'message_content/token_pill.dart'; @@ -272,6 +273,7 @@ class MessageContent extends HookConsumerWidget { ref, linkText, url, + imetaByUrl[url], linkStyle, style, resolvedChannelTap, @@ -323,6 +325,17 @@ class MessageContent extends HookConsumerWidget { Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) { final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta); + if (mediaKind == MessageMediaKind.audio) { + return Padding( + padding: const EdgeInsets.only(top: Grid.half), + child: VoiceNoteAttachment.remote( + url: imageUrl, + duration: Duration( + milliseconds: ((imeta?.duration ?? 0) * 1000).round(), + ), + ), + ); + } if (mediaKind == MessageMediaKind.video) { return _MessageVideoPreview( url: imageUrl, @@ -344,6 +357,7 @@ class MessageContent extends HookConsumerWidget { WidgetRef ref, InlineSpan linkText, String url, + ImetaEntry? imeta, TextStyle linkStyle, TextStyle? fallbackStyle, void Function(String channelId) resolvedChannelTap, @@ -358,6 +372,10 @@ class MessageContent extends HookConsumerWidget { }); final baseStyle = fallbackStyle ?? linkStyle; + if (imeta != null && + classifyMediaUrl(url, imeta: imeta) == MessageMediaKind.audio) { + return _buildMedia(context, url, imeta); + } final uri = Uri.tryParse(url); final buzzLink = uri?.scheme == 'buzz' ? parseBuzzDeepLink(uri!) ?? parseEntityDeepLink(uri) diff --git a/mobile/lib/features/channels/message_content/media_carousel.dart b/mobile/lib/features/channels/message_content/media_carousel.dart index 545adba36ca..4c360989ef4 100644 --- a/mobile/lib/features/channels/message_content/media_carousel.dart +++ b/mobile/lib/features/channels/message_content/media_carousel.dart @@ -73,7 +73,8 @@ _TrailingImageGallery? _extractTrailingImageGallery( if (match == null) break; final url = match.group(2)!; final imeta = imetaByUrl[url]; - if (classifyMediaUrl(url, imeta: imeta) == MessageMediaKind.video) { + final mediaKind = classifyMediaUrl(url, imeta: imeta); + if (mediaKind != MessageMediaKind.image) { break; } final markdownLabel = match.group(1)?.trim(); diff --git a/mobile/lib/features/channels/message_media.dart b/mobile/lib/features/channels/message_media.dart index 40d0e317734..2297e4d10eb 100644 --- a/mobile/lib/features/channels/message_media.dart +++ b/mobile/lib/features/channels/message_media.dart @@ -1,7 +1,9 @@ import 'package:flutter/foundation.dart'; -enum MessageMediaKind { image, video } +/// Media presentation selected for a message attachment URL. +enum MessageMediaKind { image, video, audio } +/// Parsed metadata from a NIP-92 `imeta` tag. @immutable class ImetaEntry { final String url; @@ -10,6 +12,9 @@ class ImetaEntry { final String? thumb; final String? image; final String? alt; + final double? duration; + final String? filename; + final int? size; const ImetaEntry({ required this.url, @@ -18,10 +23,15 @@ class ImetaEntry { this.thumb, this.image, this.alt, + this.duration, + this.filename, + this.size, }); bool get isVideo => mimeType?.startsWith('video/') == true; + bool get isAudio => mimeType?.startsWith('audio/') == true; + String? get posterUrl => image ?? thumb; double? get aspectRatio { @@ -36,6 +46,7 @@ class ImetaEntry { } } +/// Parses NIP-92 `imeta` tags into entries keyed by their attachment URL. Map parseImetaTags(List> tags) { final byUrl = {}; for (final tag in tags) { @@ -47,6 +58,9 @@ Map parseImetaTags(List> tags) { String? thumb; String? image; String? alt; + double? duration; + String? filename; + int? size; for (final part in tag.skip(1)) { final separator = part.indexOf(' '); @@ -66,6 +80,18 @@ Map parseImetaTags(List> tags) { image = value; case 'alt': alt = value; + case 'duration': + final parsedDuration = double.tryParse(value); + duration = + parsedDuration != null && + parsedDuration.isFinite && + parsedDuration >= 0 + ? parsedDuration + : null; + case 'filename': + filename = value; + case 'size': + size = int.tryParse(value); } } @@ -77,19 +103,31 @@ Map parseImetaTags(List> tags) { thumb: thumb, image: image, alt: alt, + duration: duration, + filename: filename, + size: size, ); } return byUrl; } +/// Classifies [url] using authoritative [imeta] before extension fallback. MessageMediaKind? classifyMediaUrl(String url, {ImetaEntry? imeta}) { final mimeType = imeta?.mimeType; if (mimeType != null) { + final filename = imeta?.filename?.toLowerCase(); + if (mimeType == 'video/mp4' && + filename != null && + filename.startsWith('voice-note-') && + filename.endsWith('.mp4')) { + return MessageMediaKind.audio; + } // An imeta MIME type is authoritative. The native video player chooses // whether the device can decode the specific codec/container; rejecting // every non-MP4 video here prevents it from even trying. if (mimeType.startsWith('video/')) return MessageMediaKind.video; if (mimeType.startsWith('image/')) return MessageMediaKind.image; + if (mimeType.startsWith('audio/')) return MessageMediaKind.audio; } final path = (Uri.tryParse(url)?.path ?? url).toLowerCase(); @@ -99,6 +137,9 @@ MessageMediaKind? classifyMediaUrl(String url, {ImetaEntry? imeta}) { if (_imageExtensions.any(path.endsWith)) { return MessageMediaKind.image; } + if (path.endsWith('.m4a') || path.endsWith('.aac')) { + return MessageMediaKind.audio; + } return null; } diff --git a/mobile/lib/features/channels/voice_note_attachment.dart b/mobile/lib/features/channels/voice_note_attachment.dart new file mode 100644 index 00000000000..9b1950d922e --- /dev/null +++ b/mobile/lib/features/channels/voice_note_attachment.dart @@ -0,0 +1,343 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import 'voice_note_play_pause_icon.dart'; +import 'voice_note_recording.dart'; +import 'voice_note_waveform.dart'; + +/// Displays a recorded or remote voice note with playback controls. +class VoiceNoteAttachment extends HookConsumerWidget { + const VoiceNoteAttachment.local({ + super.key, + required String path, + required this.duration, + required this.waveform, + this.onRemove, + }) : source = path, + isRemote = false; + + const VoiceNoteAttachment.remote({ + super.key, + required String url, + required this.duration, + this.waveform = const [], + }) : source = url, + isRemote = true, + onRemove = null; + + final String source; + final bool isRemote; + final Duration duration; + final List waveform; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = useMemoized(ref.read(voiceNotePlayerFactoryProvider), [ + source, + ]); + final playback = useListenable(player); + final playbackRate = useState(1.0); + useEffect(() { + if (isRemote) { + unawaited( + player.loadRemote( + source, + headers: () => + ref.read(mediaGetAuthServiceProvider).headersFor(source), + fallbackDuration: duration, + ), + ); + } else { + unawaited(player.loadLocal(source, fallbackDuration: duration)); + } + return player.dispose; + }, [player, source, isRemote, duration]); + + final state = playback.state; + final resolvedDuration = state.duration > Duration.zero + ? state.duration + : duration; + final progress = resolvedDuration.inMilliseconds <= 0 + ? 0.0 + : (state.position.inMilliseconds / resolvedDuration.inMilliseconds) + .clamp(0.0, 1.0); + final progressAnimation = useAnimationController(initialValue: progress); + + void animateProgressFrom(double fraction) { + final resolved = fraction.clamp(0.0, 1.0); + progressAnimation + ..stop() + ..value = resolved; + if (!state.isPlaying || resolvedDuration.inMilliseconds <= 0) return; + final remainingMilliseconds = math.max( + 1, + (resolvedDuration.inMilliseconds * (1 - resolved) / playbackRate.value) + .round(), + ); + unawaited( + progressAnimation.animateTo( + 1, + duration: Duration(milliseconds: remainingMilliseconds), + curve: Curves.linear, + ), + ); + } + + useEffect( + () { + animateProgressFrom( + state.isPlaying ? progressAnimation.value : progress, + ); + return null; + }, + [ + state.isPlaying, + state.isPlaying ? null : state.position.inMilliseconds, + resolvedDuration.inMilliseconds, + playbackRate.value, + ], + ); + final samples = normalizeVoiceNoteWaveform( + waveform.isEmpty ? _seededWaveform(source) : waveform, + ); + final isComposer = !isRemote; + final radius = isComposer + ? Radii.dialog + Grid.quarter - Grid.twelve + : Radii.md; + + final canCancelLoading = state.isLoading && state.canCancelLoading; + final onPlaybackPressed = state.isLoading && !canCancelLoading + ? null + : state.hasError && !isRemote + ? null + : () { + unawaited(HapticFeedback.selectionClick()); + unawaited(player.toggle()); + }; + final playbackControlLabel = state.isLoading + ? state.isPlaying + ? 'Pause voice note' + : canCancelLoading + ? 'Cancel voice note loading' + : 'Loading voice note' + : state.hasError && isRemote + ? 'Retry voice note' + : state.isPlaying + ? 'Pause voice note' + : 'Play voice note'; + + return Container( + key: ValueKey('voice-note-attachment:$source'), + constraints: BoxConstraints( + minWidth: 220, + maxWidth: isComposer ? double.infinity : 320, + minHeight: 64, + ), + padding: const EdgeInsets.all(Grid.twelve), + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: context.colors.outlineVariant), + ), + child: Row( + children: [ + SizedBox.square( + dimension: 40, + child: Semantics( + container: true, + button: true, + label: playbackControlLabel, + onTap: onPlaybackPressed, + excludeSemantics: true, + child: ExcludeSemantics( + child: IconButton.filledTonal( + key: const ValueKey('voice-note-play-pause'), + tooltip: playbackControlLabel, + onPressed: onPlaybackPressed, + style: IconButton.styleFrom( + minimumSize: const Size.square(40), + maximumSize: const Size.square(40), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: state.isLoading + ? ExcludeSemantics( + child: BuzzLoadingIndicator( + size: 18, + color: context.colors.onSecondaryContainer, + ), + ) + : state.hasError && isRemote + ? Icon( + LucideIcons.refreshCcw, + key: const ValueKey('voice-note-retry-icon'), + size: 18, + color: context.colors.onSecondaryContainer, + ) + : VoiceNotePlayPauseIcon( + isPlaying: state.isPlaying, + color: context.colors.onSecondaryContainer, + ), + ), + ), + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnimatedBuilder( + animation: progressAnimation, + builder: (context, _) => VoiceNoteWaveform( + samples: samples, + progress: progressAnimation.value, + height: 24, + onSeek: (fraction) { + animateProgressFrom(fraction); + unawaited( + player.seek( + Duration( + milliseconds: + (resolvedDuration.inMilliseconds * fraction) + .round(), + ), + ), + ); + }, + ), + ), + Text( + key: const ValueKey('voice-note-duration'), + state.hasError + ? 'Voice note unavailable' + : formatVoiceNoteDuration( + state.position > Duration.zero + ? state.position + : resolvedDuration, + ), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + if (isRemote) ...[ + const SizedBox(width: Grid.xxs), + _VoiceNotePlaybackRateButton( + key: const ValueKey('voice-note-playback-rate'), + rate: playbackRate.value, + onPressed: () { + unawaited(HapticFeedback.selectionClick()); + final next = nextVoiceNotePlaybackRate(playbackRate.value); + playbackRate.value = next; + unawaited(player.setSpeed(next)); + }, + ), + ] else if (onRemove != null) ...[ + const SizedBox(width: Grid.xxs), + SizedBox.square( + dimension: 40, + child: IconButton( + key: const ValueKey('composer-voice-note-remove'), + tooltip: 'Remove voice note', + onPressed: onRemove, + style: IconButton.styleFrom( + minimumSize: const Size.square(40), + maximumSize: const Size.square(40), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon(LucideIcons.x, size: 18), + ), + ), + ], + ], + ), + ); + } +} + +class _VoiceNotePlaybackRateButton extends StatelessWidget { + const _VoiceNotePlaybackRateButton({ + super.key, + required this.rate, + required this.onPressed, + }); + + final double rate; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: 'Playback speed ${formatVoiceNotePlaybackRate(rate)}', + hint: + 'Double tap to change to ${formatVoiceNotePlaybackRate(nextVoiceNotePlaybackRate(rate))}.', + child: Tooltip( + message: 'Playback speed', + child: Material( + color: context.colors.primary, + borderRadius: BorderRadius.circular(Radii.full), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(Radii.full), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.half + Grid.quarter, + ), + child: Stack( + alignment: Alignment.center, + children: [ + ExcludeSemantics( + child: Opacity( + opacity: 0, + child: Text('1.5×', style: _rateStyle(context)), + ), + ), + Positioned.fill( + child: Center( + child: Text( + formatVoiceNotePlaybackRate(rate), + key: const ValueKey('voice-note-playback-rate-value'), + textAlign: TextAlign.center, + style: _rateStyle(context), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + TextStyle? _rateStyle(BuildContext context) => + context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimary, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ); +} + +List _seededWaveform(String seed) { + var value = seed.hashCode & 0x7fffffff; + return List.generate(48, (_) { + value = (1103515245 * value + 12345) & 0x7fffffff; + return 0.12 + ((value % 760) / 1000); + }); +} diff --git a/mobile/lib/features/channels/voice_note_composer_recorder.dart b/mobile/lib/features/channels/voice_note_composer_recorder.dart new file mode 100644 index 00000000000..01a834c6ff1 --- /dev/null +++ b/mobile/lib/features/channels/voice_note_composer_recorder.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/relay/app_lifecycle_provider.dart'; +import '../../shared/theme/theme.dart'; +import 'voice_note_recording.dart'; +import 'voice_note_waveform.dart'; + +class _VoiceNoteRouteAware extends RouteAware { + _VoiceNoteRouteAware(this.onCovered); + + final VoidCallback onCovered; + + @override + void didPushNext() => onCovered(); +} + +/// Composer control that records, previews levels, and finalizes a voice note. +class VoiceNoteComposerRecorder extends HookConsumerWidget { + const VoiceNoteComposerRecorder({ + super.key, + required this.onCancel, + required this.onRecorded, + }); + + final VoidCallback onCancel; + final ValueChanged onRecorded; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final recorder = useMemoized(ref.read(voiceNoteRecorderFactoryProvider)); + final samples = useState>(const []); + final sampleSequence = useState(0); + final elapsed = useState(Duration.zero); + final error = useState(null); + final isStarted = useState(false); + final isStopping = useState(false); + final startedAt = useRef(null); + final routeAware = useMemoized( + () => _VoiceNoteRouteAware(() { + if (context.mounted) onCancel(); + }), + [onCancel], + ); + + useEffect(() { + final subscription = ref.listenManual(appLifecycleProvider, ( + previous, + next, + ) { + if (next != AppLifecycleState.paused && + next != AppLifecycleState.detached) { + return; + } + unawaited(recorder.cancel()); + if (context.mounted) onCancel(); + }); + return subscription.close; + }, [recorder, onCancel]); + + final route = ModalRoute.of(context); + useEffect(() { + if (route != null) voiceNoteRouteObserver.subscribe(routeAware, route); + return () => voiceNoteRouteObserver.unsubscribe(routeAware); + }, [routeAware, route]); + + Future finish() async { + if (!isStarted.value || isStopping.value || error.value != null) return; + isStopping.value = true; + unawaited(HapticFeedback.mediumImpact()); + try { + final recording = await recorder.stop(); + if (context.mounted) { + onRecorded(recording); + } else { + await deleteDroppedVoiceNoteRecording(recording.file.path); + } + } catch (_) { + if (context.mounted) { + error.value = 'Buzz could not finish the voice note.'; + isStopping.value = false; + } + } + } + + useEffect(() { + var active = true; + final levelSubscription = recorder.levels.listen((level) { + if (!active) return; + final nextSamples = [...samples.value, level]; + samples.value = nextSamples.length <= 120 + ? nextSamples + : nextSamples.sublist(nextSamples.length - 120); + sampleSequence.value += 1; + }); + final timer = Timer.periodic(const Duration(milliseconds: 200), (_) { + final started = startedAt.value; + if (!active || started == null) return; + elapsed.value = DateTime.now().difference(started); + if (elapsed.value >= voiceNoteMaxDuration) unawaited(finish()); + }); + unawaited(() async { + try { + await recorder.start(); + if (active) { + startedAt.value = DateTime.now(); + isStarted.value = true; + } + } on StateError catch (recordingError) { + if (active) error.value = recordingError.message; + } catch (_) { + if (active) { + error.value = + 'Buzz could not start recording. Check microphone access.'; + } + } + }()); + return () { + active = false; + timer.cancel(); + unawaited(levelSubscription.cancel()); + unawaited(() async { + await recorder.cancel(); + await recorder.dispose(); + }()); + }; + }, [recorder]); + + final reducedMotion = MediaQuery.disableAnimationsOf(context); + return Row( + key: const ValueKey('voice-note-recorder'), + children: [ + _RecorderButton( + key: const ValueKey('voice-note-recorder-close'), + tooltip: 'Discard voice note', + icon: LucideIcons.x, + foreground: context.colors.onSurfaceVariant, + background: context.colors.surface, + onPressed: isStopping.value ? null : onCancel, + ), + const SizedBox(width: Grid.half), + if (error.value case final message?) + Expanded( + child: Text( + message, + key: const ValueKey('voice-note-recorder-error'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ) + else ...[ + Text( + '${formatVoiceNoteDuration(elapsed.value)} / ' + '${formatVoiceNoteDuration(voiceNoteMaxDuration)}', + key: const ValueKey('voice-note-recorder-duration'), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final barCount = ((constraints.maxWidth + 2) / 5).floor().clamp( + 1, + 1024, + ); + final recentSamples = samples.value.length <= barCount + ? samples.value + : samples.value.sublist(samples.value.length - barCount); + final waveform = [ + ...List.filled(barCount - recentSamples.length, 0), + ...recentSamples, + ]; + return ClipRect( + child: TweenAnimationBuilder( + key: ValueKey(sampleSequence.value), + tween: Tween(begin: reducedMotion ? 0 : 5, end: 0), + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 90), + curve: Curves.linear, + builder: (context, offset, child) => Transform.translate( + offset: Offset(offset, 0), + child: child, + ), + child: VoiceNoteWaveform( + samples: waveform, + progress: 1, + fadeEdges: true, + height: 24, + minimumBarHeight: 3, + maximumBarHeight: 20, + colorOpacity: 0.75, + ), + ), + ); + }, + ), + ), + ], + const SizedBox(width: Grid.half), + _RecorderButton( + key: const ValueKey('voice-note-recorder-stop'), + tooltip: 'Stop recording', + icon: LucideIcons.square, + foreground: Colors.white, + background: context.colors.error, + onPressed: error.value == null && isStarted.value && !isStopping.value + ? finish + : null, + ), + ], + ); + } +} + +/// Best-effort deletion for a finalized recording the composer cannot retain. +Future deleteDroppedVoiceNoteRecording(String path) async { + try { + final file = File(path); + if (await file.exists()) await file.delete(); + } catch (_) { + // Best-effort cleanup must not escape an already unmounted recorder. + } +} + +class _RecorderButton extends StatelessWidget { + const _RecorderButton({ + super.key, + required this.tooltip, + required this.icon, + required this.foreground, + required this.background, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final Color foreground; + final Color background; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed == null + ? null + : () { + unawaited(HapticFeedback.selectionClick()); + onPressed!(); + }, + style: IconButton.styleFrom( + foregroundColor: foreground, + backgroundColor: background, + disabledBackgroundColor: background.withValues(alpha: 0.5), + shape: const CircleBorder(), + side: BorderSide(color: Colors.black.withValues(alpha: 0.04), width: 1), + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: Icon(icon, size: 18), + ), + ); +} diff --git a/mobile/lib/features/channels/voice_note_play_pause_icon.dart b/mobile/lib/features/channels/voice_note_play_pause_icon.dart new file mode 100644 index 00000000000..032a7ab4332 --- /dev/null +++ b/mobile/lib/features/channels/voice_note_play_pause_icon.dart @@ -0,0 +1,197 @@ +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Animated icon that morphs between voice-note play and pause glyphs. +class VoiceNotePlayPauseIcon extends HookWidget { + const VoiceNotePlayPauseIcon({ + super.key, + required this.isPlaying, + this.color, + this.size = 23, + }); + + final bool isPlaying; + final Color? color; + final double size; + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final controller = useAnimationController( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 160), + initialValue: isPlaying ? 1 : 0, + ); + useEffect(() { + final target = isPlaying ? 1.0 : 0.0; + if (reducedMotion) { + controller.value = target; + } else { + controller.animateTo( + target, + duration: const Duration(milliseconds: 160), + curve: const Cubic(0.77, 0, 0.175, 1), + ); + } + return null; + }, [isPlaying, reducedMotion]); + + return ExcludeSemantics( + child: SizedBox.square( + dimension: size, + child: AnimatedBuilder( + animation: controller, + builder: (context, _) => CustomPaint( + key: ValueKey( + isPlaying + ? 'voice-note-play-pause-icon-pause' + : 'voice-note-play-pause-icon-play', + ), + painter: _VoiceNotePlayPausePainter( + progress: controller.value, + color: color ?? IconTheme.of(context).color!, + ), + ), + ), + ), + ); + } +} + +class _VoiceNotePlayPausePainter extends CustomPainter { + const _VoiceNotePlayPausePainter({ + required this.progress, + required this.color, + }); + + final double progress; + final Color color; + + static const _playPrimary = [ + Offset(8, 4.75), + Offset(7, 4.15), + Offset(7, 5.35), + Offset(7, 18.65), + Offset(7, 19.85), + Offset(8, 19.25), + Offset(18.1, 12.75), + Offset(19.3, 12), + Offset(18.1, 11.25), + Offset(8, 4.75), + Offset(8, 4.75), + Offset(8, 4.75), + ]; + static const _playSecondary = [ + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + Offset(12, 12), + ]; + static const _pausePrimary = [ + Offset(7.5, 5), + Offset(6.5, 5), + Offset(6.5, 6), + Offset(6.5, 18), + Offset(6.5, 19), + Offset(7.5, 19), + Offset(9.5, 19), + Offset(10.5, 19), + Offset(10.5, 18), + Offset(10.5, 6), + Offset(10.5, 5), + Offset(9.5, 5), + ]; + static const _pauseSecondary = [ + Offset(14.5, 5), + Offset(13.5, 5), + Offset(13.5, 6), + Offset(13.5, 18), + Offset(13.5, 19), + Offset(14.5, 19), + Offset(16.5, 19), + Offset(17.5, 19), + Offset(17.5, 18), + Offset(17.5, 6), + Offset(17.5, 5), + Offset(16.5, 5), + ]; + + @override + void paint(Canvas canvas, Size size) { + final scale = size.shortestSide / 24; + canvas.save(); + canvas.scale(scale, scale); + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + final strokePaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.8 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final primary = _morphPath(_playPrimary, _pausePrimary); + final secondary = _morphPath(_playSecondary, _pauseSecondary); + canvas + ..drawPath(primary, fillPaint) + ..drawPath(primary, strokePaint) + ..drawPath(secondary, fillPaint) + ..drawPath(secondary, strokePaint); + canvas.restore(); + } + + Path _morphPath(List from, List to) { + final points = [ + for (var index = 0; index < from.length; index++) + Offset( + lerpDouble(from[index].dx, to[index].dx, progress)!, + lerpDouble(from[index].dy, to[index].dy, progress)!, + ), + ]; + return Path() + ..moveTo(points[0].dx, points[0].dy) + ..quadraticBezierTo( + points[1].dx, + points[1].dy, + points[2].dx, + points[2].dy, + ) + ..lineTo(points[3].dx, points[3].dy) + ..quadraticBezierTo( + points[4].dx, + points[4].dy, + points[5].dx, + points[5].dy, + ) + ..lineTo(points[6].dx, points[6].dy) + ..quadraticBezierTo( + points[7].dx, + points[7].dy, + points[8].dx, + points[8].dy, + ) + ..lineTo(points[9].dx, points[9].dy) + ..quadraticBezierTo( + points[10].dx, + points[10].dy, + points[11].dx, + points[11].dy, + ) + ..close(); + } + + @override + bool shouldRepaint(_VoiceNotePlayPausePainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.color != color; +} diff --git a/mobile/lib/features/channels/voice_note_recording.dart b/mobile/lib/features/channels/voice_note_recording.dart new file mode 100644 index 00000000000..c34565c3415 --- /dev/null +++ b/mobile/lib/features/channels/voice_note_recording.dart @@ -0,0 +1,941 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:just_audio/just_audio.dart' as audio; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +import '../../shared/relay/media_image.dart'; + +/// Maximum duration accepted for a recorded voice note. +const voiceNoteMaxDuration = Duration(minutes: 5); + +/// Maximum time allowed for an authenticated voice-note download. +const voiceNoteDownloadTimeout = Duration(seconds: 30); + +/// Maximum number of bytes accepted for a downloaded voice note. +const voiceNoteMaxDownloadBytes = 32 * 1024 * 1024; + +/// Playback rates offered by the voice-note player, in selection order. +const voiceNotePlaybackRates = [1, 1.5, 2, 0.5]; + +/// Route observer used to cancel recording when its composer is covered. +final voiceNoteRouteObserver = RouteObserver>(); + +/// Returns the playback rate following [current] in the supported rate cycle. +double nextVoiceNotePlaybackRate(double current) { + final index = voiceNotePlaybackRates.indexOf(current); + return voiceNotePlaybackRates[(index + 1) % voiceNotePlaybackRates.length]; +} + +/// Formats a supported voice-note playback rate for display. +String formatVoiceNotePlaybackRate(double rate) => + '${rate == 0.5 ? '.5' : rate.toStringAsFixed(rate % 1 == 0 ? 0 : 1)}×'; + +/// A finalized local voice-note recording and its presentation metadata. +@immutable +class VoiceNoteRecording { + const VoiceNoteRecording({ + required this.file, + required this.duration, + required this.waveform, + }); + + final XFile file; + final Duration duration; + final List waveform; +} + +/// Records one voice note and owns its native lifecycle. +abstract interface class VoiceNoteRecorder { + Stream get levels; + + Future start(); + + Future stop(); + + Future cancel(); + + Future dispose(); +} + +/// Provider for creating independently owned voice-note recorders. +final voiceNoteRecorderFactoryProvider = Provider( + (ref) => DeviceVoiceNoteRecorder.new, +); + +/// Injectable native recorder contract used by [DeviceVoiceNoteRecorder]. +abstract interface class VoiceNoteRecorderBackend { + Future hasPermission(); + + Future start(RecordConfig config, {required String path}); + + Stream onAmplitudeChanged(Duration interval); + + Future stop(); + + Future cancel(); + + Future dispose(); +} + +class _DeviceVoiceNoteRecorderBackend implements VoiceNoteRecorderBackend { + final AudioRecorder _recorder = AudioRecorder(); + + @override + Future hasPermission() => _recorder.hasPermission(); + + @override + Future start(RecordConfig config, {required String path}) => + _recorder.start(config, path: path); + + @override + Stream onAmplitudeChanged(Duration interval) => + _recorder.onAmplitudeChanged(interval); + + @override + Future stop() => _recorder.stop(); + + @override + Future cancel() => _recorder.cancel(); + + @override + Future dispose() => _recorder.dispose(); +} + +/// Device-backed [VoiceNoteRecorder] with cancellation-safe lifecycle fences. +class DeviceVoiceNoteRecorder implements VoiceNoteRecorder { + DeviceVoiceNoteRecorder({ + VoiceNoteRecorderBackend? backend, + Future Function()? temporaryDirectory, + }) : _recorder = backend ?? _DeviceVoiceNoteRecorderBackend(), + _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory; + + final VoiceNoteRecorderBackend _recorder; + final Future Function() _temporaryDirectory; + final StreamController _levels = StreamController.broadcast(); + final List _samples = []; + StreamSubscription? _amplitudeSubscription; + Future? _startup; + Future? _terminalOperation; + DateTime? _startedAt; + String? _path; + int _lifecycleGeneration = 0; + bool _nativeStarted = false; + bool _nativeEnded = false; + bool _finished = false; + bool _disposed = false; + + @override + Stream get levels => _levels.stream; + + void _ensureStartupActive(int generation) { + if (_disposed || _finished || generation != _lifecycleGeneration) { + throw StateError('Voice note recording was cancelled.'); + } + } + + @override + Future start() { + if (_startup != null || _nativeStarted || _finished || _disposed) { + return Future.error( + StateError('Voice note recording cannot be started.'), + ); + } + final generation = ++_lifecycleGeneration; + final startup = _start(generation); + _startup = startup; + return startup.whenComplete(() { + if (identical(_startup, startup)) _startup = null; + }); + } + + Future _start(int generation) async { + final hasPermission = await _recorder.hasPermission(); + _ensureStartupActive(generation); + if (!hasPermission) { + throw StateError('Microphone access is required to record a voice note.'); + } + final directory = await _temporaryDirectory(); + _ensureStartupActive(generation); + final path = + '${directory.path}${Platform.pathSeparator}' + 'voice-note-${DateTime.now().millisecondsSinceEpoch}.m4a'; + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.aacLc, + bitRate: 96000, + sampleRate: 44100, + numChannels: 1, + autoGain: true, + echoCancel: true, + noiseSuppress: true, + ), + path: path, + ); + _nativeStarted = true; + _ensureStartupActive(generation); + _path = path; + _startedAt = DateTime.now(); + _ensureStartupActive(generation); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 80)) + .listen((amplitude) { + final normalized = + (math + .pow(10, amplitude.current.clamp(-60.0, 0.0) / 20) + .toDouble() * + 4) + .clamp(0.04, 1.0); + _samples.add(normalized); + if (!_levels.isClosed) _levels.add(normalized); + }); + } + + @override + Future stop() { + if (_finished || !_nativeStarted || _nativeEnded) { + return Future.error(StateError('Voice note recording is not active.')); + } + _finished = true; + _lifecycleGeneration += 1; + final operation = _stop(); + final terminalOperation = operation.then((_) {}, onError: (_, _) {}); + _terminalOperation = terminalOperation; + return operation.whenComplete(() { + if (identical(_terminalOperation, terminalOperation)) { + _terminalOperation = null; + } + }); + } + + Future _stop() async { + await _amplitudeSubscription?.cancel(); + final recordedPath = await _recorder.stop() ?? _path; + _nativeEnded = true; + if (recordedPath == null || recordedPath.isEmpty) { + throw StateError('Buzz could not finish the voice note.'); + } + final startedAt = _startedAt; + final duration = startedAt == null + ? Duration.zero + : DateTime.now().difference(startedAt); + return VoiceNoteRecording( + file: XFile(recordedPath, mimeType: 'audio/mp4'), + duration: duration, + waveform: List.unmodifiable(_samples), + ); + } + + @override + Future cancel() { + final activeTerminalOperation = _terminalOperation; + if (activeTerminalOperation != null) return activeTerminalOperation; + if (_finished) return Future.value(); + _finished = true; + _lifecycleGeneration += 1; + final operation = _cancel(); + _terminalOperation = operation; + return operation.whenComplete(() { + if (identical(_terminalOperation, operation)) _terminalOperation = null; + }); + } + + Future _cancel() async { + try { + await _startup; + } catch (_) { + // A cancellation fence intentionally rejects stale startup work. + } + await _amplitudeSubscription?.cancel(); + if (_nativeStarted && !_nativeEnded) { + await _recorder.cancel(); + _nativeEnded = true; + } + } + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + _lifecycleGeneration += 1; + final terminalOperation = + _terminalOperation ?? (!_finished ? cancel() : null); + if (terminalOperation != null) { + try { + await terminalOperation; + } catch (_) { + // Disposal still owns backend release when a terminal operation fails. + } + } + try { + await _startup; + } catch (_) { + // Startup may reject because disposal invalidated its generation. + } + await _amplitudeSubscription?.cancel(); + if (_nativeStarted && !_nativeEnded) { + await _recorder.cancel(); + _nativeEnded = true; + } + await _recorder.dispose(); + await _levels.close(); + } +} + +/// Immutable state exposed by a [VoiceNotePlayerController]. +@immutable +class VoiceNotePlaybackState { + const VoiceNotePlaybackState({ + this.position = Duration.zero, + this.duration = Duration.zero, + this.isPlaying = false, + this.isLoading = false, + this.canCancelLoading = false, + this.hasError = false, + }); + + final Duration position; + final Duration duration; + final bool isPlaying; + final bool isLoading; + final bool canCancelLoading; + final bool hasError; + + VoiceNotePlaybackState copyWith({ + Duration? position, + Duration? duration, + bool? isPlaying, + bool? isLoading, + bool? canCancelLoading, + bool? hasError, + }) => VoiceNotePlaybackState( + position: position ?? this.position, + duration: duration ?? this.duration, + isPlaying: isPlaying ?? this.isPlaying, + isLoading: isLoading ?? this.isLoading, + canCancelLoading: canCancelLoading ?? this.canCancelLoading, + hasError: hasError ?? this.hasError, + ); +} + +/// Controller contract for loading and playing one voice-note source. +abstract class VoiceNotePlayerController extends ChangeNotifier { + VoiceNotePlaybackState get state; + + Future loadLocal(String path, {required Duration fallbackDuration}); + + Future loadRemote( + String url, { + required Map Function() headers, + required Duration fallbackDuration, + }); + + Future toggle(); + + Future pause(); + + Future seek(Duration position); + + Future setSpeed(double speed); +} + +/// Arbitrates the single voice note allowed to own playback at a time. +class VoiceNotePlaybackCoordinator { + VoiceNotePlayerController? _active; + + Future activate(VoiceNotePlayerController controller) async { + if (identical(_active, controller)) return true; + final previous = _active; + _active = controller; + await previous?.pause(); + return identical(_active, controller); + } + + bool ownsPlayback(VoiceNotePlayerController controller) => + identical(_active, controller); + + void release(VoiceNotePlayerController controller) { + if (identical(_active, controller)) _active = null; + } +} + +/// Provider for the channel-scoped voice-note playback coordinator. +final voiceNotePlaybackCoordinatorProvider = Provider( + (ref) => VoiceNotePlaybackCoordinator(), +); + +/// Provider for creating voice-note player controllers. +final voiceNotePlayerFactoryProvider = + Provider((ref) { + final coordinator = ref.watch(voiceNotePlaybackCoordinatorProvider); + final client = ref.watch(mediaHttpClientProvider); + return () => DeviceVoiceNotePlayerController( + coordinator: coordinator, + client: client, + ); + }); + +/// Injectable audio-player contract used by [DeviceVoiceNotePlayerController]. +abstract interface class VoiceNoteAudioPlayerBackend { + Stream get positionStream; + + Stream get durationStream; + + Stream get playerStateStream; + + bool get playing; + + Future setFilePath(String path); + + Future setUrl(String url, {Map? headers}); + + Future play(); + + Future pause(); + + /// Interrupts a pending source load and releases its native resources. + Future cancelPendingLoad(); + + Future seek(Duration position); + + Future setSpeed(double speed); + + Future dispose(); +} + +class _DeviceVoiceNoteAudioPlayerBackend + implements VoiceNoteAudioPlayerBackend { + _DeviceVoiceNoteAudioPlayerBackend() + : _player = audio.AudioPlayer(useProxyForRequestHeaders: false); + + final audio.AudioPlayer _player; + + @override + Stream get positionStream => _player.positionStream; + + @override + Stream get durationStream => _player.durationStream; + + @override + Stream get playerStateStream => _player.playerStateStream; + + @override + bool get playing => _player.playing; + + @override + Future setFilePath(String path) => _player.setFilePath(path); + + @override + Future setUrl(String url, {Map? headers}) => + _player.setUrl(url, headers: headers); + + @override + Future play() => _player.play(); + + @override + Future pause() => _player.pause(); + + @override + Future cancelPendingLoad() => _player.stop(); + + @override + Future seek(Duration position) => _player.seek(position); + + @override + Future setSpeed(double speed) => _player.setSpeed(speed); + + @override + Future dispose() => _player.dispose(); +} + +/// Device-backed voice-note player with authenticated loading and cancellation. +class DeviceVoiceNotePlayerController extends VoiceNotePlayerController { + DeviceVoiceNotePlayerController({ + required VoiceNotePlaybackCoordinator coordinator, + required http.Client client, + Future Function()? temporaryDirectory, + bool? requiresAuthenticatedLocalFile, + Duration downloadTimeout = voiceNoteDownloadTimeout, + int maxDownloadBytes = voiceNoteMaxDownloadBytes, + VoiceNoteAudioPlayerBackend? player, + }) : _coordinator = coordinator, + _client = client, + _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory, + _requiresAuthenticatedLocalFile = + requiresAuthenticatedLocalFile ?? Platform.isIOS, + _downloadTimeout = downloadTimeout, + _maxDownloadBytes = maxDownloadBytes, + _player = player ?? _DeviceVoiceNoteAudioPlayerBackend() { + _subscriptions.add( + _player.positionStream.listen((position) { + _update(_state.copyWith(position: position)); + }), + ); + _subscriptions.add( + _player.durationStream.listen((duration) { + if (duration != null) _update(_state.copyWith(duration: duration)); + }), + ); + _subscriptions.add( + _player.playerStateStream.listen((playerState) { + if (playerState.processingState == audio.ProcessingState.completed) { + _coordinator.release(this); + _update( + _state.copyWith( + position: Duration.zero, + isPlaying: false, + isLoading: false, + canCancelLoading: false, + ), + ); + unawaited(_stopAndRewindCompletedPlayback()); + return; + } + final isBackendLoading = + playerState.processingState == audio.ProcessingState.loading || + playerState.processingState == audio.ProcessingState.buffering; + _update( + _state.copyWith( + isPlaying: playerState.playing, + isLoading: isBackendLoading, + canCancelLoading: + isBackendLoading && + (_toggleOperation != null || playerState.playing), + ), + ); + }), + ); + } + + final VoiceNotePlaybackCoordinator _coordinator; + final http.Client _client; + final Future Function() _temporaryDirectory; + final VoiceNoteAudioPlayerBackend _player; + final bool _requiresAuthenticatedLocalFile; + final Duration _downloadTimeout; + final int _maxDownloadBytes; + final List> _subscriptions = []; + VoiceNotePlaybackState _state = const VoiceNotePlaybackState(); + ({ + String url, + Map Function() headers, + Duration fallbackDuration, + })? + _pendingRemote; + Completer? _downloadAbort; + Future? _toggleOperation; + Object? _cancellableSourceLoad; + bool _toggleCancellationRequested = false; + int _playbackOperationGeneration = 0; + File? _downloadingRemoteFile; + File? _remoteFile; + int _sourceGeneration = 0; + bool _hasPlayableSource = false; + bool _disposed = false; + + Future _stopAndRewindCompletedPlayback() async { + await _player.pause(); + await _player.seek(Duration.zero); + } + + @override + VoiceNotePlaybackState get state => _state; + + @override + Future loadLocal(String path, {required Duration fallbackDuration}) { + _replaceSource(); + return _load( + () => _player.setFilePath(path), + fallbackDuration: fallbackDuration, + sourceGeneration: _sourceGeneration, + ); + } + + @override + Future loadRemote( + String url, { + required Map Function() headers, + required Duration fallbackDuration, + }) { + _replaceSource(); + final remote = ( + url: url, + headers: headers, + fallbackDuration: fallbackDuration, + ); + _pendingRemote = remote; + _update(VoiceNotePlaybackState(duration: fallbackDuration)); + return Future.value(); + } + + void _replaceSource() { + _sourceGeneration += 1; + _playbackOperationGeneration += 1; + _pendingRemote = null; + _hasPlayableSource = false; + final remoteFile = _remoteFile; + _remoteFile = null; + unawaited(_deleteRemoteFile(remoteFile)); + final activeDownloadAbort = _downloadAbort; + if (activeDownloadAbort != null && !activeDownloadAbort.isCompleted) { + activeDownloadAbort.complete(); + } + } + + Future _loadPendingRemote(int playbackOperationGeneration) async { + final remote = _pendingRemote; + if (remote == null || + playbackOperationGeneration != _playbackOperationGeneration) { + return null; + } + final sourceGeneration = _sourceGeneration; + final uri = Uri.parse(remote.url); + final requestAbort = Completer(); + _downloadAbort = requestAbort; + File? file; + try { + if (playbackOperationGeneration != _playbackOperationGeneration) { + if (!requestAbort.isCompleted) requestAbort.complete(); + return null; + } + final request = http.AbortableStreamedRequest( + 'GET', + uri, + abortTrigger: requestAbort.future, + )..headers.addAll(remote.headers()); + final response = await _client + .send(request) + .timeout( + _downloadTimeout, + onTimeout: () { + if (!requestAbort.isCompleted) requestAbort.complete(); + throw TimeoutException('Voice note download timed out'); + }, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + if (!requestAbort.isCompleted) requestAbort.complete(); + throw HttpException( + 'Voice note download failed (${response.statusCode})', + uri: uri, + ); + } + if (response.contentLength case final contentLength? + when contentLength > _maxDownloadBytes) { + if (!requestAbort.isCompleted) requestAbort.complete(); + throw HttpException('Voice note download is too large', uri: uri); + } + final directory = await _temporaryDirectory(); + if (playbackOperationGeneration != _playbackOperationGeneration) { + if (!requestAbort.isCompleted) requestAbort.complete(); + return null; + } + file = File( + '${directory.path}${Platform.pathSeparator}' + 'buzz-voice-note-${DateTime.now().microsecondsSinceEpoch}.mp4', + ); + _downloadingRemoteFile = file; + var downloadedBytes = 0; + await response.stream + .map((chunk) { + downloadedBytes += chunk.length; + if (downloadedBytes > _maxDownloadBytes) { + if (!requestAbort.isCompleted) requestAbort.complete(); + throw HttpException('Voice note download is too large', uri: uri); + } + return chunk; + }) + .pipe(file.openWrite()) + .timeout( + _downloadTimeout, + onTimeout: () { + if (!requestAbort.isCompleted) requestAbort.complete(); + throw TimeoutException('Voice note download timed out'); + }, + ); + if (_disposed || + playbackOperationGeneration != _playbackOperationGeneration || + sourceGeneration != _sourceGeneration || + !_coordinator.ownsPlayback(this)) { + if (!requestAbort.isCompleted) requestAbort.complete(); + return null; + } + final duration = await _player.setFilePath(file.path); + if (_disposed || + playbackOperationGeneration != _playbackOperationGeneration || + sourceGeneration != _sourceGeneration || + !_coordinator.ownsPlayback(this)) { + return null; + } + await _deleteRemoteFile(_remoteFile); + if (_disposed || + playbackOperationGeneration != _playbackOperationGeneration || + sourceGeneration != _sourceGeneration || + !_coordinator.ownsPlayback(this)) { + return null; + } + _remoteFile = file; + _downloadingRemoteFile = null; + if (identical(_pendingRemote, remote)) _pendingRemote = null; + return duration; + } on http.RequestAbortedException { + return null; + } finally { + if (identical(_downloadAbort, requestAbort)) _downloadAbort = null; + if (identical(_downloadingRemoteFile, file)) { + _downloadingRemoteFile = null; + } + if (file != null && !identical(_remoteFile, file)) { + await _deleteRemoteFile(file); + } + } + } + + Future _deleteRemoteFile(File? file) async { + if (file == null) return; + try { + if (await file.exists()) await file.delete(); + } on FileSystemException { + // Temporary playback cleanup must not make the player fail. + } + } + + Future _load( + Future Function() load, { + required Duration fallbackDuration, + required int sourceGeneration, + int? playbackOperationGeneration, + bool canCancelLoading = false, + }) async { + final sourceLoad = Object(); + if (canCancelLoading) _cancellableSourceLoad = sourceLoad; + _update( + VoiceNotePlaybackState( + duration: fallbackDuration, + isLoading: true, + canCancelLoading: canCancelLoading, + ), + ); + try { + final duration = await load(); + if (_disposed || + sourceGeneration != _sourceGeneration || + (playbackOperationGeneration != null && + playbackOperationGeneration != _playbackOperationGeneration)) { + if (sourceGeneration == _sourceGeneration) { + _update(_state.copyWith(isLoading: false, canCancelLoading: false)); + } + return; + } + _hasPlayableSource = true; + _update( + _state.copyWith( + duration: duration ?? fallbackDuration, + isLoading: false, + canCancelLoading: false, + hasError: false, + ), + ); + } catch (_) { + if (_disposed || + sourceGeneration != _sourceGeneration || + (playbackOperationGeneration != null && + playbackOperationGeneration != _playbackOperationGeneration)) { + if (sourceGeneration == _sourceGeneration) { + _update(_state.copyWith(isLoading: false, canCancelLoading: false)); + } + return; + } + _coordinator.release(this); + _update( + _state.copyWith( + isLoading: false, + canCancelLoading: false, + hasError: true, + ), + ); + } finally { + if (identical(_cancellableSourceLoad, sourceLoad)) { + _cancellableSourceLoad = null; + } + } + } + + @override + Future toggle() { + final activeToggle = _toggleOperation; + if (activeToggle != null) { + if (!_toggleCancellationRequested) { + _toggleCancellationRequested = true; + unawaited(pause()); + } + return activeToggle; + } + _toggleCancellationRequested = false; + final playbackOperationGeneration = ++_playbackOperationGeneration; + final operation = _toggle(playbackOperationGeneration); + _toggleOperation = operation; + return operation.whenComplete(() { + if (identical(_toggleOperation, operation)) { + _toggleOperation = null; + _toggleCancellationRequested = false; + } + }); + } + + Future _play(int sourceGeneration) async { + try { + await _player.play(); + } catch (_) { + if (_disposed || sourceGeneration != _sourceGeneration) return; + _coordinator.release(this); + _update( + _state.copyWith( + isLoading: false, + canCancelLoading: false, + hasError: true, + ), + ); + } + } + + Future _toggle(int playbackOperationGeneration) async { + if (_player.playing) { + await pause(); + } else if (_state.isLoading) { + return; + } else { + final remote = _pendingRemote; + if (_state.hasError && remote == null && !_hasPlayableSource) return; + if (_state.hasError) { + _update(_state.copyWith(hasError: false)); + } + final ownsPlayback = await _coordinator.activate(this); + if (!ownsPlayback || + _disposed || + playbackOperationGeneration != _playbackOperationGeneration) { + return; + } + if (remote != null) { + final sourceGeneration = _sourceGeneration; + if (_requiresAuthenticatedLocalFile) { + await _load( + () => _loadPendingRemote(playbackOperationGeneration), + fallbackDuration: remote.fallbackDuration, + sourceGeneration: sourceGeneration, + playbackOperationGeneration: playbackOperationGeneration, + canCancelLoading: true, + ); + if (playbackOperationGeneration != _playbackOperationGeneration || + sourceGeneration != _sourceGeneration || + _pendingRemote != null) { + return; + } + } else { + await _load( + () => _player.setUrl(remote.url, headers: remote.headers()), + fallbackDuration: remote.fallbackDuration, + sourceGeneration: sourceGeneration, + playbackOperationGeneration: playbackOperationGeneration, + canCancelLoading: true, + ); + if (playbackOperationGeneration != _playbackOperationGeneration || + sourceGeneration != _sourceGeneration || + _state.hasError) { + return; + } + if (identical(_pendingRemote, remote)) _pendingRemote = null; + } + } + if (playbackOperationGeneration == _playbackOperationGeneration && + _coordinator.ownsPlayback(this) && + !_disposed && + !_state.hasError) { + unawaited(_play(_sourceGeneration)); + } + } + } + + @override + Future pause() async { + final shouldCancelPendingLoad = _cancellableSourceLoad != null; + _playbackOperationGeneration += 1; + _coordinator.release(this); + final activeDownloadAbort = _downloadAbort; + if (activeDownloadAbort != null && !activeDownloadAbort.isCompleted) { + activeDownloadAbort.complete(); + } + if (shouldCancelPendingLoad) { + await _player.cancelPendingLoad(); + } else { + await _player.pause(); + } + } + + @override + Future seek(Duration position) => _player.seek(position); + + @override + Future setSpeed(double speed) => _player.setSpeed(speed); + + void _update(VoiceNotePlaybackState next) { + if (_disposed) return; + _state = next; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _sourceGeneration += 1; + _coordinator.release(this); + final activeDownloadAbort = _downloadAbort; + if (activeDownloadAbort != null && !activeDownloadAbort.isCompleted) { + activeDownloadAbort.complete(); + } + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + unawaited(_player.dispose()); + unawaited(_deleteRemoteFile(_remoteFile)); + super.dispose(); + } +} + +/// Formats a duration as a non-negative `m:ss` voice-note timestamp. +String formatVoiceNoteDuration(Duration duration) { + final totalSeconds = math.max(0, duration.inSeconds); + final minutes = totalSeconds ~/ 60; + final seconds = totalSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; +} + +/// Resamples [samples] into normalized waveform bars for attachment previews. +List normalizeVoiceNoteWaveform( + List samples, { + int barCount = 36, +}) { + if (barCount <= 0) return const []; + if (samples.isEmpty) return List.filled(barCount, 0.12); + return List.generate(barCount, (index) { + final start = (index * samples.length / barCount).floor(); + final end = math.max( + start + 1, + ((index + 1) * samples.length / barCount).floor(), + ); + var peak = 0.0; + for ( + var sampleIndex = start; + sampleIndex < end && sampleIndex < samples.length; + sampleIndex++ + ) { + peak = math.max(peak, samples[sampleIndex]); + } + return peak.clamp(0.08, 1.0); + }); +} diff --git a/mobile/lib/features/channels/voice_note_waveform.dart b/mobile/lib/features/channels/voice_note_waveform.dart new file mode 100644 index 00000000000..4eb44a948d6 --- /dev/null +++ b/mobile/lib/features/channels/voice_note_waveform.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; + +/// Resolves bar width and spacing so a waveform occupies its full width. +@visibleForTesting +({double barWidth, double gap}) voiceNoteWaveformBarLayout({ + required double width, + required int sampleCount, +}) { + if (sampleCount <= 0 || width <= 0) return (barWidth: 0, gap: 0); + const preferredGap = 2.0; + final availableBarWidth = + (width - (preferredGap * (sampleCount - 1))) / sampleCount; + final barWidth = availableBarWidth.clamp(1.0, 3.0); + if (sampleCount == 1) return (barWidth: barWidth, gap: 0); + final gap = ((width - (barWidth * sampleCount)) / (sampleCount - 1)).clamp( + 0.0, + double.infinity, + ); + return (barWidth: barWidth, gap: gap); +} + +/// Paints voice-note samples and optionally exposes seek semantics. +class VoiceNoteWaveform extends StatelessWidget { + const VoiceNoteWaveform({ + super.key, + required this.samples, + this.progress = 0, + this.onSeek, + this.fadeEdges = false, + this.height = 32, + this.minimumBarHeight = 4, + this.maximumBarHeight, + this.colorOpacity = 1, + }); + + final List samples; + final double progress; + final ValueChanged? onSeek; + final bool fadeEdges; + final double height; + final double minimumBarHeight; + final double? maximumBarHeight; + final double colorOpacity; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + Widget waveform = CustomPaint( + key: const ValueKey('voice-note-waveform'), + painter: _VoiceNoteWaveformPainter( + samples: samples, + progress: progress.clamp(0.0, 1.0), + activeColor: colorScheme.primary.withValues(alpha: colorOpacity), + inactiveColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.46), + minimumBarHeight: minimumBarHeight, + maximumBarHeight: maximumBarHeight ?? height, + ), + size: Size(double.infinity, height), + ); + if (fadeEdges) { + waveform = ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + colors: [ + Colors.transparent, + Colors.white, + Colors.white, + Colors.transparent, + ], + stops: [0, 0.1, 0.9, 1], + ).createShader(bounds), + child: waveform, + ); + } + return LayoutBuilder( + builder: (context, constraints) { + void seek(double dx) { + final width = constraints.maxWidth; + if (onSeek != null && width > 0 && width.isFinite) { + onSeek!((dx / width).clamp(0.0, 1.0)); + } + } + + void adjust(double delta) => + onSeek?.call((progress.clamp(0.0, 1.0) + delta).clamp(0.0, 1.0)); + + return Semantics( + label: 'Voice note waveform', + slider: onSeek != null, + value: onSeek == null ? null : '${(progress * 100).round()} percent', + increasedValue: onSeek == null + ? null + : '${((progress + 0.1).clamp(0.0, 1.0) * 100).round()} percent', + decreasedValue: onSeek == null + ? null + : '${((progress - 0.1).clamp(0.0, 1.0) * 100).round()} percent', + onIncrease: onSeek == null ? null : () => adjust(0.1), + onDecrease: onSeek == null ? null : () => adjust(-0.1), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: onSeek == null + ? null + : (details) => seek(details.localPosition.dx), + onHorizontalDragStart: onSeek == null + ? null + : (details) => seek(details.localPosition.dx), + onHorizontalDragUpdate: onSeek == null + ? null + : (details) => seek(details.localPosition.dx), + child: SizedBox( + height: height, + width: double.infinity, + child: waveform, + ), + ), + ); + }, + ); + } +} + +class _VoiceNoteWaveformPainter extends CustomPainter { + const _VoiceNoteWaveformPainter({ + required this.samples, + required this.progress, + required this.activeColor, + required this.inactiveColor, + required this.minimumBarHeight, + required this.maximumBarHeight, + }); + + final List samples; + final double progress; + final Color activeColor; + final Color inactiveColor; + final double minimumBarHeight; + final double maximumBarHeight; + + @override + void paint(Canvas canvas, Size size) { + if (samples.isEmpty || size.width <= 0 || size.height <= 0) return; + final layout = voiceNoteWaveformBarLayout( + width: size.width, + sampleCount: samples.length, + ); + final resolvedWidth = layout.barWidth; + final gap = layout.gap; + const originX = 0.0; + final activeEdge = size.width * progress; + final radius = Radius.circular(resolvedWidth / 2); + + void drawBars(Color color) { + final paint = Paint()..color = color; + for (var index = 0; index < samples.length; index++) { + final maxBarHeight = maximumBarHeight.clamp( + minimumBarHeight, + size.height, + ); + final barHeight = + (minimumBarHeight + + (samples[index].clamp(0.0, 1.0) * + (maxBarHeight - minimumBarHeight))) + .clamp(minimumBarHeight, maxBarHeight); + final left = originX + index * (resolvedWidth + gap); + final rect = Rect.fromLTWH( + left, + (size.height - barHeight) / 2, + resolvedWidth, + barHeight, + ); + canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint); + } + } + + drawBars(inactiveColor); + if (activeEdge <= 0) return; + canvas.save(); + canvas.clipRect(Rect.fromLTWH(0, 0, activeEdge, size.height)); + drawBars(activeColor); + canvas.restore(); + } + + @override + bool shouldRepaint(_VoiceNoteWaveformPainter oldDelegate) => + oldDelegate.samples != samples || + oldDelegate.progress != progress || + oldDelegate.activeColor != activeColor || + oldDelegate.inactiveColor != inactiveColor || + oldDelegate.minimumBarHeight != minimumBarHeight || + oldDelegate.maximumBarHeight != maximumBarHeight; +} diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index cbe59bcd33a..4f79080efd8 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -18,12 +18,14 @@ import 'mp4_fast_start.dart'; import 'relay_provider.dart'; part 'media_upload/platform_bindings.dart'; +part 'media_upload/helpers.dart'; const _mediaUploadPath = '/upload'; const _legacyMediaUploadPath = '/media/upload'; const _mediaUploadPlatformChannelName = 'buzz/media_upload'; const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; const _transcodeVideoToMp4Method = 'transcodeVideoToMp4'; +const _packageVoiceNoteForUploadMethod = 'packageVoiceNoteForUpload'; const _generateVideoPosterMethod = 'generateVideoPoster'; const _transcodeImageToJpegMethod = 'transcodeImageToJpeg'; const _requiresLegacyMediaStoragePermissionMethod = @@ -53,6 +55,7 @@ const _allowedImageMimeTypes = { 'image/webp', }; const _allowedVideoMimeTypes = {'video/mp4'}; +const _allowedAudioMimeTypes = {'audio/mp4', 'audio/m4a', 'audio/aac'}; const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload."; @@ -71,6 +74,9 @@ typedef SanitizeImageBytes = typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); typedef TranscodeVideoToMp4 = Future Function(String filePath); +/// Packages a recorded voice-note file into its upload container. +typedef PackageVoiceNoteForUpload = Future Function(String filePath); + /// Generates poster-frame bytes for the video at [filePath], when available. typedef GenerateVideoPoster = Future Function(String filePath); typedef ReadClipboardImage = Future Function(); @@ -168,6 +174,24 @@ class BlobDescriptor { filename: value, ); + /// Returns a descriptor carrying canonical packaged voice-note metadata. + BlobDescriptor withVoiceNoteMetadata({ + required String filename, + required double fallbackDurationSeconds, + }) => BlobDescriptor( + url: url, + sha256: sha256, + size: size, + type: type, + uploaded: uploaded, + dim: dim, + blurhash: blurhash, + thumb: thumb, + duration: duration ?? fallbackDurationSeconds, + image: image, + filename: filename, + ); + /// Returns a descriptor with [value] as its NIP-71 video poster URL. BlobDescriptor withImage(String value) => BlobDescriptor( url: url, @@ -198,12 +222,14 @@ class BlobDescriptor { ]; String toMarkdownImage() { - if (type.startsWith('video/')) return '![video]($url)'; - if (type.startsWith('image/')) return '![image]($url)'; final label = (filename ?? 'file').replaceAllMapped( RegExp(r'[\\\[\]]'), (match) => '\\${match[0]}', ); + if (type.startsWith('audio/')) return '![audio]($url)'; + if (_isPackagedVoiceNote(type, filename)) return '[$label]($url)'; + if (type.startsWith('video/')) return '![video]($url)'; + if (type.startsWith('image/')) return '![image]($url)'; return '[$label]($url)'; } } @@ -219,6 +245,7 @@ class MediaUploadService { final SanitizeImageBytes _sanitizeImageBytes; final TranscodeImageToJpeg _transcodeImageToJpeg; final TranscodeVideoToMp4 _transcodeVideoToMp4; + final PackageVoiceNoteForUpload _packageVoiceNoteForUpload; final GenerateVideoPoster _generateVideoPoster; final ReadClipboardImage _readClipboardImage; final DateTime Function() _now; @@ -236,6 +263,7 @@ class MediaUploadService { SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, TranscodeVideoToMp4? transcodeVideoToMp4, + PackageVoiceNoteForUpload? packageVoiceNoteForUpload, GenerateVideoPoster? generateVideoPoster, ReadClipboardImage? readClipboardImage, DateTime Function()? now, @@ -256,6 +284,8 @@ class MediaUploadService { _transcodeImageToJpeg = transcodeImageToJpeg ?? _transcodePickedImageToJpeg, _transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4, + _packageVoiceNoteForUpload = + packageVoiceNoteForUpload ?? _packagePickedVoiceNoteForUpload, _generateVideoPoster = generateVideoPoster ?? _generatePickedVideoPoster, _readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage, _now = now ?? DateTime.now, @@ -434,6 +464,48 @@ class MediaUploadService { return uploadVideo(pickedVideo); } + /// Packages a recorded AAC voice note in the canonical MP4 envelope. + Future uploadVoiceNote( + XFile voiceNote, { + required Duration duration, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + _throwIfCancelled(cancellationToken); + final mimeType = voiceNote.mimeType ?? 'audio/mp4'; + if (!_allowedAudioMimeTypes.contains(mimeType)) { + throw Exception('unsupported voice note type: $mimeType'); + } + String? packagedPath; + try { + packagedPath = await _packageVoiceNoteForUpload(voiceNote.path); + _throwIfCancelled(cancellationToken); + final bytes = await File(packagedPath).readAsBytes(); + if (bytes.isEmpty) throw Exception('Voice note is empty.'); + if (bytes.length > _maxFileSizeBytes) { + throw Exception('Voice note is too large. Maximum is 100MB.'); + } + final descriptor = await _uploadPreparedBytes( + bytes, + mimeType: 'video/mp4', + onProgress: onProgress, + cancellationToken: cancellationToken, + ); + return descriptor.withVoiceNoteMetadata( + filename: _voiceNoteMp4Filename(voiceNote.name), + fallbackDurationSeconds: duration.inMilliseconds / 1000, + ); + } finally { + if (packagedPath != null && packagedPath != voiceNote.path) { + try { + await File(packagedPath).delete(); + } on FileSystemException { + // Best-effort temp file cleanup. + } + } + } + } + /// Opens the system document picker for a generic file attachment. Future pickAttachmentFile() async { final pickAttachmentFile = _pickAttachmentFile; @@ -718,6 +790,23 @@ String _safeAttachmentFilename(String filename) { return safeBasename.isEmpty ? 'file' : safeBasename; } +String _voiceNoteMp4Filename(String filename) { + final safe = _safeAttachmentFilename(filename); + final withoutExtension = safe.replaceFirst(RegExp(r'\.[^.]*$'), ''); + final stem = withoutExtension.toLowerCase().startsWith('voice-note-') + ? withoutExtension + : 'voice-note-$withoutExtension'; + return '$stem.mp4'; +} + +bool _isPackagedVoiceNote(String mimeType, String? filename) { + final normalized = filename?.toLowerCase(); + return mimeType == 'video/mp4' && + normalized != null && + normalized.startsWith('voice-note-') && + normalized.endsWith('.mp4'); +} + Stream> _uploadByteStream( Uint8List bytes, ValueChanged? onProgress, @@ -886,110 +975,3 @@ bool _looksLikeHeicOrHeif(Uint8List bytes) { return false; } - -bool _startsWith(Uint8List bytes, List prefix) { - if (bytes.length < prefix.length) return false; - for (var i = 0; i < prefix.length; i++) { - if (bytes[i] != prefix[i]) return false; - } - return true; -} - -bool _matchesAscii(Uint8List bytes, int offset, String value) { - final codeUnits = ascii.encode(value); - if (bytes.length < offset + codeUnits.length) return false; - for (var i = 0; i < codeUnits.length; i++) { - if (bytes[offset + i] != codeUnits[i]) return false; - } - return true; -} - -int _readUint32BigEndian(Uint8List bytes, int offset) { - return (bytes[offset] << 24) | - (bytes[offset + 1] << 16) | - (bytes[offset + 2] << 8) | - bytes[offset + 3]; -} - -int _readUint32LittleEndian(Uint8List bytes, int offset) { - return bytes[offset] | - (bytes[offset + 1] << 8) | - (bytes[offset + 2] << 16) | - (bytes[offset + 3] << 24); -} - -Future _readPlatformClipboardImage() async { - return _mediaUploadPlatformChannel.invokeMethod( - _readClipboardImageMethod, - ); -} - -Future _generatePickedVideoPoster(String filePath) { - return _mediaUploadPlatformChannel.invokeMethod( - _generateVideoPosterMethod, - filePath, - ); -} - -Future _transcodePickedVideoToMp4(String filePath) async { - final result = await _mediaUploadPlatformChannel.invokeMethod( - _transcodeVideoToMp4Method, - filePath, - ); - if (result == null || result.isEmpty) { - throw Exception('Failed to convert video to MP4.'); - } - if (defaultTargetPlatform == TargetPlatform.android) { - final source = File(result); - final destination = File( - '$result.faststart-${DateTime.now().microsecondsSinceEpoch}.mp4', - ); - try { - await rewriteMp4ForFastStart(source, destination); - await source.delete(); - return destination.path; - } catch (_) { - try { - await destination.delete(); - } on FileSystemException { - // Best-effort cleanup; preserve the original platform error. - } - rethrow; - } - } - return result; -} - -Future _transcodePickedImageToJpeg(Uint8List bytes) async { - return _invokeRequiredPlatformBytesMethod( - _transcodeImageToJpegMethod, - arguments: bytes, - errorMessage: 'failed to convert image for upload', - ); -} - -Future _sanitizePickedImageBytes( - Uint8List bytes, - String mimeType, -) async { - return _invokeRequiredPlatformBytesMethod( - _sanitizeImageForUploadMethod, - arguments: {'bytes': bytes, 'mimeType': mimeType}, - errorMessage: 'failed to sanitize image for upload', - ); -} - -Future _invokeRequiredPlatformBytesMethod( - String method, { - Object? arguments, - required String errorMessage, -}) async { - final result = await _mediaUploadPlatformChannel.invokeMethod( - method, - arguments, - ); - if (result == null || result.isEmpty) { - throw Exception(errorMessage); - } - return result; -} diff --git a/mobile/lib/shared/relay/media_upload/helpers.dart b/mobile/lib/shared/relay/media_upload/helpers.dart new file mode 100644 index 00000000000..c8dd4504d5a --- /dev/null +++ b/mobile/lib/shared/relay/media_upload/helpers.dart @@ -0,0 +1,139 @@ +part of '../media_upload.dart'; + +bool _startsWith(Uint8List bytes, List prefix) { + if (bytes.length < prefix.length) return false; + for (var i = 0; i < prefix.length; i++) { + if (bytes[i] != prefix[i]) return false; + } + return true; +} + +bool _matchesAscii(Uint8List bytes, int offset, String value) { + final codeUnits = ascii.encode(value); + if (bytes.length < offset + codeUnits.length) return false; + for (var i = 0; i < codeUnits.length; i++) { + if (bytes[offset + i] != codeUnits[i]) return false; + } + return true; +} + +int _readUint32BigEndian(Uint8List bytes, int offset) { + return (bytes[offset] << 24) | + (bytes[offset + 1] << 16) | + (bytes[offset + 2] << 8) | + bytes[offset + 3]; +} + +int _readUint32LittleEndian(Uint8List bytes, int offset) { + return bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24); +} + +Future _readPlatformClipboardImage() async { + return _mediaUploadPlatformChannel.invokeMethod( + _readClipboardImageMethod, + ); +} + +Future _generatePickedVideoPoster(String filePath) { + return _mediaUploadPlatformChannel.invokeMethod( + _generateVideoPosterMethod, + filePath, + ); +} + +Future _transcodePickedVideoToMp4(String filePath) async { + final result = await _mediaUploadPlatformChannel.invokeMethod( + _transcodeVideoToMp4Method, + filePath, + ); + if (result == null || result.isEmpty) { + throw Exception('Failed to convert video to MP4.'); + } + if (defaultTargetPlatform == TargetPlatform.android) { + final source = File(result); + final destination = File( + '$result.faststart-${DateTime.now().microsecondsSinceEpoch}.mp4', + ); + try { + await rewriteMp4ForFastStart(source, destination); + await source.delete(); + return destination.path; + } catch (_) { + try { + await destination.delete(); + } on FileSystemException { + // Best-effort cleanup; preserve the original platform error. + } + rethrow; + } + } + return result; +} + +Future _packagePickedVoiceNoteForUpload(String filePath) async { + final result = await _mediaUploadPlatformChannel.invokeMethod( + _packageVoiceNoteForUploadMethod, + filePath, + ); + if (result == null || result.isEmpty) { + throw Exception('Failed to prepare voice note for upload.'); + } + if (defaultTargetPlatform == TargetPlatform.android) { + final source = File(result); + final destination = File( + '$result.faststart-${DateTime.now().microsecondsSinceEpoch}.mp4', + ); + try { + await rewriteMp4ForFastStart(source, destination); + await source.delete(); + return destination.path; + } catch (_) { + for (final file in [destination, source]) { + try { + if (await file.exists()) await file.delete(); + } on FileSystemException { + // Best-effort cleanup; preserve the original platform error. + } + } + rethrow; + } + } + return result; +} + +Future _transcodePickedImageToJpeg(Uint8List bytes) async { + return _invokeRequiredPlatformBytesMethod( + _transcodeImageToJpegMethod, + arguments: bytes, + errorMessage: 'failed to convert image for upload', + ); +} + +Future _sanitizePickedImageBytes( + Uint8List bytes, + String mimeType, +) async { + return _invokeRequiredPlatformBytesMethod( + _sanitizeImageForUploadMethod, + arguments: {'bytes': bytes, 'mimeType': mimeType}, + errorMessage: 'failed to sanitize image for upload', + ); +} + +Future _invokeRequiredPlatformBytesMethod( + String method, { + Object? arguments, + required String errorMessage, +}) async { + final result = await _mediaUploadPlatformChannel.invokeMethod( + method, + arguments, + ); + if (result == null || result.isEmpty) { + throw Exception(errorMessage); + } + return result; +} diff --git a/mobile/lib/shared/widgets/concentric_sheet_surface.dart b/mobile/lib/shared/widgets/concentric_sheet_surface.dart index 2a8878acdf8..cfcf9b015ae 100644 --- a/mobile/lib/shared/widgets/concentric_sheet_surface.dart +++ b/mobile/lib/shared/widgets/concentric_sheet_surface.dart @@ -25,6 +25,9 @@ class ConcentricSheetSurface extends HookWidget { bottom: Grid.xxs, ), this.providesSheetSurface = true, + this.usesGlass = false, + this.minimumRadius = Radii.dialog, + this.contentClipRadius, super.key, }); @@ -35,6 +38,9 @@ class ConcentricSheetSurface extends HookWidget { final ConcentricSurfaceCorners corners; final EdgeInsetsGeometry padding; final bool providesSheetSurface; + final bool usesGlass; + final double minimumRadius; + final double? contentClipRadius; static bool providesSurfaceOf(BuildContext context) => context @@ -82,6 +88,23 @@ class ConcentricSheetSurface extends HookWidget { } } + Future _updateNativeSurfaceGeometry({ + required MethodChannel channel, + required double minimumRadius, + required Brightness brightness, + }) async { + try { + await channel.invokeMethod('updateGeometry', { + 'minimumRadius': minimumRadius, + 'brightness': brightness.name, + }); + } on MissingPluginException { + // The platform view may have been disposed while its shape was changing. + } on PlatformException { + // The native surface is optional; retain its last successful geometry. + } + } + @override Widget build(BuildContext context) { final shouldCheckNativeSurface = @@ -94,6 +117,7 @@ class ConcentricSheetSurface extends HookWidget { ); final nativeSurfaceSupported = useFuture(supportFuture).data ?? false; final surfaceColor = color ?? context.colors.surface; + final brightness = context.theme.brightness; final nativeSurfaceChannel = useState(null); useEffect( () { @@ -120,12 +144,37 @@ class ConcentricSheetSurface extends HookWidget { backdropColor, ], ); + useEffect( + () { + final channel = nativeSurfaceChannel.value; + if (!shouldCheckNativeSurface || + !nativeSurfaceSupported || + channel == null) { + return null; + } + unawaited( + _updateNativeSurfaceGeometry( + channel: channel, + minimumRadius: minimumRadius, + brightness: brightness, + ), + ); + return null; + }, + [ + shouldCheckNativeSurface, + nativeSurfaceSupported, + nativeSurfaceChannel.value, + minimumRadius, + brightness, + ], + ); if (!shouldCheckNativeSurface) { return _ConcentricSheetSurfaceScope(providesSurface: false, child: child); } - final fallbackBorderRadius = _borderRadius(Radii.dialog); + final fallbackBorderRadius = _borderRadius(minimumRadius); return Padding( padding: padding, @@ -134,22 +183,26 @@ class ConcentricSheetSurface extends HookWidget { if (nativeSurfaceSupported) Positioned.fill( child: ExcludeSemantics( - child: UiKitView( - viewType: 'buzz/concentric_sheet_surface', - hitTestBehavior: PlatformViewHitTestBehavior.transparent, - onPlatformViewCreated: (viewId) { - nativeSurfaceChannel.value = MethodChannel( - 'buzz/concentric_sheet_surface/$viewId', - ); - }, - creationParams: { - 'color': surfaceColor.toARGB32(), - if (backdropColor case final color?) - 'backdropColor': color.toARGB32(), - 'minimumRadius': Radii.dialog, - 'corners': corners.name, - }, - creationParamsCodec: const StandardMessageCodec(), + child: IgnorePointer( + child: UiKitView( + viewType: 'buzz/concentric_sheet_surface', + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + onPlatformViewCreated: (viewId) { + nativeSurfaceChannel.value = MethodChannel( + 'buzz/concentric_sheet_surface/$viewId', + ); + }, + creationParams: { + 'color': surfaceColor.toARGB32(), + if (backdropColor case final color?) + 'backdropColor': color.toARGB32(), + 'minimumRadius': minimumRadius, + 'corners': corners.name, + 'usesGlass': usesGlass, + 'brightness': brightness.name, + }, + creationParamsCodec: const StandardMessageCodec(), + ), ), ), ) @@ -169,7 +222,10 @@ class ConcentricSheetSurface extends HookWidget { // circular clip still lets scrolling rows show through the native // corner cutouts. borderRadius: _borderRadius( - nativeSurfaceSupported ? _nativeContentClipRadius : Radii.dialog, + contentClipRadius ?? + (nativeSurfaceSupported + ? _nativeContentClipRadius + : minimumRadius), ), clipBehavior: Clip.antiAlias, child: providesSheetSurface diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 4526e1f3367..8b190dcb03c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" bech32: dependency: transitive description: @@ -792,6 +800,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.11.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: e60aa97b233ddea025e13dfac59195207f528fa5e9e4a4a49a8c0766701e5fe6 + url: "https://pub.dev" + source: hosted + version: "0.10.6" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" leak_tracker: dependency: transitive description: @@ -1152,6 +1184,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" riverpod: dependency: transitive description: @@ -1373,6 +1469,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" term_glyph: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0abcc8a77e1..a5166bfa075 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: google_mlkit_selfie_segmentation: ^0.10.1 photo_manager: ^3.11.0 video_player: ^2.10.1 + just_audio: ^0.10.6 + record: ^6.2.1 package_info_plus: ^10.0.0 app_badge_plus: ^1.2.10 app_links: ^6.4.0 diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 21fa3d124ec..2bd6face240 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -12410,7 +12410,9 @@ void main() { final composer = find.byKey(const ValueKey('composer-surface')); // Clear the gesture arena's touch slop so this represents a deliberate // tail-detaching drag rather than a long-press hold with small motion. - await tester.drag(list, const Offset(0, 48)); + // The compact composer now rests lower, so use enough drag distance to + // keep the final reply beneath its top edge for this covered-tail case. + await tester.drag(list, const Offset(0, 56)); await tester.pumpAndSettle(); expect( tester.getBottomLeft(latest).dy, diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index e7170c2afe1..23b19d95577 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -18,6 +18,9 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/photo_library.dart'; +import 'package:buzz/features/channels/voice_note_play_pause_icon.dart'; +import 'package:buzz/features/channels/voice_note_recording.dart'; +import 'package:buzz/features/channels/voice_note_waveform.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; @@ -178,18 +181,30 @@ Widget _buildComposeBar({ bool? supportsShowingSystemContextMenu, bool? disableAnimations, TextScaler? textScaler, + EdgeInsets? viewPadding, List customEmoji = const [], RelayConfigNotifier Function()? relayConfig, PhotoLibrary photoLibrary = const _EmptyPhotoLibrary(), VoidCallback? onFocusRequested, FocusNode? focusNode, ValueChanged? onFocusRestorerChanged, + AppLifecycleNotifier Function()? appLifecycle, String composeBarKey = 'compose-bar', + VoiceNoteRecorder Function()? voiceNoteRecorderFactory, + VoiceNotePlayerController Function()? voiceNotePlayerFactory, }) { return ProviderScope( overrides: [ customEmojiListProvider.overrideWithValue(customEmoji), mediaUploadServiceProvider.overrideWithValue(uploadService), + if (voiceNoteRecorderFactory != null) + voiceNoteRecorderFactoryProvider.overrideWithValue( + voiceNoteRecorderFactory, + ), + if (voiceNotePlayerFactory != null) + voiceNotePlayerFactoryProvider.overrideWithValue( + voiceNotePlayerFactory, + ), photoLibraryProvider.overrideWithValue(photoLibrary), currentPubkeyProvider.overrideWith((ref) => currentPubkey), channelMembersProvider( @@ -203,12 +218,14 @@ Widget _buildComposeBar({ relayConfigProvider.overrideWith( relayConfig ?? _FakeRelayConfigNotifier.new, ), + if (appLifecycle != null) appLifecycleProvider.overrideWith(appLifecycle), savedPrefsProvider.overrideWithValue(_testPrefs), channelsProvider.overrideWith( () => _FakeChannelsNotifier(channels, cachedMembers: cachedMembers), ), ], child: MaterialApp( + navigatorObservers: [voiceNoteRouteObserver], theme: AppTheme.light(), builder: supportsShowingSystemContextMenu == null && @@ -231,13 +248,24 @@ Widget _buildComposeBar({ body: SafeArea( child: Align( alignment: Alignment.bottomCenter, - child: ComposeBar( - key: ValueKey(composeBarKey), - channelId: 'channel-1', - focusNode: focusNode, - onFocusRestorerChanged: onFocusRestorerChanged, - onFocusRequested: onFocusRequested, - onSend: onSend, + child: Builder( + builder: (context) { + final composeBar = ComposeBar( + key: ValueKey(composeBarKey), + channelId: 'channel-1', + focusNode: focusNode, + onFocusRestorerChanged: onFocusRestorerChanged, + onFocusRequested: onFocusRequested, + onSend: onSend, + ); + if (viewPadding == null) return composeBar; + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(viewPadding: viewPadding), + child: composeBar, + ); + }, ), ), ), @@ -302,6 +330,13 @@ class _FakeRelayConfigNotifier extends RelayConfigNotifier { ); } +class _FakeAppLifecycleNotifier extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; + + void setLifecycle(AppLifecycleState value) => state = value; +} + class _EmptyPhotoLibrary implements PhotoLibrary { const _EmptyPhotoLibrary(); @@ -347,6 +382,155 @@ class _FakeVideoUploadService extends MediaUploadService { } } +class _FakeVoiceNoteUploadService extends MediaUploadService { + _FakeVoiceNoteUploadService() + : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + VoiceNoteRecording? uploadedRecording; + Completer? pendingVoiceNoteUpload; + XFile? file; + + @override + Future pickAttachmentFile() async => file; + + @override + Future uploadVoiceNote( + XFile voiceNote, { + required Duration duration, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + uploadedRecording = VoiceNoteRecording( + file: voiceNote, + duration: duration, + waveform: const [], + ); + final pending = pendingVoiceNoteUpload; + if (pending != null) return pending.future; + onProgress?.call(1); + return BlobDescriptor( + url: 'https://relay.example/media/voice-note.mp4', + sha256: + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + size: 4, + type: 'video/mp4', + uploaded: 1, + duration: duration.inMilliseconds / 1000, + filename: voiceNote.name.replaceFirst('.m4a', '.mp4'), + ); + } +} + +class _FakeVoiceNoteRecorder implements VoiceNoteRecorder { + _FakeVoiceNoteRecorder({this.path = '/tmp/voice-note-test.m4a'}); + + final String path; + final StreamController _levels = StreamController.broadcast( + sync: true, + ); + bool started = false; + bool cancelled = false; + bool disposed = false; + + @override + Stream get levels => _levels.stream; + + void emit(double level) => _levels.add(level); + + @override + Future start() async { + started = true; + _levels.add(0.72); + } + + @override + Future stop() async => VoiceNoteRecording( + file: XFile(path, mimeType: 'audio/mp4'), + duration: const Duration(seconds: 3), + waveform: const [0.2, 0.7, 0.4, 0.9], + ); + + @override + Future cancel() async { + cancelled = true; + } + + @override + Future dispose() async { + disposed = true; + await _levels.close(); + } +} + +class _DelayedVoiceNoteRecorder extends _FakeVoiceNoteRecorder { + final Completer startup = Completer(); + bool stopCalled = false; + + @override + Future start() async { + await startup.future; + if (!cancelled) await super.start(); + } + + @override + Future stop() async { + stopCalled = true; + return super.stop(); + } +} + +class _FakeVoiceNotePlayer extends VoiceNotePlayerController { + VoiceNotePlaybackState _state = const VoiceNotePlaybackState( + duration: Duration(seconds: 3), + ); + + @override + VoiceNotePlaybackState get state => _state; + double speed = 1; + + @override + Future loadLocal( + String path, { + required Duration fallbackDuration, + }) async { + _state = VoiceNotePlaybackState(duration: fallbackDuration); + notifyListeners(); + } + + @override + Future loadRemote( + String url, { + required Map Function() headers, + required Duration fallbackDuration, + }) => loadLocal(url, fallbackDuration: fallbackDuration); + + @override + Future pause() async { + _state = _state.copyWith(isPlaying: false); + notifyListeners(); + } + + @override + Future seek(Duration position) async { + _state = _state.copyWith(position: position); + notifyListeners(); + } + + @override + Future setSpeed(double value) async => speed = value; + + @override + Future toggle() async { + _state = _state.copyWith(isPlaying: !_state.isPlaying); + notifyListeners(); + } +} + class _FakePhotoLibrary implements PhotoLibrary { final List photos; @@ -527,6 +711,13 @@ void main() { final compactWidth = tester .getSize(find.byKey(const ValueKey('composer-width-transition'))) .width; + final compactPosition = tester.widget( + find.byKey(const ValueKey('composer-position-transition')), + ); + expect( + compactPosition.transform.getTranslation().y, + Grid.twelve + Grid.quarter, + ); await _expandComposer(tester); @@ -541,6 +732,10 @@ void main() { .decoration as BoxDecoration; expect(compactWidth, closeTo(expandedWidth * 0.85, 0.5)); + final expandedPosition = tester.widget( + find.byKey(const ValueKey('composer-position-transition')), + ); + expect(expandedPosition.transform.getTranslation().y, 0); expect( expandedDecoration.borderRadius, BorderRadius.circular(Radii.dialog), @@ -1677,6 +1872,84 @@ void main() { } }); + testWidgets( + 'native Voice note waits for the keyboard while the popover dismisses', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + final recorder = _FakeVoiceNoteRecorder(); + _setMockNativeAttachmentPopoverHandler((call) async { + return switch (call.method) { + 'isSupported' || 'present' => true, + 'dismiss' => null, + _ => null, + }; + }); + + try { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + voiceNoteRecorderFactory: () => recorder, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + await _sendNativeAttachmentPopoverCall(tester, 'recordVoiceNote'); + await tester.pump(); + + expect( + find.byKey(const ValueKey('voice-note-recorder')), + findsNothing, + ); + expect(recorder.started, isFalse); + final initialPosition = tester.widget( + find.byKey(const ValueKey('composer-position-transition')), + ); + expect(initialPosition.transform.getTranslation().y, 0); + + await tester.pump(const Duration(milliseconds: 100)); + + final closingKeyboardPosition = tester.widget( + find.byKey(const ValueKey('composer-position-transition')), + ); + expect( + closingKeyboardPosition.transform.getTranslation().y, + greaterThan(0), + ); + expect( + closingKeyboardPosition.transform.getTranslation().y, + lessThan(Grid.twelve + Grid.quarter), + ); + expect(recorder.started, isFalse); + + tester.view.viewInsets = FakeViewPadding.zero; + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('voice-note-recorder')), + findsOneWidget, + ); + expect(recorder.started, isTrue); + } finally { + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpWidget(const SizedBox.shrink()); + tester.view.reset(); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + testWidgets('leaving a focused composer dismisses the native keyboard', ( tester, ) async { @@ -2402,7 +2675,13 @@ void main() { final menu = find.byKey(const ValueKey('attachment-menu')); final surface = find.byKey(const ValueKey('attachment-surface-popover')); final rows = [ - for (final label in ['camera', 'photos', 'video', 'files']) + for (final label in [ + 'camera', + 'photos', + 'video', + 'voice note', + 'files', + ]) find.byKey(ValueKey('attachment-menu-item-$label')), ]; final menuRect = tester.getRect(menu); @@ -2416,23 +2695,41 @@ void main() { material.shadowColor, appPopoverShadowColor(tester.element(surface)), ); - expect(menuRect.size, const Size(216, 264)); + expect(menuRect.size, const Size(216, 324)); for (final row in rows) { expect(tester.getSize(row).height, 52); expect(tester.getRect(row).left - menuRect.left, Grid.xs); expect(menuRect.right - tester.getRect(row).right, Grid.xs); } - for (final label in ['Camera', 'Photos', 'Video', 'Files']) { + for (final label in [ + 'Camera', + 'Photos', + 'Video', + 'Voice note', + 'Files', + ]) { final text = tester.widget(find.text(label)); expect(text.style?.fontSize, 20); expect(text.style?.fontFamily, 'Inter'); } final icons = [ - for (final label in ['camera', 'photos', 'video', 'files']) + for (final label in [ + 'camera', + 'photos', + 'video', + 'voice note', + 'files', + ]) find.byKey(ValueKey('attachment-menu-icon-$label')), ]; final labels = [ - for (final label in ['camera', 'photos', 'video', 'files']) + for (final label in [ + 'camera', + 'photos', + 'video', + 'voice note', + 'files', + ]) find.byKey(ValueKey('attachment-menu-label-$label')), ]; for (final icon in icons) { @@ -2527,7 +2824,13 @@ void main() { final menu = find.byKey(const ValueKey('attachment-menu')); final rows = [ - for (final label in ['camera', 'photos', 'video', 'files']) + for (final label in [ + 'camera', + 'photos', + 'video', + 'voice note', + 'files', + ]) find.byKey(ValueKey('attachment-menu-item-$label')), ]; final scrollView = tester.widget( @@ -4269,6 +4572,701 @@ void main() { expect(uploadService.uploadedVideo, same(pickedVideo)); expect(sentContent, '\n![video](https://relay.example/media/test.mp4)'); }); + + testWidgets('records, previews, uploads, and sends a voice note', ( + tester, + ) async { + final recorder = _FakeVoiceNoteRecorder(); + final uploadService = _FakeVoiceNoteUploadService(); + String? sentContent; + List> sentMediaTags = const []; + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + viewPadding: const EdgeInsets.only(bottom: 34), + voiceNoteRecorderFactory: () => recorder, + voiceNotePlayerFactory: _FakeVoiceNotePlayer.new, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + sentMediaTags = mediaTags; + }, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), 'Keep this draft'); + await _openAttachmentMenu(tester); + expect(find.text('Voice note'), findsOneWidget); + await tester.tap(find.text('Voice note')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final transitioningDecoration = + tester + .widget( + find.byKey(const ValueKey('composer-surface')), + ) + .decoration + as BoxDecoration; + final transitioningRadius = + (transitioningDecoration.borderRadius! as BorderRadius).topLeft.x; + expect(transitioningRadius, greaterThan(Radii.dialog)); + expect(transitioningRadius, lessThan(Radii.full)); + await tester.pumpAndSettle(); + + expect(recorder.started, isTrue); + expect(find.byKey(const ValueKey('voice-note-recorder')), findsOneWidget); + expect( + find.byKey(const ValueKey('voice-note-recorder-close')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('voice-note-recorder-stop')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('voice-note-waveform')), findsOneWidget); + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(LucideIcons.arrowUp), findsNothing); + expect(find.byTooltip('Add attachment'), findsNothing); + + final recordingWidth = tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width; + expect(recordingWidth, closeTo(744, 0.5)); + final recordingOuterGutter = tester.widget( + find.byKey(const ValueKey('composer-recording-outer-gutter')), + ); + expect( + recordingOuterGutter.padding, + const EdgeInsets.symmetric(horizontal: 16), + ); + final recordingPosition = tester.widget( + find.byKey(const ValueKey('composer-position-transition')), + ); + expect( + recordingPosition.transform.getTranslation().y, + Grid.twelve + Grid.quarter, + ); + final composerDecoration = + tester + .widget( + find.byKey(const ValueKey('composer-surface')), + ) + .decoration + as BoxDecoration; + final recordingComposer = tester.widget( + find.byKey(const ValueKey('composer-surface')), + ); + expect( + tester.widget(find.byKey(const ValueKey('voice-note-recorder'))), + isA(), + ); + expect( + composerDecoration.borderRadius, + BorderRadius.circular(Radii.full), + ); + expect(recordingComposer.padding, const EdgeInsets.all(Grid.twelve)); + + final initialWaveform = tester.widget( + find.byType(VoiceNoteWaveform), + ); + expect(initialWaveform.progress, 1); + expect(initialWaveform.samples.first, 0); + expect(initialWaveform.samples.last, 0.72); + + for (var index = 0; index < 130; index += 1) { + recorder.emit(index == 129 ? 0.99 : 0.1); + } + await tester.pump(const Duration(milliseconds: 100)); + final waveform = tester.widget( + find.byType(VoiceNoteWaveform), + ); + expect(waveform.samples.last, 0.99); + final updatedRecordingPosition = tester.widget( + find.byKey(const ValueKey('composer-position-transition')), + ); + expect( + updatedRecordingPosition.transform.getTranslation().y, + recordingPosition.transform.getTranslation().y, + ); + + await tester.tap(find.byKey(const ValueKey('voice-note-recorder-stop'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('composer-voice-note-remove')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('voice-note-playback-rate')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('voice-note-play-pause-icon-play')), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('voice-note-play-pause'))); + await tester.pump(); + expect( + tester + .widget(find.byType(VoiceNotePlayPauseIcon)) + .isPlaying, + isTrue, + ); + await tester.pump(const Duration(milliseconds: 160)); + expect( + find.byKey(const ValueKey('voice-note-play-pause-icon-pause')), + findsOneWidget, + ); + final composerVoiceNote = tester.widget( + find.byKey( + const ValueKey('voice-note-attachment:/tmp/voice-note-test.m4a'), + ), + ); + final voiceNoteDecoration = + composerVoiceNote.decoration! as BoxDecoration; + expect( + voiceNoteDecoration.borderRadius, + BorderRadius.circular(Radii.dialog + Grid.quarter - Grid.twelve), + ); + final previewComposer = tester.widget( + find.byKey(const ValueKey('composer-surface')), + ); + expect(previewComposer.padding, const EdgeInsets.all(Grid.twelve)); + final previewComposerDecoration = + previewComposer.decoration! as BoxDecoration; + final previewOuterRadius = + (previewComposerDecoration.borderRadius! as BorderRadius).topLeft.x; + final previewInnerRadius = + (voiceNoteDecoration.borderRadius! as BorderRadius).topLeft.x; + expect(previewOuterRadius - previewInnerRadius, Grid.twelve); + final waveformRect = tester.getRect(find.byType(VoiceNoteWaveform)); + final playRect = tester.getRect( + find.byKey(const ValueKey('voice-note-play-pause')), + ); + final removeRect = tester.getRect( + find.byKey(const ValueKey('composer-voice-note-remove')), + ); + expect(waveformRect.left - playRect.right, Grid.xxs); + expect(removeRect.left - waveformRect.right, Grid.xxs); + expect( + tester + .widget( + find.byKey(const ValueKey('composer-width-transition')), + ) + .widthFactor, + 1, + ); + expect( + tester + .getSize( + find.byKey( + const ValueKey( + 'voice-note-attachment:/tmp/voice-note-test.m4a', + ), + ), + ) + .width, + greaterThan(320), + ); + expect( + tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width, + recordingWidth, + ); + final sendButton = find + .ancestor( + of: find.byIcon(LucideIcons.arrowUp), + matching: find.byType(IconButton), + ) + .hitTestable(); + await tester.tap(sendButton); + await tester.pumpAndSettle(); + + expect( + uploadService.uploadedRecording?.duration, + const Duration(seconds: 3), + ); + expect( + sentContent, + 'Keep this draft\n' + '[voice-note-test.mp4](https://relay.example/media/voice-note.mp4)', + ); + expect( + sentMediaTags.single, + containsAll([ + 'm video/mp4', + 'duration 3.0', + 'filename voice-note-test.mp4', + ]), + ); + }); + + testWidgets('community switch cancels pending voice-note startup', ( + tester, + ) async { + final signer = nostr.Keys.generate(); + final recorder = _DelayedVoiceNoteRecorder(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayConfig: () => _SwitchableRelayConfigNotifier( + RelayConfig(baseUrl: 'https://relay.example', nsec: signer.nsec), + ), + voiceNoteRecorderFactory: () => recorder, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('voice-note-recorder')), findsOneWidget); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://other.example', nsec: signer.nsec); + expect( + container.read(relayConfigProvider).baseUrl, + 'https://other.example', + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('voice-note-recorder')), findsNothing); + expect(recorder.cancelled, isTrue); + recorder.startup.complete(); + await tester.pumpAndSettle(); + expect(recorder.started, isFalse); + expect(recorder.disposed, isTrue); + }); + + testWidgets('disables Stop while voice-note startup is pending', ( + tester, + ) async { + final recorder = _DelayedVoiceNoteRecorder(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _FakeVoiceNoteUploadService(), + voiceNoteRecorderFactory: () => recorder, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + + final stop = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('voice-note-recorder-stop')), + matching: find.byType(IconButton), + ), + ); + expect(stop.onPressed, isNull); + await tester.tap(find.byKey(const ValueKey('voice-note-recorder-stop'))); + expect(recorder.stopCalled, isFalse); + + recorder.startup.complete(); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('voice-note-recorder-stop')), + matching: find.byType(IconButton), + ), + ) + .onPressed, + isNotNull, + ); + }); + + testWidgets( + 'permission startup survives transient inactive and resumed states', + (tester) async { + final recorder = _DelayedVoiceNoteRecorder(); + final lifecycle = _FakeAppLifecycleNotifier(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _FakeVoiceNoteUploadService(), + voiceNoteRecorderFactory: () => recorder, + appLifecycle: () => lifecycle, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + + lifecycle.setLifecycle(AppLifecycleState.inactive); + lifecycle.setLifecycle(AppLifecycleState.resumed); + recorder.startup.complete(); + await tester.pumpAndSettle(); + + expect(recorder.started, isTrue); + expect(recorder.cancelled, isFalse); + expect( + find.byKey(const ValueKey('voice-note-recorder')), + findsOneWidget, + ); + }, + ); + + testWidgets('hidden remains a transient lifecycle state', (tester) async { + final recorder = _FakeVoiceNoteRecorder(); + final lifecycle = _FakeAppLifecycleNotifier(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _FakeVoiceNoteUploadService(), + voiceNoteRecorderFactory: () => recorder, + appLifecycle: () => lifecycle, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + + lifecycle.setLifecycle(AppLifecycleState.hidden); + await tester.pump(); + + expect(recorder.cancelled, isFalse); + expect(find.byKey(const ValueKey('voice-note-recorder')), findsOneWidget); + }); + + testWidgets( + 'paused cancels startup without waiting for its permission result', + (tester) async { + final recorder = _DelayedVoiceNoteRecorder(); + final lifecycle = _FakeAppLifecycleNotifier(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _FakeVoiceNoteUploadService(), + voiceNoteRecorderFactory: () => recorder, + appLifecycle: () => lifecycle, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + + lifecycle.setLifecycle(AppLifecycleState.paused); + + expect(recorder.cancelled, isTrue); + recorder.startup.complete(); + await tester.pumpAndSettle(); + expect(recorder.started, isFalse); + expect(recorder.disposed, isTrue); + }, + ); + + for (final lifecycleState in [ + AppLifecycleState.paused, + AppLifecycleState.detached, + ]) { + testWidgets( + '${lifecycleState.name} starts cancellation without a rendered frame', + (tester) async { + final recorder = _FakeVoiceNoteRecorder(); + final lifecycle = _FakeAppLifecycleNotifier(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _FakeVoiceNoteUploadService(), + voiceNoteRecorderFactory: () => recorder, + appLifecycle: () => lifecycle, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + + lifecycle.setLifecycle(lifecycleState); + + expect(recorder.cancelled, isTrue); + await tester.pumpAndSettle(); + expect(recorder.disposed, isTrue); + expect( + find.byKey(const ValueKey('voice-note-recorder')), + findsNothing, + ); + }, + ); + } + + testWidgets('voice note rejects an existing attachment like desktop', ( + tester, + ) async { + final recorder = _FakeVoiceNoteRecorder(); + final uploadService = _FakeVoiceNoteUploadService() + ..file = XFile('/tmp/extra.txt'); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + voiceNoteRecorderFactory: () => recorder, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Files')); + await tester.pumpAndSettle(); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + + expect( + find.text('A voice note must be the only attachment.'), + findsOneWidget, + ); + expect(find.text('extra.txt'), findsOneWidget); + expect(recorder.started, isFalse); + expect(find.byKey(const ValueKey('voice-note-recorder')), findsNothing); + }); + + testWidgets('voice note stays the only attachment like desktop', ( + tester, + ) async { + final recorder = _FakeVoiceNoteRecorder(); + final uploadService = _FakeVoiceNoteUploadService() + ..file = XFile('/tmp/extra.txt'); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + voiceNoteRecorderFactory: () => recorder, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('voice-note-recorder-stop'))); + await tester.pumpAndSettle(); + + await _openAttachmentMenu(tester); + await tester.tap(find.text('Files')); + await tester.pumpAndSettle(); + + expect( + find.text('A voice note must be the only attachment.'), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('voice-note-attachment:/tmp/voice-note-test.m4a'), + ), + findsOneWidget, + ); + expect(find.text('extra.txt'), findsNothing); + }); + + testWidgets( + 'starting a voice note prevents a failed upload from restoring its draft', + (tester) async { + final uploadService = _FakeVoiceNoteUploadService() + ..pendingVoiceNoteUpload = Completer(); + final recorders = [ + _FakeVoiceNoteRecorder(path: '/tmp/first-voice-note.m4a'), + _FakeVoiceNoteRecorder(path: '/tmp/second-voice-note.m4a'), + ]; + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + voiceNoteRecorderFactory: () => recorders.removeAt(0), + voiceNotePlayerFactory: _FakeVoiceNotePlayer.new, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('voice-note-recorder-stop')), + ); + await tester.pumpAndSettle(); + final sendButton = find + .ancestor( + of: find.byIcon(LucideIcons.arrowUp), + matching: find.byType(IconButton), + ) + .hitTestable(); + await tester.tap(sendButton); + await tester.pump(); + expect( + uploadService.uploadedRecording?.file.path, + '/tmp/first-voice-note.m4a', + ); + + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('voice-note-recorder')), + findsOneWidget, + ); + + uploadService.pendingVoiceNoteUpload!.completeError( + Exception('upload failed'), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('voice-note-recorder')), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('voice-note-attachment:/tmp/first-voice-note.m4a'), + ), + findsNothing, + ); + await tester.tap( + find.byKey(const ValueKey('voice-note-recorder-stop')), + ); + await tester.pumpAndSettle(); + expect( + find.byKey( + const ValueKey('voice-note-attachment:/tmp/second-voice-note.m4a'), + ), + findsOneWidget, + ); + }, + ); + + testWidgets('covering the route cancels its active voice-note recorder', ( + tester, + ) async { + final recorder = _FakeVoiceNoteRecorder(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _FakeVoiceNoteUploadService(), + voiceNoteRecorderFactory: () => recorder, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + + final context = tester.element( + find.byKey(const ValueKey('voice-note-recorder')), + ); + unawaited( + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const Scaffold())), + ); + await tester.pumpAndSettle(); + + expect(recorder.cancelled, isTrue); + expect(find.byKey(const ValueKey('voice-note-recorder')), findsNothing); + }); + + testWidgets('discarding an inline voice note restores the composer', ( + tester, + ) async { + final recorder = _FakeVoiceNoteRecorder(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _FakeVoiceNoteUploadService(), + voiceNoteRecorderFactory: () => recorder, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('voice-note-recorder-close'))); + await tester.pump(); + + final contentMorph = find.byKey(const ValueKey('composer-content-morph')); + expect( + find.descendant( + of: contentMorph, + matching: find.byKey(const ValueKey('composer-voice-note-content')), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: contentMorph, + matching: find.byKey(const ValueKey('composer-standard-content')), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: contentMorph, + matching: find.byType(SizeTransition), + ), + findsWidgets, + ); + expect( + find.descendant( + of: contentMorph, + matching: find.byType(SlideTransition), + ), + findsNothing, + ); + expect( + tester.widget(contentMorph).duration, + const Duration(milliseconds: 140), + ); + await tester.pumpAndSettle(); + + expect(recorder.cancelled, isTrue); + expect(find.byKey(const ValueKey('voice-note-recorder')), findsNothing); + expect(find.byTooltip('Add attachment').hitTestable(), findsOneWidget); + expect( + find.byKey(const ValueKey('composer-voice-note-remove')), + findsNothing, + ); + }); + + testWidgets('removing a voice note restores keyboard focus', ( + tester, + ) async { + final recorder = _FakeVoiceNoteRecorder(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _FakeVoiceNoteUploadService(), + voiceNoteRecorderFactory: () => recorder, + focusNode: focusNode, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + + await _openAttachmentMenu(tester); + await tester.tap(find.text('Voice note')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('voice-note-recorder-stop'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('composer-voice-note-remove')), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Message\u2026')); + await tester.pump(); + await tester.pump(); + + expect(find.byType(TextField), findsOneWidget); + expect(focusNode.hasFocus, isTrue); + }); }); group('findTrigger', () { diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index c4d624b22d7..aaf6a5973b1 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1,19 +1,29 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/misc.dart'; +import 'package:http/http.dart' as http; +import 'package:just_audio/just_audio.dart' as audio; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/features/channels/voice_note_attachment.dart'; +import 'package:buzz/features/channels/voice_note_waveform.dart'; +import 'package:buzz/features/channels/voice_note_recording.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'package:buzz/shared/emoji/emoji_only.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; Widget _testable( Widget child, { @@ -47,6 +57,171 @@ void _setSurfaceSize(WidgetTester tester, Size size) { tester.view.physicalSize = size; } +class _FakeVoiceNotePlayer extends VoiceNotePlayerController { + VoiceNotePlaybackState _state = const VoiceNotePlaybackState(); + + @override + VoiceNotePlaybackState get state => _state; + + double speed = 1; + + @override + Future loadLocal( + String path, { + required Duration fallbackDuration, + }) async { + _state = VoiceNotePlaybackState(duration: fallbackDuration); + notifyListeners(); + } + + @override + Future loadRemote( + String url, { + required Map Function() headers, + required Duration fallbackDuration, + }) => loadLocal(url, fallbackDuration: fallbackDuration); + + @override + Future pause() async { + _state = _state.copyWith(isPlaying: false); + notifyListeners(); + } + + @override + Future seek(Duration position) async { + _state = _state.copyWith(position: position); + notifyListeners(); + } + + @override + Future setSpeed(double value) async => speed = value; + + @override + Future toggle() async { + _state = _state.copyWith(isPlaying: !_state.isPlaying); + notifyListeners(); + } +} + +class _LoadingVoiceNotePlayer extends _FakeVoiceNotePlayer { + @override + VoiceNotePlaybackState get state => + const VoiceNotePlaybackState(isLoading: true, canCancelLoading: true); + + @override + Future loadRemote( + String url, { + required Map Function() headers, + required Duration fallbackDuration, + }) async {} +} + +class _ToggleTrackingLoadingVoiceNotePlayer extends _LoadingVoiceNotePlayer { + int toggleCount = 0; + + @override + Future toggle() async { + toggleCount += 1; + } +} + +class _BufferingVoiceNotePlayer extends _FakeVoiceNotePlayer { + int toggleCount = 0; + + @override + VoiceNotePlaybackState get state => const VoiceNotePlaybackState( + duration: Duration(seconds: 3), + isPlaying: true, + isLoading: true, + canCancelLoading: true, + ); + + @override + Future loadRemote( + String url, { + required Map Function() headers, + required Duration fallbackDuration, + }) async {} + + @override + Future toggle() async { + toggleCount += 1; + } +} + +class _HeldAudioPlayerBackend implements VoiceNoteAudioPlayerBackend { + final positions = const Stream.empty(); + final durations = const Stream.empty(); + final states = const Stream.empty(); + final pathLoad = Completer(); + + @override + Stream get positionStream => positions; + + @override + Stream get durationStream => durations; + + @override + Stream get playerStateStream => states; + + @override + bool get playing => false; + + @override + Future setFilePath(String path) => pathLoad.future; + + @override + Future setUrl(String url, {Map? headers}) async => + null; + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future cancelPendingLoad() async {} + + @override + Future seek(Duration position) async {} + + @override + Future setSpeed(double speed) async {} + + @override + Future dispose() async {} +} + +class _NoopHttpClient extends http.BaseClient { + @override + Future send(http.BaseRequest request) { + throw UnsupportedError('Local playback must not issue HTTP requests'); + } +} + +class _RetryableVoiceNotePlayer extends _FakeVoiceNotePlayer { + _RetryableVoiceNotePlayer() { + _state = const VoiceNotePlaybackState(hasError: true); + } + + int toggleCount = 0; + + @override + Future loadRemote( + String url, { + required Map Function() headers, + required Duration fallbackDuration, + }) async {} + + @override + Future toggle() async { + toggleCount += 1; + _state = _state.copyWith(hasError: false, isPlaying: true); + notifyListeners(); + } +} + Finder _imagePreview(String imageUrl) { return find.byKey(ValueKey('message-media-image-preview:$imageUrl')); } @@ -171,6 +346,75 @@ class _TestChannelsNotifier extends ChannelsNotifier { } void main() { + test('wide voice-note waveforms distribute bars across their full width', () { + const width = 320.0; + const sampleCount = 48; + final layout = voiceNoteWaveformBarLayout( + width: width, + sampleCount: sampleCount, + ); + + expect(layout.barWidth, 3); + expect( + (layout.barWidth * sampleCount) + (layout.gap * (sampleCount - 1)), + closeTo(width, 0.001), + ); + }); + + testWidgets('voice-note waveform semantics seek within bounded steps', ( + tester, + ) async { + final progress = ValueNotifier(0.0); + addTearDown(progress.dispose); + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, _) => VoiceNoteWaveform( + samples: const [0.2, 0.8], + progress: value, + onSeek: (next) => progress.value = next, + ), + ), + ), + ); + + final semantics = tester.getSemantics( + find.bySemanticsLabel('Voice note waveform'), + ); + expect(semantics.value, '0 percent'); + semantics.owner!.performAction(semantics.id, SemanticsAction.increase); + await tester.pump(); + expect( + tester.getSemantics(find.bySemanticsLabel('Voice note waveform')).value, + '10 percent', + ); + + progress.value = 0.5; + await tester.pump(); + final middle = tester.getSemantics( + find.bySemanticsLabel('Voice note waveform'), + ); + middle.owner!.performAction(middle.id, SemanticsAction.decrease); + await tester.pump(); + expect( + tester.getSemantics(find.bySemanticsLabel('Voice note waveform')).value, + '40 percent', + ); + + progress.value = 1; + await tester.pump(); + final end = tester.getSemantics( + find.bySemanticsLabel('Voice note waveform'), + ); + end.owner!.performAction(end.id, SemanticsAction.increase); + await tester.pump(); + expect( + tester.getSemantics(find.bySemanticsLabel('Voice note waveform')).value, + '100 percent', + ); + }); + group('MessageContent', () { testWidgets('forwards text alignment to markdown rendering', ( tester, @@ -817,6 +1061,410 @@ void main() { }); group('media attachments', () { + testWidgets('uses the shared Buzz loader while a voice note loads', ( + tester, + ) async { + const url = 'https://example.com/media/loading-voice-note.mp4'; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![audio]($url)', + tags: [ + [ + 'imeta', + 'url $url', + 'm video/mp4', + 'duration 3.0', + 'filename voice-note-loading.mp4', + ], + ], + ), + overrides: [ + voiceNotePlayerFactoryProvider.overrideWithValue( + _LoadingVoiceNotePlayer.new, + ), + ], + ), + ); + await tester.pump(); + + expect(find.byType(BuzzLoadingIndicator), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + find.bySemanticsLabel('Cancel voice note loading'), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Loading voice note'), findsNothing); + }); + + testWidgets('routes repeated loading-control taps through toggle', ( + tester, + ) async { + const url = 'https://example.com/media/cancel-loading-voice-note.mp4'; + final player = _ToggleTrackingLoadingVoiceNotePlayer(); + + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![audio]($url)', + tags: [ + [ + 'imeta', + 'url $url', + 'm video/mp4', + 'duration 3.0', + 'filename voice-note-loading.mp4', + ], + ], + ), + overrides: [ + voiceNotePlayerFactoryProvider.overrideWithValue(() => player), + ], + ), + ); + await tester.pump(); + + final control = find.bySemanticsLabel('Cancel voice note loading'); + final controlSemantics = tester.getSemantics(control); + expect( + controlSemantics.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + tester.binding.performSemanticsAction( + SemanticsActionEvent( + type: SemanticsAction.tap, + viewId: tester.view.viewId, + nodeId: controlSemantics.id, + ), + ); + await tester.pump(); + + expect(player.toggleCount, 1); + }); + + testWidgets('active buffering playback keeps its pause action', ( + tester, + ) async { + const url = 'https://example.com/media/buffering-voice-note.mp4'; + final player = _BufferingVoiceNotePlayer(); + + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![audio]($url)', + tags: [ + [ + 'imeta', + 'url $url', + 'm video/mp4', + 'duration 3.0', + 'filename voice-note-buffering.mp4', + ], + ], + ), + overrides: [ + voiceNotePlayerFactoryProvider.overrideWithValue(() => player), + ], + ), + ); + await tester.pump(); + + final control = find.bySemanticsLabel('Pause voice note'); + expect(control, findsOneWidget); + expect( + tester + .getSemantics(control) + .getSemanticsData() + .hasAction(SemanticsAction.tap), + isTrue, + ); + + await tester.tap(find.byKey(const ValueKey('voice-note-play-pause'))); + await tester.pump(); + + expect(player.toggleCount, 1); + }); + + testWidgets( + 'local preview loading is non-actionable while remote loading cancels', + (tester) async { + final backend = _HeldAudioPlayerBackend(); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: _NoopHttpClient(), + player: backend, + ); + addTearDown(() { + if (!backend.pathLoad.isCompleted) backend.pathLoad.complete(null); + }); + + await tester.pumpWidget( + _testable( + const VoiceNoteAttachment.local( + path: '/tmp/local-voice-note.m4a', + duration: Duration(seconds: 3), + waveform: [], + ), + overrides: [ + voiceNotePlayerFactoryProvider.overrideWithValue(() => player), + ], + ), + ); + await tester.pump(); + + expect(backend.pathLoad.isCompleted, isFalse); + + final control = find.bySemanticsLabel('Loading voice note'); + final controlSemantics = tester.getSemantics(control); + expect(control, findsOneWidget); + expect( + controlSemantics.getSemanticsData().hasAction(SemanticsAction.tap), + isFalse, + ); + expect( + find.bySemanticsLabel('Cancel voice note loading'), + findsNothing, + ); + await tester.tap(find.byKey(const ValueKey('voice-note-play-pause'))); + await tester.pump(); + expect(player.state.canCancelLoading, isFalse); + expect(backend.pathLoad.isCompleted, isFalse); + }, + ); + + testWidgets('offers an accessible retry after a voice note fails', ( + tester, + ) async { + const url = 'https://example.com/media/retry-voice-note.mp4'; + final player = _RetryableVoiceNotePlayer(); + + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![audio]($url)', + tags: [ + [ + 'imeta', + 'url $url', + 'm video/mp4', + 'duration 3.0', + 'filename voice-note-retry.mp4', + ], + ], + ), + overrides: [ + voiceNotePlayerFactoryProvider.overrideWithValue(() => player), + ], + ), + ); + await tester.pump(); + + expect(find.text('Voice note unavailable'), findsOneWidget); + expect(find.byTooltip('Retry voice note'), findsOneWidget); + expect(find.bySemanticsLabel('Retry voice note'), findsOneWidget); + expect( + find.byKey(const ValueKey('voice-note-retry-icon')), + findsOneWidget, + ); + + await tester.tap(find.byTooltip('Retry voice note')); + await tester.pump(); + + expect(player.toggleCount, 1); + expect(find.byTooltip('Pause voice note'), findsOneWidget); + expect(find.text('Voice note unavailable'), findsNothing); + }); + + testWidgets('renders desktop packaged voice-note links as audio cards', ( + tester, + ) async { + const url = 'https://example.com/media/desktop-voice-note.mp4'; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: '[voice-note-desktop.mp4]($url)', + tags: [ + [ + 'imeta', + 'url $url', + 'm video/mp4', + 'duration 3.0', + 'filename voice-note-desktop.mp4', + ], + ], + ), + overrides: [ + voiceNotePlayerFactoryProvider.overrideWithValue( + _FakeVoiceNotePlayer.new, + ), + ], + ), + ); + await tester.pump(); + + expect( + find.byKey(const ValueKey('voice-note-attachment:$url')), + findsOneWidget, + ); + expect(find.text('voice-note-desktop.mp4'), findsNothing); + }); + + testWidgets('keeps audio-looking links without imeta as ordinary links', ( + tester, + ) async { + const url = 'https://example.com/media/not-an-attachment.mp4'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '[recording.mp4]($url)')), + ); + await tester.pump(); + + expect( + find.byKey(const ValueKey('voice-note-attachment:$url')), + findsNothing, + ); + expect(find.text('recording.mp4'), findsOneWidget); + }); + + testWidgets('renders an audio imeta attachment as a voice note card', ( + tester, + ) async { + const url = 'https://example.com/media/voice-note.mp4'; + final player = _FakeVoiceNotePlayer(); + final hapticCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![audio]($url)', + tags: [ + [ + 'imeta', + 'url $url', + 'm video/mp4', + 'duration 3.0', + 'filename voice-note-test.mp4', + ], + ], + ), + overrides: [ + voiceNotePlayerFactoryProvider.overrideWithValue(() => player), + ], + ), + ); + await tester.pump(); + + expect( + find.byKey(const ValueKey('voice-note-attachment:$url')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('voice-note-play-pause')), + findsOneWidget, + ); + final cardFinder = find.byKey( + const ValueKey('voice-note-attachment:$url'), + ); + final rateFinder = find.byKey( + const ValueKey('voice-note-playback-rate'), + ); + final card = tester.widget(cardFinder); + expect(card.padding, const EdgeInsets.all(Grid.twelve)); + expect( + tester.getTopLeft(find.byType(VoiceNoteWaveform)).dx, + tester + .getTopLeft(find.byKey(const ValueKey('voice-note-duration'))) + .dx, + ); + final leadingInset = + tester + .getTopLeft(find.byKey(const ValueKey('voice-note-play-pause'))) + .dx - + tester.getTopLeft(cardFinder).dx; + final trailingInset = + tester.getTopRight(cardFinder).dx - + tester.getTopRight(rateFinder).dx; + expect(leadingInset, Grid.twelve + 1); + expect(trailingInset, leadingInset); + expect(rateFinder, findsOneWidget); + final rateSize = tester.getSize(rateFinder); + final ratePadding = tester + .widgetList( + find.descendant(of: rateFinder, matching: find.byType(Padding)), + ) + .singleWhere( + (widget) => + widget.padding == + const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.half + Grid.quarter, + ), + ); + expect( + ratePadding.padding, + const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.half + Grid.quarter, + ), + ); + final rateValueFinder = find.byKey( + const ValueKey('voice-note-playback-rate-value'), + ); + hapticCalls.clear(); + await tester.tap(find.byKey(const ValueKey('voice-note-play-pause'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); + expect( + tester + .widget(find.byType(VoiceNoteWaveform)) + .progress, + greaterThan(0), + ); + + final waveformRect = tester.getRect(find.byType(VoiceNoteWaveform)); + await tester.dragFrom( + Offset( + waveformRect.left + waveformRect.width * 0.25, + waveformRect.center.dy, + ), + Offset(waveformRect.width * 0.5, 0), + ); + await tester.pump(); + expect(player.state.position.inMilliseconds, closeTo(2250, 80)); + + expect(tester.widget(rateValueFinder).data, '1×'); + hapticCalls.clear(); + await tester.tap(rateFinder); + await tester.pump(); + expect(tester.widget(rateValueFinder).data, '1.5×'); + expect(player.speed, 1.5); + expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); + expect(tester.getSize(rateFinder), rateSize); + await tester.tap(rateFinder); + await tester.pump(); + expect(tester.widget(rateValueFinder).data, '2×'); + expect(tester.getSize(rateFinder), rateSize); + await tester.tap(rateFinder); + await tester.pump(); + expect(tester.widget(rateValueFinder).data, '.5×'); + expect(tester.getSize(rateFinder), rateSize); + }); + testWidgets( 'renders image markdown as a media preview and opens viewer', (tester) async { @@ -911,6 +1559,66 @@ void main() { ); }); + testWidgets( + 'keeps voice notes out of image carousels for audio-only and mixed media', + (tester) async { + const firstAudio = 'https://example.com/media/voice-note-first.mp4'; + const secondAudio = 'https://example.com/media/voice-note-second.mp4'; + const image = 'https://example.com/media/photo.png'; + + Widget message(String content, List> tags) => _testable( + MessageContent(content: content, tags: tags), + overrides: [ + voiceNotePlayerFactoryProvider.overrideWithValue( + _FakeVoiceNotePlayer.new, + ), + ], + ); + + await tester.pumpWidget( + message('![audio]($firstAudio)\n![audio]($secondAudio)', const [ + [ + 'imeta', + 'url $firstAudio', + 'm video/mp4', + 'filename voice-note-first.mp4', + ], + [ + 'imeta', + 'url $secondAudio', + 'm video/mp4', + 'filename voice-note-second.mp4', + ], + ]), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('message-media-carousel')), + findsNothing, + ); + expect(find.byType(VoiceNoteAttachment), findsNWidgets(2)); + + await tester.pumpWidget( + message('![image]($image)\n![audio]($firstAudio)', const [ + ['imeta', 'url $image', 'm image/png'], + [ + 'imeta', + 'url $firstAudio', + 'm video/mp4', + 'filename voice-note-first.mp4', + ], + ]), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('message-media-carousel')), + findsNothing, + ); + expect(find.byType(VoiceNoteAttachment), findsOneWidget); + expect(_imagePreview(image), findsOneWidget); + }, + ); + testWidgets( 'groups uploaded photos into a carousel and opens the full gallery', (tester) async { diff --git a/mobile/test/features/channels/message_media_test.dart b/mobile/test/features/channels/message_media_test.dart index 8e61a71dd19..cfa4ea3d609 100644 --- a/mobile/test/features/channels/message_media_test.dart +++ b/mobile/test/features/channels/message_media_test.dart @@ -34,5 +34,64 @@ void main() { MessageMediaKind.video, ); }); + + test('classifies voice notes from imeta or an audio extension', () { + expect( + classifyMediaUrl( + 'https://example.com/media/blob', + imeta: const ImetaEntry( + url: 'https://example.com/media/blob', + mimeType: 'audio/mp4', + ), + ), + MessageMediaKind.audio, + ); + expect( + classifyMediaUrl('https://example.com/media/voice-note.m4a'), + MessageMediaKind.audio, + ); + expect( + classifyMediaUrl( + 'https://example.com/media/blob.mp4', + imeta: const ImetaEntry( + url: 'https://example.com/media/blob.mp4', + mimeType: 'video/mp4', + filename: 'voice-note-test.mp4', + ), + ), + MessageMediaKind.audio, + ); + }); + + test('rejects non-finite and negative durations', () { + for (final duration in ['NaN', 'Infinity', '-1']) { + final entry = parseImetaTags([ + [ + 'imeta', + 'url https://example.com/media/$duration', + 'duration $duration', + ], + ]).values.single; + expect(entry.duration, isNull); + } + }); + + test('parses voice note duration and filename metadata', () { + final entry = parseImetaTags(const [ + [ + 'imeta', + 'url https://example.com/media/voice-note.m4a', + 'm audio/mp4', + 'duration 3.25', + 'filename voice-note.m4a', + 'size 42', + ], + ]).values.single; + + expect(entry.duration, 3.25); + expect(entry.filename, 'voice-note.m4a'); + expect(entry.size, 42); + expect(entry.isAudio, isTrue); + }); }); } diff --git a/mobile/test/features/channels/voice_note_recording_test.dart b/mobile/test/features/channels/voice_note_recording_test.dart new file mode 100644 index 00000000000..6669cc620cc --- /dev/null +++ b/mobile/test/features/channels/voice_note_recording_test.dart @@ -0,0 +1,1127 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:just_audio/just_audio.dart' as audio; +import 'package:record/record.dart'; +import 'package:buzz/features/channels/voice_note_composer_recorder.dart'; +import 'package:buzz/features/channels/voice_note_recording.dart'; + +class _DelayedRecorderBackend implements VoiceNoteRecorderBackend { + final permission = Completer(); + final nativeStart = Completer(); + final nativeStop = Completer(); + final amplitudes = StreamController.broadcast(); + bool startCalled = false; + bool stopCalled = false; + bool stopCompleted = false; + bool cancelCalled = false; + bool disposeCalled = false; + bool terminalOverlap = false; + + @override + Future hasPermission() => permission.future; + + @override + Future start(RecordConfig config, {required String path}) { + startCalled = true; + return nativeStart.future; + } + + @override + Stream onAmplitudeChanged(Duration interval) => amplitudes.stream; + + @override + Future stop() async { + stopCalled = true; + final path = await nativeStop.future; + stopCompleted = true; + return path; + } + + @override + Future cancel() async { + if (stopCalled && !stopCompleted) terminalOverlap = true; + cancelCalled = true; + } + + @override + Future dispose() async { + if (stopCalled && !stopCompleted) terminalOverlap = true; + disposeCalled = true; + await amplitudes.close(); + } +} + +class _DelayedHttpClient extends http.BaseClient { + final sent = Completer(); + final response = Completer(); + + @override + Future send(http.BaseRequest request) { + sent.complete(request); + return response.future; + } +} + +class _SequencedHttpClient extends http.BaseClient { + final requests = []; + final responses = >[]; + + @override + Future send(http.BaseRequest request) { + requests.add(request); + final response = Completer(); + responses.add(response); + return response.future; + } +} + +class _FakeAudioPlayerBackend implements VoiceNoteAudioPlayerBackend { + final positions = StreamController.broadcast(); + final durations = StreamController.broadcast(); + final states = StreamController.broadcast(); + final pathLoads = >[]; + final urlLoads = >[]; + bool delayPathLoads = false; + bool delayUrlLoads = false; + bool delayPlay = false; + bool _playing = false; + int playCount = 0; + int pauseCount = 0; + int cancelPendingLoadCount = 0; + final loadedPaths = []; + final loadedUrls = []; + final loadedUrlHeaders = ?>[]; + + @override + Stream get positionStream => positions.stream; + + @override + Stream get durationStream => durations.stream; + + @override + Stream get playerStateStream => states.stream; + + @override + bool get playing => _playing; + + @override + Future setFilePath(String path) { + loadedPaths.add(path); + if (!delayPathLoads) return Future.value(const Duration(seconds: 7)); + final load = Completer(); + pathLoads.add(load); + return load.future.whenComplete(() => pathLoads.remove(load)); + } + + @override + Future setUrl(String url, {Map? headers}) { + loadedUrls.add(url); + loadedUrlHeaders.add(headers == null ? null : Map.of(headers)); + if (!delayUrlLoads) return Future.value(const Duration(seconds: 7)); + final load = Completer(); + urlLoads.add(load); + return load.future.whenComplete(() => urlLoads.remove(load)); + } + + @override + Future play() { + _playing = true; + playCount += 1; + if (!delayPlay) return Future.value(); + final playback = Completer(); + late final StreamSubscription subscription; + subscription = states.stream.listen((state) { + if (!state.playing && !playback.isCompleted) playback.complete(); + }); + return playback.future.whenComplete(subscription.cancel); + } + + @override + Future pause() async { + pauseCount += 1; + _playing = false; + states.add(audio.PlayerState(false, audio.ProcessingState.ready)); + } + + @override + Future cancelPendingLoad() async { + cancelPendingLoadCount += 1; + for (final load in [...pathLoads, ...urlLoads]) { + if (!load.isCompleted) { + load.completeError(audio.PlayerInterruptedException('cancelled')); + } + } + states.add(audio.PlayerState(false, audio.ProcessingState.idle)); + } + + @override + Future seek(Duration position) async {} + + @override + Future setSpeed(double speed) async {} + + @override + Future dispose() async { + await positions.close(); + await durations.close(); + await states.close(); + } +} + +class _CoordinatedPlayer extends VoiceNotePlayerController { + _CoordinatedPlayer( + this.coordinator, { + required this.source, + required this.isRemote, + }); + + final VoiceNotePlaybackCoordinator coordinator; + final String source; + final bool isRemote; + VoiceNotePlaybackState _state = const VoiceNotePlaybackState(); + int pauseCount = 0; + Completer? pauseBarrier; + + @override + VoiceNotePlaybackState get state => _state; + + @override + Future loadLocal( + String path, { + required Duration fallbackDuration, + }) async {} + + @override + Future loadRemote( + String url, { + required Map Function() headers, + required Duration fallbackDuration, + }) async {} + + @override + Future pause() async { + pauseCount += 1; + await pauseBarrier?.future; + _state = _state.copyWith(isPlaying: false); + } + + @override + Future seek(Duration position) async {} + + @override + Future setSpeed(double speed) async {} + + @override + Future toggle() async { + if (_state.isPlaying) { + await pause(); + return; + } + if (await coordinator.activate(this)) { + _state = _state.copyWith(isPlaying: true); + } + } + + void complete() { + _state = _state.copyWith(isPlaying: false); + coordinator.release(this); + } + + @override + void dispose() { + coordinator.release(this); + super.dispose(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'dropped finalized recordings are deleted without surfacing errors', + () async { + final directory = await Directory.systemTemp.createTemp( + 'voice-note-dropped-recording-test', + ); + addTearDown(() => directory.delete(recursive: true)); + final recording = File('${directory.path}/recording.m4a'); + await recording.writeAsBytes([1, 2, 3]); + + await deleteDroppedVoiceNoteRecording(recording.path); + await deleteDroppedVoiceNoteRecording(recording.path); + + expect(await recording.exists(), isFalse); + }, + ); + + test('cancellation fences delayed permission before native start', () async { + final backend = _DelayedRecorderBackend(); + final directory = await Directory.systemTemp.createTemp('voice-note-test'); + addTearDown(() => directory.delete(recursive: true)); + final recorder = DeviceVoiceNoteRecorder( + backend: backend, + temporaryDirectory: () async => directory, + ); + + final startup = recorder.start(); + final startupExpectation = expectLater(startup, throwsStateError); + final cancellation = recorder.cancel(); + backend.permission.complete(true); + + await cancellation; + await startupExpectation; + expect(backend.startCalled, isFalse); + await recorder.dispose(); + expect(backend.disposeCalled, isTrue); + }); + + test('cancellation ends native recording when start resolves late', () async { + final backend = _DelayedRecorderBackend(); + final directory = await Directory.systemTemp.createTemp('voice-note-test'); + addTearDown(() => directory.delete(recursive: true)); + final recorder = DeviceVoiceNoteRecorder( + backend: backend, + temporaryDirectory: () async => directory, + ); + + final startup = recorder.start(); + final startupExpectation = expectLater(startup, throwsStateError); + backend.permission.complete(true); + await Future.delayed(Duration.zero); + expect(backend.startCalled, isTrue); + + final cancellation = recorder.cancel(); + backend.nativeStart.complete(); + await cancellation; + await startupExpectation; + + expect(backend.cancelCalled, isTrue); + await recorder.dispose(); + }); + + test( + 'dispose waits for an in-flight stop before releasing backend', + () async { + final backend = _DelayedRecorderBackend(); + final directory = await Directory.systemTemp.createTemp( + 'voice-note-test', + ); + addTearDown(() => directory.delete(recursive: true)); + final recorder = DeviceVoiceNoteRecorder( + backend: backend, + temporaryDirectory: () async => directory, + ); + backend.permission.complete(true); + backend.nativeStart.complete(); + await recorder.start(); + + final stopping = recorder.stop(); + final disposing = recorder.dispose(); + await Future.delayed(Duration.zero); + + expect(backend.stopCalled, isTrue); + expect(backend.cancelCalled, isFalse); + expect(backend.disposeCalled, isFalse); + expect(backend.terminalOverlap, isFalse); + + backend.nativeStop.complete('/tmp/voice-note-test.m4a'); + final recording = await stopping; + await disposing; + + expect(recording.file.path, '/tmp/voice-note-test.m4a'); + expect(backend.stopCompleted, isTrue); + expect(backend.cancelCalled, isFalse); + expect(backend.disposeCalled, isTrue); + expect(backend.terminalOverlap, isFalse); + }, + ); + + test( + 'authenticated iOS playback waits for play and aborts on disposal', + () async { + final client = _DelayedHttpClient(); + final directory = await Directory.systemTemp.createTemp( + 'voice-note-playback-test', + ); + addTearDown(() => directory.delete(recursive: true)); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: client, + temporaryDirectory: () async => directory, + requiresAuthenticatedLocalFile: true, + ); + + var authGeneration = 0; + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => { + 'Authorization': 'Nostr signed-event-${authGeneration++}', + }, + fallbackDuration: const Duration(seconds: 7), + ); + expect(authGeneration, 0); + expect(player.state.isLoading, isFalse); + expect(player.state.duration, const Duration(seconds: 7)); + expect(client.sent.isCompleted, isFalse); + + final playback = player.toggle(); + final request = await client.sent.future; + expect(request, isA()); + expect(request.headers['Authorization'], 'Nostr signed-event-0'); + expect(authGeneration, 1); + + final abortable = request as http.AbortableStreamedRequest; + player.dispose(); + await abortable.abortTrigger; + client.response.completeError(http.RequestAbortedException(request.url)); + await playback; + + expect(directory.listSync().whereType(), isEmpty); + }, + ); + + test( + 'second remote toggle cancels download and third retries with fresh auth', + () async { + final client = _SequencedHttpClient(); + final audioPlayer = _FakeAudioPlayerBackend(); + final directory = await Directory.systemTemp.createTemp( + 'voice-note-toggle-cancel-test', + ); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: client, + temporaryDirectory: () async => directory, + requiresAuthenticatedLocalFile: true, + player: audioPlayer, + ); + addTearDown(player.dispose); + var authGeneration = 0; + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => { + 'Authorization': 'Nostr signed-event-${authGeneration++}', + }, + fallbackDuration: const Duration(seconds: 7), + ); + + final firstToggle = player.toggle(); + await Future.delayed(Duration.zero); + final firstRequest = + client.requests.single as http.AbortableStreamedRequest; + final secondToggle = player.toggle(); + await firstRequest.abortTrigger; + client.responses.single.completeError( + http.RequestAbortedException(firstRequest.url), + ); + await Future.wait([firstToggle, secondToggle]); + + expect(client.requests, hasLength(1)); + expect(authGeneration, 1); + expect(audioPlayer.loadedPaths, isEmpty); + expect(audioPlayer.playCount, 0); + expect(player.state.isLoading, isFalse); + expect(directory.listSync().whereType(), isEmpty); + + final retry = player.toggle(); + await Future.delayed(Duration.zero); + expect(client.requests, hasLength(2)); + expect( + client.requests.last.headers['Authorization'], + 'Nostr signed-event-1', + ); + client.responses.last.complete( + http.StreamedResponse(Stream.value([1, 2, 3]), 200), + ); + await retry; + + expect(authGeneration, 2); + expect(audioPlayer.loadedPaths, hasLength(1)); + expect(audioPlayer.playCount, 1); + }, + ); + + test( + 'second toggle during coordinator activation cancels before GET and retries', + () async { + final coordinator = VoiceNotePlaybackCoordinator(); + final pauseBarrier = Completer(); + final previous = _CoordinatedPlayer( + coordinator, + source: '/tmp/previous.m4a', + isRemote: false, + )..pauseBarrier = pauseBarrier; + await previous.toggle(); + + final client = _SequencedHttpClient(); + final audioPlayer = _FakeAudioPlayerBackend(); + final directory = await Directory.systemTemp.createTemp( + 'voice-note-activation-cancel-test', + ); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + final player = DeviceVoiceNotePlayerController( + coordinator: coordinator, + client: client, + temporaryDirectory: () async => directory, + requiresAuthenticatedLocalFile: true, + player: audioPlayer, + ); + addTearDown(player.dispose); + addTearDown(previous.dispose); + var authGeneration = 0; + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => { + 'Authorization': 'Nostr signed-event-${authGeneration++}', + }, + fallbackDuration: const Duration(seconds: 7), + ); + + final firstToggle = player.toggle(); + await Future.delayed(Duration.zero); + expect(previous.pauseCount, 1); + final secondToggle = player.toggle(); + pauseBarrier.complete(); + await Future.wait([firstToggle, secondToggle]); + + expect(client.requests, isEmpty); + expect(authGeneration, 0); + expect(audioPlayer.loadedPaths, isEmpty); + expect(audioPlayer.playCount, 0); + + final retry = player.toggle(); + while (client.requests.isEmpty) { + await Future.delayed(Duration.zero); + } + expect(client.requests, hasLength(1)); + expect( + client.requests.single.headers['Authorization'], + 'Nostr signed-event-0', + ); + client.responses.single.complete( + http.StreamedResponse(Stream.value([1, 2, 3]), 200), + ); + await retry; + + expect(authGeneration, 1); + expect(audioPlayer.loadedPaths, hasLength(1)); + expect(audioPlayer.playCount, 1); + }, + ); + + test( + 'second toggle during local source load suppresses play and retries', + () async { + final client = _SequencedHttpClient(); + final audioPlayer = _FakeAudioPlayerBackend()..delayPathLoads = true; + final directory = await Directory.systemTemp.createTemp( + 'voice-note-source-load-cancel-test', + ); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: client, + temporaryDirectory: () async => directory, + requiresAuthenticatedLocalFile: true, + player: audioPlayer, + ); + addTearDown(player.dispose); + var authGeneration = 0; + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => { + 'Authorization': 'Nostr signed-event-${authGeneration++}', + }, + fallbackDuration: const Duration(seconds: 7), + ); + + final firstToggle = player.toggle(); + await Future.delayed(Duration.zero); + client.responses.single.complete( + http.StreamedResponse(Stream.value([1, 2, 3]), 200), + ); + while (audioPlayer.pathLoads.isEmpty) { + await Future.delayed(Duration.zero); + } + final cancelledFile = File(audioPlayer.loadedPaths.single); + final secondToggle = player.toggle(); + await Future.wait([firstToggle, secondToggle]); + + expect(audioPlayer.cancelPendingLoadCount, 1); + expect(audioPlayer.playCount, 0); + expect(player.state.isLoading, isFalse); + expect(await cancelledFile.exists(), isFalse); + + final retry = player.toggle(); + await Future.delayed(Duration.zero); + expect(client.requests, hasLength(2)); + expect( + client.requests.last.headers['Authorization'], + 'Nostr signed-event-1', + ); + client.responses.last.complete( + http.StreamedResponse(Stream.value([4, 5, 6]), 200), + ); + while (audioPlayer.pathLoads.isEmpty) { + await Future.delayed(Duration.zero); + } + audioPlayer.pathLoads.single.complete(const Duration(seconds: 7)); + await retry; + + expect(authGeneration, 2); + expect(audioPlayer.playCount, 1); + }, + ); + + test( + 'failed local source load retains remote source for authenticated retry', + () async { + final client = _SequencedHttpClient(); + final audioPlayer = _FakeAudioPlayerBackend()..delayPathLoads = true; + final directory = await Directory.systemTemp.createTemp( + 'voice-note-source-load-retry-test', + ); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: client, + temporaryDirectory: () async => directory, + requiresAuthenticatedLocalFile: true, + player: audioPlayer, + ); + addTearDown(player.dispose); + var authGeneration = 0; + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => { + 'Authorization': 'Nostr signed-event-${authGeneration++}', + }, + fallbackDuration: const Duration(seconds: 7), + ); + + final firstToggle = player.toggle(); + await Future.delayed(Duration.zero); + client.responses.single.complete( + http.StreamedResponse(Stream.value([1, 2, 3]), 200), + ); + while (audioPlayer.pathLoads.isEmpty) { + await Future.delayed(Duration.zero); + } + audioPlayer.pathLoads.single.completeError(StateError('load failed')); + await firstToggle; + + expect(player.state.hasError, isTrue); + expect(audioPlayer.playCount, 0); + + final retry = player.toggle(); + await Future.delayed(Duration.zero); + expect(client.requests, hasLength(2)); + expect( + client.requests.last.headers['Authorization'], + 'Nostr signed-event-1', + ); + client.responses.last.complete( + http.StreamedResponse(Stream.value([4, 5, 6]), 200), + ); + while (audioPlayer.pathLoads.isEmpty) { + await Future.delayed(Duration.zero); + } + audioPlayer.pathLoads.single.complete(const Duration(seconds: 7)); + await retry; + + expect(authGeneration, 2); + expect(player.state.hasError, isFalse); + expect(audioPlayer.playCount, 1); + }, + ); + + test( + 'oversized remote download aborts and leaves no temporary file', + () async { + final client = _SequencedHttpClient(); + final audioPlayer = _FakeAudioPlayerBackend(); + final directory = await Directory.systemTemp.createTemp( + 'voice-note-download-limit-test', + ); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: client, + temporaryDirectory: () async => directory, + requiresAuthenticatedLocalFile: true, + maxDownloadBytes: 3, + player: audioPlayer, + ); + addTearDown(player.dispose); + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => const {}, + fallbackDuration: const Duration(seconds: 7), + ); + + final playback = player.toggle(); + await Future.delayed(Duration.zero); + final request = client.requests.single as http.AbortableStreamedRequest; + client.responses.single.complete( + http.StreamedResponse(Stream.value([1, 2, 3, 4]), 200), + ); + await request.abortTrigger; + await playback; + + expect(player.state.hasError, isTrue); + expect(audioPlayer.loadedPaths, isEmpty); + expect(audioPlayer.playCount, 0); + expect(directory.listSync().whereType(), isEmpty); + }, + ); + + test('stalled response times out and remains retryable', () async { + final client = _SequencedHttpClient(); + final audioPlayer = _FakeAudioPlayerBackend(); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: client, + requiresAuthenticatedLocalFile: true, + downloadTimeout: Duration.zero, + player: audioPlayer, + ); + addTearDown(player.dispose); + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => const {}, + fallbackDuration: const Duration(seconds: 7), + ); + + await player.toggle(); + + expect(player.state.hasError, isTrue); + expect(client.requests, hasLength(1)); + expect(audioPlayer.playCount, 0); + final retry = player.toggle(); + await Future.delayed(Duration.zero); + expect(client.requests, hasLength(2)); + client.responses.last.completeError( + http.RequestAbortedException(client.requests.last.url), + ); + await retry; + }); + + test('transient remote failure retries with fresh auth and plays', () async { + final client = _SequencedHttpClient(); + final audioPlayer = _FakeAudioPlayerBackend(); + final directory = await Directory.systemTemp.createTemp( + 'voice-note-retry-test', + ); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: client, + temporaryDirectory: () async => directory, + requiresAuthenticatedLocalFile: true, + player: audioPlayer, + ); + addTearDown(player.dispose); + var authGeneration = 0; + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => { + 'Authorization': 'Nostr signed-event-${authGeneration++}', + }, + fallbackDuration: const Duration(seconds: 7), + ); + + final firstToggle = player.toggle(); + await Future.delayed(Duration.zero); + client.responses.single.complete( + http.StreamedResponse(Stream>.empty(), 503), + ); + await firstToggle; + expect(player.state.hasError, isTrue); + expect(client.requests, hasLength(1)); + expect(audioPlayer.playCount, 0); + + final retry = player.toggle(); + await Future.delayed(Duration.zero); + expect(client.requests, hasLength(2)); + expect( + client.requests.last.headers['Authorization'], + 'Nostr signed-event-1', + ); + client.responses.last.complete( + http.StreamedResponse(Stream.value([4, 5, 6]), 200), + ); + await retry; + + expect(authGeneration, 2); + expect(player.state.hasError, isFalse); + expect(audioPlayer.playCount, 1); + }); + + test( + 'pause aborts a rapid-toggle download without temporary residue', + () async { + final client = _SequencedHttpClient(); + final audioPlayer = _FakeAudioPlayerBackend(); + final directory = await Directory.systemTemp.createTemp( + 'voice-note-pause-test', + ); + addTearDown(() => directory.delete(recursive: true)); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: client, + temporaryDirectory: () async => directory, + requiresAuthenticatedLocalFile: true, + player: audioPlayer, + ); + addTearDown(player.dispose); + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => const {}, + fallbackDuration: const Duration(seconds: 7), + ); + + final firstToggle = player.toggle(); + while (client.requests.isEmpty) { + await Future.delayed(Duration.zero); + } + final request = client.requests.single as http.AbortableStreamedRequest; + final secondToggle = player.toggle(); + await request.abortTrigger; + client.responses.single.completeError( + http.RequestAbortedException(request.url), + ); + await Future.wait([firstToggle, secondToggle]); + + expect(client.requests, hasLength(1)); + expect(audioPlayer.loadedPaths, isEmpty); + expect(audioPlayer.playCount, 0); + expect(directory.listSync().whereType(), isEmpty); + }, + ); + + test( + 'source replacement aborts the owned download without stale playback', + () async { + final client = _SequencedHttpClient(); + final audioPlayer = _FakeAudioPlayerBackend()..delayPathLoads = true; + final directory = await Directory.systemTemp.createTemp( + 'voice-note-source-replacement-test', + ); + addTearDown(() => directory.delete(recursive: true)); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: client, + temporaryDirectory: () async => directory, + requiresAuthenticatedLocalFile: true, + player: audioPlayer, + ); + addTearDown(player.dispose); + await player.loadRemote( + 'https://example.com/first.mp4', + headers: () => const {}, + fallbackDuration: const Duration(seconds: 7), + ); + + final playback = player.toggle(); + await Future.delayed(Duration.zero); + client.responses.single.complete( + http.StreamedResponse(Stream.value([1, 2, 3]), 200), + ); + while (audioPlayer.pathLoads.isEmpty) { + await Future.delayed(Duration.zero); + } + final staleFile = File(audioPlayer.loadedPaths.single); + expect(await staleFile.exists(), isTrue); + + final replacement = player.loadLocal( + '${directory.path}/replacement.m4a', + fallbackDuration: const Duration(seconds: 8), + ); + await Future.delayed(Duration.zero); + audioPlayer.pathLoads.first.complete(const Duration(seconds: 7)); + await playback; + while (await staleFile.exists()) { + await Future.delayed(Duration.zero); + } + + expect(audioPlayer.playCount, 0); + expect(player.state.duration, const Duration(seconds: 8)); + expect(player.state.isLoading, isTrue); + + audioPlayer.pathLoads.last.complete(const Duration(seconds: 8)); + await replacement; + expect(player.state.duration, const Duration(seconds: 8)); + expect(player.state.isLoading, isFalse); + }, + ); + + test('pause interrupts production-shaped pending playback', () async { + final audioPlayer = _FakeAudioPlayerBackend()..delayPlay = true; + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: _SequencedHttpClient(), + requiresAuthenticatedLocalFile: false, + player: audioPlayer, + ); + addTearDown(player.dispose); + await player.loadLocal( + '/tmp/voice-note.m4a', + fallbackDuration: const Duration(seconds: 7), + ); + + final playing = player.toggle(); + await Future.delayed(Duration.zero); + expect(audioPlayer.playCount, 1); + expect(audioPlayer.playing, isTrue); + + await player.toggle(); + await playing; + + expect(audioPlayer.pauseCount, 1); + expect(audioPlayer.playing, isFalse); + }); + + test('active playback remains pauseable while buffering', () async { + final audioPlayer = _FakeAudioPlayerBackend(); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: _SequencedHttpClient(), + requiresAuthenticatedLocalFile: false, + player: audioPlayer, + ); + addTearDown(player.dispose); + await player.loadLocal( + '/tmp/voice-note.m4a', + fallbackDuration: const Duration(seconds: 7), + ); + await player.toggle(); + + audioPlayer.states.add( + audio.PlayerState(true, audio.ProcessingState.buffering), + ); + await Future.delayed(Duration.zero); + + expect(player.state.isPlaying, isTrue); + expect(player.state.isLoading, isTrue); + expect(player.state.canCancelLoading, isTrue); + + await player.toggle(); + + expect(audioPlayer.pauseCount, 1); + expect(audioPlayer.cancelPendingLoadCount, 0); + expect(audioPlayer.playing, isFalse); + }); + + test( + 'Android cancel interrupts pending preload, preserves loading capability, and retries', + () async { + final audioPlayer = _FakeAudioPlayerBackend()..delayUrlLoads = true; + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: _SequencedHttpClient(), + requiresAuthenticatedLocalFile: false, + player: audioPlayer, + ); + addTearDown(player.dispose); + var authGeneration = 0; + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => { + 'Authorization': 'Nostr signed-event-${authGeneration++}', + }, + fallbackDuration: const Duration(seconds: 7), + ); + + final firstToggle = player.toggle(); + await Future.delayed(Duration.zero); + expect(player.state.canCancelLoading, isTrue); + + audioPlayer.positions.add(const Duration(seconds: 1)); + audioPlayer.durations.add(const Duration(seconds: 8)); + await Future.delayed(Duration.zero); + expect(player.state.canCancelLoading, isTrue); + + final cancel = player.toggle(); + await Future.wait([firstToggle, cancel]); + + expect(audioPlayer.cancelPendingLoadCount, 1); + expect(audioPlayer.pauseCount, 0); + expect(audioPlayer.playCount, 0); + expect(player.state.isLoading, isFalse); + expect(player.state.canCancelLoading, isFalse); + + final retry = player.toggle(); + await Future.delayed(Duration.zero); + expect(audioPlayer.loadedUrls, hasLength(2)); + expect( + audioPlayer.loadedUrlHeaders.last?['Authorization'], + 'Nostr signed-event-1', + ); + audioPlayer.urlLoads.single.complete(const Duration(seconds: 7)); + await retry; + + expect(authGeneration, 2); + expect(audioPlayer.playCount, 1); + }, + ); + + test('Android defers remote auth until playback starts', () async { + final audioPlayer = _FakeAudioPlayerBackend(); + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: _SequencedHttpClient(), + requiresAuthenticatedLocalFile: false, + player: audioPlayer, + ); + addTearDown(player.dispose); + var authGeneration = 0; + + await player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => { + 'Authorization': 'Nostr signed-event-${authGeneration++}', + }, + fallbackDuration: const Duration(seconds: 7), + ); + + expect(authGeneration, 0); + expect(audioPlayer.loadedUrls, isEmpty); + + await player.toggle(); + + expect(authGeneration, 1); + expect( + audioPlayer.loadedUrlHeaders.single?['Authorization'], + 'Nostr signed-event-0', + ); + expect(audioPlayer.playCount, 1); + }); + + test('Android remote failure retains the source for retry', () async { + final audioPlayer = _FakeAudioPlayerBackend()..delayUrlLoads = true; + final player = DeviceVoiceNotePlayerController( + coordinator: VoiceNotePlaybackCoordinator(), + client: _SequencedHttpClient(), + requiresAuthenticatedLocalFile: false, + player: audioPlayer, + ); + addTearDown(player.dispose); + var authGeneration = 0; + + final initialLoad = player.loadRemote( + 'https://example.com/voice-note.mp4', + headers: () => { + 'Authorization': 'Nostr signed-event-${authGeneration++}', + }, + fallbackDuration: const Duration(seconds: 7), + ); + await initialLoad; + + expect(player.state.hasError, isFalse); + expect(audioPlayer.loadedUrls, isEmpty); + expect(authGeneration, 0); + + final firstPlay = player.toggle(); + await Future.delayed(Duration.zero); + expect(audioPlayer.loadedUrls, hasLength(1)); + expect( + audioPlayer.loadedUrlHeaders.single?['Authorization'], + 'Nostr signed-event-0', + ); + audioPlayer.urlLoads.single.completeError(StateError('network failed')); + await firstPlay; + + expect(player.state.hasError, isTrue); + expect(authGeneration, 1); + expect(audioPlayer.playCount, 0); + + final retry = player.toggle(); + await Future.delayed(Duration.zero); + expect(audioPlayer.loadedUrls, hasLength(2)); + expect( + audioPlayer.loadedUrlHeaders.last?['Authorization'], + 'Nostr signed-event-1', + ); + expect(authGeneration, 2); + audioPlayer.urlLoads.single.complete(const Duration(seconds: 7)); + await retry; + + expect(player.state.hasError, isFalse); + expect(audioPlayer.playCount, 1); + }); + + test( + 'playback coordinator arbitrates instances and releases ownership', + () async { + final coordinator = VoiceNotePlaybackCoordinator(); + final first = _CoordinatedPlayer( + coordinator, + source: 'https://example.com/first.mp4', + isRemote: true, + ); + final second = _CoordinatedPlayer( + coordinator, + source: 'https://example.com/second.mp4', + isRemote: true, + ); + final duplicateSource = _CoordinatedPlayer( + coordinator, + source: first.source, + isRemote: true, + ); + final composerPreview = _CoordinatedPlayer( + coordinator, + source: '/tmp/voice-note.m4a', + isRemote: false, + ); + addTearDown(second.dispose); + addTearDown(duplicateSource.dispose); + addTearDown(composerPreview.dispose); + expect(duplicateSource.source, first.source); + + await first.toggle(); + await second.toggle(); + + expect(first.pauseCount, 1); + expect(first.state.isPlaying, isFalse); + expect(second.state.isPlaying, isTrue); + + await duplicateSource.toggle(); + expect(second.pauseCount, 1); + expect(second.state.isPlaying, isFalse); + expect(duplicateSource.state.isPlaying, isTrue); + + duplicateSource.complete(); + await composerPreview.toggle(); + expect(duplicateSource.pauseCount, 0); + expect(composerPreview.isRemote, isFalse); + expect(composerPreview.state.isPlaying, isTrue); + + await first.toggle(); + expect(composerPreview.pauseCount, 1); + expect(composerPreview.state.isPlaying, isFalse); + expect(first.state.isPlaying, isTrue); + + first.dispose(); + await second.toggle(); + expect(first.pauseCount, 1); + expect(second.state.isPlaying, isTrue); + }, + ); +} diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 815f41c53af..e9dbed302ef 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -1222,6 +1222,148 @@ void main() { }); }); + group('uploadVoiceNote', () { + test('keeps ordinary audio attachments on inline audio markdown', () { + const descriptor = BlobDescriptor( + url: 'https://relay.example/media/meeting.m4a', + sha256: + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + size: 3, + type: 'audio/mp4', + uploaded: 1, + filename: 'meeting.m4a', + ); + + expect( + descriptor.toMarkdownImage(), + '![audio](https://relay.example/media/meeting.m4a)', + ); + }); + + test( + 'removes generated Android package when fast-start rewrite fails', + () async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final sourceDirectory = await Directory.systemTemp.createTemp( + 'voice_note_rewrite_source_', + ); + final generatedDirectory = await Directory.systemTemp.createTemp( + 'voice_note_rewrite_generated_', + ); + final source = File('${sourceDirectory.path}/recording.m4a'); + final generated = File('${generatedDirectory.path}/packaged.mp4'); + await source.writeAsBytes(const [1, 2, 3]); + await generated.writeAsBytes(const [4, 5, 6]); + _setMockMediaUploadPlatformHandler((call) async { + if (call.method == 'packageVoiceNoteForUpload') { + return generated.path; + } + return null; + }); + addTearDown(() async { + debugDefaultTargetPlatformOverride = previousPlatform; + _setMockMediaUploadPlatformHandler((call) async { + switch (call.method) { + case 'sanitizeImageForUpload': + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + case 'transcodeImageToJpeg': + return _jpegBytes; + default: + return null; + } + }); + if (await sourceDirectory.exists()) { + await sourceDirectory.delete(recursive: true); + } + if (await generatedDirectory.exists()) { + await generatedDirectory.delete(recursive: true); + } + }); + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + + await expectLater( + service.uploadVoiceNote( + XFile(source.path, mimeType: 'audio/mp4'), + duration: const Duration(seconds: 3), + ), + throwsFormatException, + ); + + expect(await source.exists(), isTrue); + expect(await generated.exists(), isFalse); + expect(generatedDirectory.listSync().whereType(), isEmpty); + }, + ); + + test('uploads the packaged voice note as a video MP4 audio card', () async { + final sourceDirectory = await Directory.systemTemp.createTemp( + 'voice_note_source_', + ); + final packagedDirectory = await Directory.systemTemp.createTemp( + 'voice_note_packaged_', + ); + final source = File('${sourceDirectory.path}/voice-note-test.m4a'); + final packaged = File('${packagedDirectory.path}/voice-note-test.mp4'); + await source.writeAsBytes(const [1, 2, 3]); + await packaged.writeAsBytes(const [4, 5, 6]); + var packagedSourcePath = ''; + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((request) async { + expect(request.headers['Content-Type'], 'video/mp4'); + expect(request.bodyBytes, const [4, 5, 6]); + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/voice-note.mp4', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': request.bodyBytes.length, + 'type': 'video/mp4', + 'uploaded': 1, + 'duration': 3.0, + }), + HttpStatus.ok, + ); + }), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + packageVoiceNoteForUpload: (path) async { + packagedSourcePath = path; + return packaged.path; + }, + ); + + try { + final descriptor = await service.uploadVoiceNote( + XFile(source.path, mimeType: 'audio/mp4'), + duration: const Duration(milliseconds: 3250), + ); + + expect(packagedSourcePath, source.path); + expect(descriptor.type, 'video/mp4'); + expect(descriptor.filename, 'voice-note-test.mp4'); + expect(descriptor.duration, 3.0); + expect(descriptor.toImetaTag(), contains('duration 3.0')); + expect( + descriptor.toMarkdownImage(), + '[voice-note-test.mp4](${descriptor.url})', + ); + expect(await packaged.exists(), isFalse); + } finally { + await sourceDirectory.delete(recursive: true); + await packagedDirectory.delete(recursive: true); + } + }); + }); + group('pickAndUploadVideo', () { // Helper: build ftyp header bytes for a given brand. Uint8List buildFtypHeader(String brand) { diff --git a/mobile/test/shared/widgets/modal_presentation_test.dart b/mobile/test/shared/widgets/modal_presentation_test.dart index 7d01e8e385d..66ea9a10877 100644 --- a/mobile/test/shared/widgets/modal_presentation_test.dart +++ b/mobile/test/shared/widgets/modal_presentation_test.dart @@ -75,6 +75,15 @@ void main() { await tester.pump(); expect(find.byType(UiKitView), findsOneWidget); + expect( + find.ancestor( + of: find.byType(UiKitView), + matching: find.byWidgetPredicate( + (widget) => widget is IgnorePointer && widget.ignoring, + ), + ), + findsOneWidget, + ); expect( find.byWidgetPredicate( (widget) => widget is Material && widget.color == Colors.red, @@ -183,14 +192,16 @@ void main() { ); await tester.pump(); - expect(colorUpdates, hasLength(1)); - expect(colorUpdates.single.method, 'updateColors'); + final initialColorUpdates = colorUpdates + .where((call) => call.method == 'updateColors') + .toList(); + expect(initialColorUpdates, hasLength(1)); expect( - colorUpdates.single.arguments, + initialColorUpdates.single.arguments, containsPair('color', darkTheme.colorScheme.surface.toARGB32()), ); expect( - colorUpdates.single.arguments, + initialColorUpdates.single.arguments, containsPair( 'backdropColor', darkTheme.extension()!.huddleDrawerSurface.toARGB32(), @@ -201,13 +212,16 @@ void main() { await tester.pumpWidget(themedSurface(lightTheme)); await tester.pumpAndSettle(); - expect(colorUpdates.length, greaterThanOrEqualTo(2)); + final updatedColorCalls = colorUpdates + .where((call) => call.method == 'updateColors') + .toList(); + expect(updatedColorCalls.length, greaterThanOrEqualTo(2)); expect( - colorUpdates.last.arguments, + updatedColorCalls.last.arguments, containsPair('color', lightTheme.colorScheme.surface.toARGB32()), ); expect( - colorUpdates.last.arguments, + updatedColorCalls.last.arguments, containsPair( 'backdropColor', lightTheme.extension()!.huddleDrawerSurface.toARGB32(), @@ -226,6 +240,69 @@ void main() { } }); + testWidgets('native glass surfaces receive concentric composer geometry', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const supportChannel = MethodChannel('buzz/concentric_sheet_surface'); + const viewChannel = MethodChannel('buzz/concentric_sheet_surface/84'); + final updates = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + supportChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + viewChannel, + (call) async { + updates.add(call); + return null; + }, + ); + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const ConcentricSheetSurface( + enabled: true, + usesGlass: true, + minimumRadius: 26, + contentClipRadius: 18, + padding: EdgeInsets.zero, + providesSheetSurface: false, + child: SizedBox(height: 80, child: Text('Composer')), + ), + ), + ); + await tester.pump(); + + final nativeSurface = tester.widget(find.byType(UiKitView)); + expect(nativeSurface.creationParams, containsPair('usesGlass', true)); + expect(nativeSurface.creationParams, containsPair('minimumRadius', 26)); + final contentClip = tester.widget( + find.byKey(const ValueKey('concentric-sheet-content-clip')), + ); + expect(contentClip.borderRadius, BorderRadius.circular(18)); + + nativeSurface.onPlatformViewCreated!(84); + await tester.pump(); + final geometryUpdate = updates.singleWhere( + (call) => call.method == 'updateGeometry', + ); + expect(geometryUpdate.arguments, containsPair('minimumRadius', 26.0)); + expect(geometryUpdate.arguments, containsPair('brightness', 'light')); + } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + supportChannel, + null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + viewChannel, + null, + ); + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('native titled sheets leave the concentric surface unobscured', ( tester, ) async {