diff --git a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt index ba799cc..520896e 100644 --- a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt +++ b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt @@ -3,6 +3,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Color import android.graphics.ImageFormat import android.graphics.Matrix import android.graphics.Rect @@ -15,6 +16,11 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.util.zip.CRC32 +import java.util.zip.Deflater +import java.util.zip.DeflaterOutputStream +import kotlin.math.roundToInt /** * Camera2 API を使用してフロントカメラから YUV_420_888 フォーマットで @@ -26,9 +32,18 @@ private var backgroundHandler: Handler? = null private var cameraDevice: CameraDevice? = null + /** フレーム取得後に処理するコールバックの型エイリアス.*/ + private typealias FrameProcessor = ( + yBytes: ByteArray, uBytes: ByteArray, vBytes: ByteArray, + width: Int, height: Int, + yRowStride: Int, uvRowStride: Int, uvPixelStride: Int, + reader: ImageReader, camera: CameraDevice, + result: MethodChannel.Result + ) -> Unit + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "captureFullResolutionYuv" -> captureFullResolutionYuv(result) + "captureFullResolutionPng" -> captureFullResolutionPng(result) "convertYuvToJpeg" -> convertYuvToJpeg(call, result) "scanFile" -> { val path = call.argument("path") @@ -105,7 +120,60 @@ } } - private fun captureFullResolutionYuv(result: MethodChannel.Result) { + private fun captureFullResolutionPng(result: MethodChannel.Result) { + openCameraAndCapture(result) { yBytes, uBytes, vBytes, width, height, yRowStride, uvRowStride, uvPixelStride, reader, camera, res -> + // YUV → RGB 変換 + 90° 時計回り回転を統合 + // 元の(x,y) → 回転後の(height-1-y, x) + // 出力サイズ: width_out=height, height_out=width + val rotatedPixels = IntArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + val yIndex = y * yRowStride + x + val uvIndex = (y / 2) * uvRowStride + (x / 2) * uvPixelStride + + val yValue = (yBytes[yIndex].toInt() and 0xFF).toDouble() + val uValue = (uBytes[uvIndex].toInt() and 0xFF).toDouble() + val vValue = (vBytes[uvIndex].toInt() and 0xFF).toDouble() + + val r = (yValue + 1.402 * (vValue - 128)).roundToInt().coerceIn(0, 255) + val g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)).roundToInt().coerceIn(0, 255) + val b = (yValue + 1.772 * (uValue - 128)).roundToInt().coerceIn(0, 255) + + rotatedPixels[x * height + (height - 1 - y)] = Color.rgb(r, g, b) + } + } + + val rotatedWidth = height + val rotatedHeight = width + val bitmap = Bitmap.createBitmap(rotatedWidth, rotatedHeight, Bitmap.Config.ARGB_8888) + bitmap.setPixels(rotatedPixels, 0, rotatedWidth, 0, 0, rotatedWidth, rotatedHeight) + + // 非圧縮 PNG エンコード(Deflater.NO_COMPRESSION で高速化) + val pngBytes = encodePngUncompressed(bitmap) + bitmap.recycle() + + reader.close() + camera.close() + cameraDevice = null + + Handler(context.mainLooper).post { + res.success(pngBytes) + stopBackgroundThread() + } + } + } + + /** + * フロントカメラを開いてフル解像度の YUV フレームを 1 枚取得し,[onFrame] を呼び出す. + * + * カメラのオープン・セッション作成・AE/AF 安定待機(1 秒)・本番キャプチャの + * 共通フローを担う.フレーム取得後の処理は [onFrame] で差異化する. + * [onFrame] 内で reader と camera のクローズおよび result への応答を行うこと. + */ + private fun openCameraAndCapture( + result: MethodChannel.Result, + onFrame: FrameProcessor, + ) { startBackgroundThread() val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager @@ -153,7 +221,6 @@ override fun onConfigured(session: CameraCaptureSession) { try { // Phase 1: プレビューを流して AE/AF を安定させる - // この間のフレームは捨てる(リスナー未設定) val previewRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { addTarget(imageReader.surface) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) @@ -187,6 +254,9 @@ val uPlane = image.planes[1] val vPlane = image.planes[2] + val width = image.width + val height = image.height + val yBytes = ByteArray(yPlane.buffer.remaining()) val uBytes = ByteArray(uPlane.buffer.remaining()) val vBytes = ByteArray(vPlane.buffer.remaining()) @@ -195,26 +265,18 @@ uPlane.buffer.get(uBytes) vPlane.buffer.get(vBytes) - val data = mapOf( - "width" to image.width, - "height" to image.height, - "yPlane" to yBytes, - "uPlane" to uBytes, - "vPlane" to vBytes, - "yRowStride" to yPlane.rowStride, - "uvRowStride" to uPlane.rowStride, - "uvPixelStride" to uPlane.pixelStride - ) + val yRowStride = yPlane.rowStride + val uvRowStride = uPlane.rowStride + val uvPixelStride = uPlane.pixelStride image.close() - reader.close() - camera.close() - cameraDevice = null - Handler(context.mainLooper).post { - result.success(data) - stopBackgroundThread() - } + onFrame( + yBytes, uBytes, vBytes, + width, height, + yRowStride, uvRowStride, uvPixelStride, + reader, camera, result + ) } catch (e: Exception) { image.close() reader.close() @@ -291,6 +353,74 @@ } } + /// Bitmap を非圧縮 PNG(Deflater.NO_COMPRESSION)としてエンコードする. + private fun encodePngUncompressed(bitmap: Bitmap): ByteArray { + val w = bitmap.width + val h = bitmap.height + val out = ByteArrayOutputStream(w * h * 3 + 1024) + + // PNG シグネチャ + out.write(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) + + // IHDR チャンク + val ihdrData = ByteBuffer.allocate(13) + .putInt(w) + .putInt(h) + .put(8) // bit depth + .put(2) // color type: RGB + .put(0) // compression method + .put(0) // filter method + .put(0) // interlace method + .array() + writeChunk(out, "IHDR", ihdrData) + + // IDAT チャンク: 非圧縮 zlib でラップした RGB データ + val idatStream = ByteArrayOutputStream(w * h * 3 + h + 64) + val deflater = Deflater(Deflater.NO_COMPRESSION) + val deflaterOut = DeflaterOutputStream(idatStream, deflater, 65536) + + val pixels = IntArray(w) + val rowBytes = ByteArray(1 + w * 3) // フィルタバイト(0) + RGB + rowBytes[0] = 0 // フィルタ: None + + for (y in 0 until h) { + bitmap.getPixels(pixels, 0, w, 0, y, w, 1) + for (x in 0 until w) { + val pixel = pixels[x] + rowBytes[1 + x * 3] = ((pixel shr 16) and 0xFF).toByte() // R + rowBytes[1 + x * 3 + 1] = ((pixel shr 8) and 0xFF).toByte() // G + rowBytes[1 + x * 3 + 2] = (pixel and 0xFF).toByte() // B + } + deflaterOut.write(rowBytes) + } + deflaterOut.finish() + deflaterOut.close() + deflater.end() + + writeChunk(out, "IDAT", idatStream.toByteArray()) + + // IEND チャンク + writeChunk(out, "IEND", byteArrayOf()) + + return out.toByteArray() + } + + /// PNG チャンクを書き込む(length + type + data + CRC). + private fun writeChunk(out: ByteArrayOutputStream, type: String, data: ByteArray) { + val typeBytes = type.toByteArray(Charsets.US_ASCII) + // length (4 bytes, big-endian) + out.write(ByteBuffer.allocate(4).putInt(data.size).array()) + // type (4 bytes) + out.write(typeBytes) + // data + out.write(data) + // CRC (type + data) + val crc = CRC32() + crc.update(typeBytes) + crc.update(data) + out.write(ByteBuffer.allocate(4).putInt(crc.value.toInt()).array()) + } + private fun cleanup() { cameraDevice?.close() cameraDevice = null diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 8646309..0956563 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -150,11 +150,11 @@ // カメラが完全に解放されるまで待つ await Future.delayed(const Duration(milliseconds: 500)); - // Camera2 API でフル解像度 YUV キャプチャ - final yuvData = await _rawCaptureService.captureFullResolutionYuv(); + // Camera2 API でフル解像度キャプチャ(ネイティブで YUV→PNG 変換) + final pngBytes = await _rawCaptureService.captureFullResolutionPng(); - // PNG に変換して保存 - final savedPath = await _fileService.saveImageFromYuvData(yuvData); + // PNG バイト列をファイルに保存 + final savedPath = await _fileService.saveImage(pngBytes); // MediaStore に登録(PC から MTP で見えるようにする) await _rawCaptureService.scanFile(savedPath); diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart index 1f43965..f25c11f 100644 --- a/lib/screens/gallery_screen.dart +++ b/lib/screens/gallery_screen.dart @@ -53,10 +53,7 @@ minScale: 1.0, maxScale: 5.0, child: SizedBox.expand( - child: Image.file( - _selectedFile!, - fit: BoxFit.cover, - ), + child: Image.file(_selectedFile!, fit: BoxFit.cover), ), ), // ヘッダー(ファイル名 + 閉じるボタン) @@ -83,12 +80,8 @@ ), ), IconButton( - icon: const Icon( - Icons.close, - color: Colors.white, - ), - onPressed: () => - setState(() => _selectedFile = null), + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => setState(() => _selectedFile = null), ), ], ), diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index ab4b257..1f9765a 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -1,7 +1,5 @@ import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:image/image.dart' as img; +import 'dart:typed_data'; /// 撮影画像の保存・命名・削除を行うサービス. class FileService { @@ -10,42 +8,18 @@ /// 保存先ディレクトリのパスを返す. String get directoryPath => _basePath; - /// Platform Channel から受け取った YUV データを PNG に変換して保存する. + /// PNG バイト列をファイルに書き込んで保存する. /// - /// Camera2 API でフル解像度キャプチャした生データを直接変換するため, - /// JPEG 圧縮を経由せず画質劣化がない. - Future saveImageFromYuvData(Map yuvData) async { + /// Android ネイティブ側で YUV→RGB→PNG 変換済みのバイト列を受け取り, + /// そのまま書き込むため高速に動作する. + Future saveImage(Uint8List pngBytes) async { final directory = Directory(_basePath); if (!await directory.exists()) { await directory.create(recursive: true); } - - final yPlane = _toUint8List(yuvData['yPlane']); - final uPlane = _toUint8List(yuvData['uPlane']); - final vPlane = _toUint8List(yuvData['vPlane']); - - if (yPlane.isEmpty) { - throw Exception('YUV データが空です'); - } - - final params = { - 'width': yuvData['width'] as int, - 'height': yuvData['height'] as int, - 'yPlane': yPlane, - 'uPlane': uPlane, - 'vPlane': vPlane, - 'yRowStride': yuvData['yRowStride'] as int, - 'uvRowStride': yuvData['uvRowStride'] as int, - 'uvPixelStride': yuvData['uvPixelStride'] as int, - }; - - final pngBytes = await compute(_convertYuvToPng, params); - final fileName = await generateFileName(); final filePath = '$_basePath/$fileName'; - final file = File(filePath); - await file.writeAsBytes(pngBytes); - + await File(filePath).writeAsBytes(pngBytes); return filePath; } @@ -96,47 +70,4 @@ } return '${baseName}_$suffix.png'; } - - /// `dynamic` な YUV プレーンを `Uint8List` に変換する. - Uint8List _toUint8List(dynamic raw) => raw is Uint8List - ? raw - : Uint8List.fromList((raw as List).cast()); - - /// YUV420 を RGB に変換し PNG エンコードする(isolate 内で実行). - static Uint8List _convertYuvToPng(Map params) { - final width = params['width'] as int; - final height = params['height'] as int; - final yPlane = params['yPlane'] as Uint8List; - final uPlane = params['uPlane'] as Uint8List; - final vPlane = params['vPlane'] as Uint8List; - final yRowStride = params['yRowStride'] as int; - final uvRowStride = params['uvRowStride'] as int; - final uvPixelStride = params['uvPixelStride'] as int; - - final image = img.Image(width: width, height: height); - - for (var y = 0; y < height; y++) { - for (var x = 0; x < width; x++) { - final yIndex = y * yRowStride + x; - final uvIndex = (y ~/ 2) * uvRowStride + (x ~/ 2) * uvPixelStride; - - final yValue = yPlane[yIndex]; - final uValue = uPlane[uvIndex]; - final vValue = vPlane[uvIndex]; - - final r = (yValue + 1.402 * (vValue - 128)).round().clamp(0, 255); - final g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)) - .round() - .clamp(0, 255); - final b = (yValue + 1.772 * (uValue - 128)).round().clamp(0, 255); - - image.setPixelRgb(x, y, r, g, b); - } - } - - // センサーの向き(270°)を補正するため 90° 時計回りに回転 - final rotated = img.copyRotate(image, angle: 90); - - return Uint8List.fromList(img.encodePng(rotated)); - } } diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 5bf13e6..6001884 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -1,18 +1,21 @@ import 'package:flutter/services.dart'; -/// Camera2 API を使用してフル解像度の YUV 画像をキャプチャするサービス. +/// Camera2 API を使用してフル解像度の画像をキャプチャするサービス. class RawCaptureService { static const _channel = MethodChannel('com.example.mini_tias/raw_capture'); - /// フロントカメラからフル解像度の YUV データをキャプチャする. - Future> captureFullResolutionYuv() async { + /// フロントカメラからフル解像度の PNG バイト列をキャプチャする(ネイティブ変換). + Future captureFullResolutionPng() async { final result = await _channel - .invokeMethod('captureFullResolutionYuv') + .invokeMethod('captureFullResolutionPng') .timeout( const Duration(seconds: 30), onTimeout: () => throw Exception('カメラキャプチャがタイムアウトしました'), ); - return Map.from(result as Map); + if (result == null) { + throw Exception('PNG キャプチャに失敗しました'); + } + return result; } /// YUV_420_888 を JPEG に変換する(Android ネイティブの高速変換). diff --git a/pubspec.lock b/pubspec.lock index c6ebf24..52f27dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - archive: - dependency: transitive - description: - name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff - url: "https://pub.dev" - source: hosted - version: "4.0.9" async: dependency: transitive description: @@ -113,14 +105,6 @@ url: "https://pub.dev" source: hosted version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" flutter: dependency: "direct main" description: flutter @@ -152,14 +136,6 @@ description: flutter source: sdk version: "0.0.0" - image: - dependency: "direct main" - description: - name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce - url: "https://pub.dev" - source: hosted - version: "4.8.0" leak_tracker: dependency: transitive description: @@ -280,14 +256,6 @@ url: "https://pub.dev" source: hosted version: "0.2.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" plugin_platform_interface: dependency: transitive description: @@ -296,14 +264,6 @@ url: "https://pub.dev" source: hosted version: "2.1.8" - posix: - dependency: transitive - description: - name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" provider: dependency: "direct main" description: @@ -453,14 +413,6 @@ url: "https://pub.dev" source: hosted version: "1.1.1" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" sdks: dart: ">=3.11.4 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 05d7385..30d4e92 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,6 @@ screen_brightness: ^2.1.7 camera: ^0.12.0+1 permission_handler: ^12.0.1 - image: ^4.8.0 dev_dependencies: flutter_test: diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index 4d577a7..7bd9b54 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mini_tias/services/file_service.dart'; @@ -30,5 +33,183 @@ ]); expect(result, 'MiniTIAS_20260404_120000.png'); }); + + // --- 追加テスト: ファイル命名規則の詳細検証 --- + + test('返り値が MiniTIAS_ プレフィックスで始まる', () { + final result = FileService.generateFileNameSync('20260404_120000', []); + expect(result, startsWith('MiniTIAS_')); + }); + + test('返り値が .png 拡張子で終わる', () { + final result = FileService.generateFileNameSync('20260404_120000', []); + expect(result, endsWith('.png')); + }); + + test('連番が連続して欠けている場合(_1 が欠落),_2 ではなく _1 を返す', () { + // 仕様: suffix=1 から順に探すため _1 が存在しなければ _1 を返す + final result = FileService.generateFileNameSync('20260404_120000', [ + 'MiniTIAS_20260404_120000.png', + 'MiniTIAS_20260404_120000_2.png', // _1 が存在しない状態 + ]); + expect(result, 'MiniTIAS_20260404_120000_1.png'); + }); + + test('連番が多数存在する場合,次の番号を付与する', () { + final existing = [ + 'MiniTIAS_20260404_120000.png', + 'MiniTIAS_20260404_120000_1.png', + 'MiniTIAS_20260404_120000_2.png', + 'MiniTIAS_20260404_120000_3.png', + 'MiniTIAS_20260404_120000_4.png', + ]; + final result = FileService.generateFileNameSync( + '20260404_120000', + existing, + ); + expect(result, 'MiniTIAS_20260404_120000_5.png'); + }); + + test('空のタイムスタンプでも形式を維持する', () { + final result = FileService.generateFileNameSync('', []); + expect(result, 'MiniTIAS_.png'); + }); }); + + group('FileService.saveImage', () { + late Directory tempDir; + late FileService service; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('mini_tias_test_'); + service = _FileServiceTestable(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('PNG バイト列を渡すと,ファイルパスを返す', () async { + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + final path = await service.saveImage(pngBytes); + expect(path, isNotEmpty); + }); + + test('PNG バイト列を渡すと,.png 拡張子のファイルが生成される', () async { + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + final path = await service.saveImage(pngBytes); + expect(path, endsWith('.png')); + }); + + test('PNG バイト列を渡すと,ファイル名が MiniTIAS_ プレフィックスを持つ', () async { + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + final path = await service.saveImage(pngBytes); + final fileName = path.split('/').last; + expect(fileName, startsWith('MiniTIAS_')); + }); + + test('渡したバイト列がそのままファイルに書き込まれる', () async { + final pngBytes = Uint8List.fromList([137, 80, 78, 71, 13, 10, 26, 10]); + final path = await service.saveImage(pngBytes); + final written = await File(path).readAsBytes(); + expect(written, equals(pngBytes)); + }); + + test('空のバイト列でもファイルを生成できる', () async { + final pngBytes = Uint8List(0); + final path = await service.saveImage(pngBytes); + expect(await File(path).exists(), isTrue); + final written = await File(path).readAsBytes(); + expect(written.length, 0); + }); + + test('同秒に 2 回呼び出すと,2 つ目のファイル名に _1 サフィックスが付く', () async { + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + // 最初の saveImage でファイルを作成 + final path1 = await service.saveImage(pngBytes); + // 同秒内に再度呼び出す(ファイルが既に存在する状態を擬似的に作る) + // generateFileName はファイルシステムを参照するため,1 ファイル作成後に呼ぶ + final path2 = await service.saveImage(pngBytes); + + final fileName1 = path1.split('/').last; + final fileName2 = path2.split('/').last; + + // 2 つのファイル名は異なるはず + // (同秒の場合 _1 サフィックスが付く,異なる秒の場合も名前が異なる) + if (fileName1.replaceAll(RegExp(r'_\d+\.png$'), '.png') == + fileName2.replaceAll(RegExp(r'_\d+\.png$'), '.png')) { + // 同秒の場合: 2 つ目は _1 サフィックスを持つはず + expect(fileName2, contains('_1.png')); + } else { + // 異なる秒の場合: それぞれ独立したタイムスタンプ付きファイル名 + expect(fileName1, isNot(equals(fileName2))); + } + }); + + test('ディレクトリが存在しない場合でも自動作成してファイルを保存する', () async { + final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); + final serviceWithSubDir = _FileServiceTestable(nonExistentSubDir.path); + expect(await nonExistentSubDir.exists(), isFalse); + + final pngBytes = Uint8List.fromList([0, 1, 2, 3]); + final path = await serviceWithSubDir.saveImage(pngBytes); + expect(await File(path).exists(), isTrue); + }); + }); + + group('FileService.directoryPath', () { + test('保存先ディレクトリのパスを返す', () { + final service = FileService(); + expect(service.directoryPath, '/storage/emulated/0/Pictures/MiniTIAS'); + }); + }); +} + +/// テスト用: 保存先ディレクトリをオーバーライドできる FileService サブクラス. +class _FileServiceTestable extends FileService { + _FileServiceTestable(this._testBasePath); + + final String _testBasePath; + + @override + String get directoryPath => _testBasePath; + + @override + Future saveImage(Uint8List pngBytes) async { + final directory = Directory(_testBasePath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + final fileName = await _generateFileNameForTest(); + final filePath = '$_testBasePath/$fileName'; + await File(filePath).writeAsBytes(pngBytes); + return filePath; + } + + Future _generateFileNameForTest() async { + final now = DateTime.now(); + final timestamp = + '${now.year}' + '${now.month.toString().padLeft(2, '0')}' + '${now.day.toString().padLeft(2, '0')}' + '_' + '${now.hour.toString().padLeft(2, '0')}' + '${now.minute.toString().padLeft(2, '0')}' + '${now.second.toString().padLeft(2, '0')}'; + + final baseName = 'MiniTIAS_$timestamp'; + final candidate = '$baseName.png'; + + if (!await File('$_testBasePath/$candidate').exists()) { + return candidate; + } + + var suffix = 1; + while (await File('$_testBasePath/${baseName}_$suffix.png').exists()) { + suffix++; + } + return '${baseName}_$suffix.png'; + } }