【Flutter】アプリのクラッシュログまたはエラーを送信する

# 前言
クラッシュレポートなどの回収はFirebase Crashlyticsの利用はおすすめですが、この記事ではFirebase Crashlyticsを利用しなくて、自分のサーバーへ送信したいときの実装案について述べます。

https://pub.dev/packages/firebase_crashlytics

# 例外キャッチ
- FlutterError.onError
主にUIの構築エラーをキャッチできる
- runZonedGuarded
null pointerエラー、リストのインデックス超過エラーとかtry catchしていないエラーはrunZonedGuardedでキャッチできる
```dart
void main() async {
 await runZonedGuarded(() async {
    WidgetsFlutterBinding.ensureInitialized();
    //Flutterでキャッチされた例外/エラー
    FlutterError.onError = (errorDetails) {
      logger.e(errorDetails);
    };
    runApp();
  }, (error, stackTrace) async {
    //Flutterでキャッチされなかった例外/エラー
    logger.e(error);
  });
}
```

# ログ送信
エラーキャッチできたため、アプリの強制終了がなく、WebApi経由でエラーログを送信することが可能です。
```dart
  final String apiUrl = 'YOUR_API_URL';

  try {
    await http.post(
      Uri.parse(apiUrl),
      headers: {'Content-Type': 'application/json'},
      body: '{"error": "$errorMessage"}',
    );
  } catch (e) {
    print('送信失敗: $e');
  }
```

# logger
https://pub.dev/packages/logger

上記のSDKを継承することで、ログを出力処理すると共にログの送信処理を行います。
```dart
class RemoteLogOutput extends LogOutput {
  @override
  void output(OutputEvent event) async {
    try {
      final logMessage = event.lines.join('\n');
      print(logMessage);
      // 省略
      // Web Apiでログ送信処理
    } catch (e) {
      print('Failed to send log to remote server: $e');
    }
  }
}
```

# キャッチできなかった例外
キャッチできなかった例外またはクラッシュ発生した時、ログ送信処理終了する前、アプリが強制終了される場合、クラッシュレポートまたはエラーログをローカルに保存し、次回アプリを開くとき、ログ送信処理を行います。

Flutterでクラッシュキャッチできないため、iOSとAndroidそれぞれの実装となります。
- iOS
ios/Runner/AppDelegate.swift
上記ファイルに下記のソースを追加する
```swift
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {

    NSSetUncaughtExceptionHandler { exception in
      // ログを保存する
      AppDelegate.writeCrashLogToFile(exception: exception)
    }

    GeneratedPluginRegistrant.register(with: self)

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  // クラッシュ発生時ログファイルをローカルに保存する
  static func writeCrashLogToFile(exception: NSException) {
    // UIApplication.shared.delegate のAppDelegateインスタンスを取得
    guard let self = UIApplication.shared.delegate as? AppDelegate else { return }
    let fileManager = FileManager.default
    let urls = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
    if let crashLogFile = urls.first?.appendingPathComponent("crash_log.txt") {
        let crashLog = """
            Name: \(exception.name.rawValue)
            Reason: \(String(describing: exception.reason))
            UserInfo: \(exception.userInfo)
            StackTrace: \(exception.callStackSymbols.joined(separator: "\n"))

            """
        do {
            if fileManager.fileExists(atPath: crashLogFile.path) {
                let fileHandle = try FileHandle(forWritingTo: crashLogFile)
                fileHandle.seekToEndOfFile()
                if let crashLogData = crashLog.data(using: .utf8) {
                    fileHandle.write(crashLogData)
                }
                fileHandle.closeFile()
            } else {
                try crashLog.write(to: crashLogFile, atomically: true, encoding: .utf8)
            }
        } catch {
            print("Error writing crash log to file: \(error)")
        }
    }
  }
}

```

- Android
    - android/app/src/main/AndroidManifest.xml
上記ファイルにストレージの保存と読み取り権限を追加します。
    ```xml
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    ```
    - android/app/src/main/kotlin/YOU_APP_ID/CrashLogUtils.kt
    上記のファイルを作成する
    ```kotlin
    package YOU_APP_ID // 自分のAPPID com.xxx.xxx
    
    import android.content.Context
    import android.util.Log
    import java.io.File
    import java.io.FileOutputStream
    import java.io.PrintWriter
    import java.io.StringWriter

    object CrashLogUtils {
        private var isCrashLogSaved = false

        fun saveCrashLog(context: Context, crashLog: String) {
            if (isCrashLogSaved) {
                return
            }

            var outputStream: FileOutputStream? = null

            try {
                val crashLogDir = getCrashLogDirectory(context)
                if (crashLogDir != null) {
                    val crashLogFile = File(crashLogDir, "crash_log.txt")
                    outputStream = FileOutputStream(crashLogFile, true)
                    outputStream.write(crashLog.toByteArray())
                        Log.d("CrashLogUtils", "Crash log saved: " + crashLogFile.absolutePath)
                    isCrashLogSaved = true
                } else {
                    Log.e("CrashLogUtils", "Failed to create crash log directory")
                }
            } catch (e: Exception) {
                e.printStackTrace()
                Log.e("CrashLogUtils", "Error saving crash log: " + e.message)
            } finally {
                outputStream?.close()
            }
        }

        private fun getCrashLogDirectory(context: Context): File? {
            val directory = File(context.getExternalFilesDir(null), "crash_logs")
            if (!directory.exists()) {
                if (!directory.mkdirs()) {
                    return null
                }
            }
            return directory
        }
    }
    ```
    - android/app/src/main/kotlin/YOU_APP_ID/MainActivity.kt
    ```kotlin
    package YOU_APP_ID // 自分のAPPID com.xxx.xxx

    import android.content.Context
    import android.os.Bundle
    import androidx.annotation.NonNull
    import io.flutter.embedding.android.FlutterActivity
    import io.flutter.embedding.engine.FlutterEngine
    import io.flutter.plugins.GeneratedPluginRegistrant
    import java.io.PrintWriter
    import java.io.StringWriter

    class MainActivity : FlutterActivity() {
        override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
            GeneratedPluginRegistrant.registerWith(flutterEngine)
            Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(this))
        }

        private class CustomUncaughtExceptionHandler(private val context: Context) :
            Thread.UncaughtExceptionHandler {
            override fun uncaughtException(thread: Thread, throwable: Throwable) {
                val crashLog = getStackTraceAsString(throwable)
                CrashLogUtils.saveCrashLog(context, crashLog)
            }

            private fun getStackTraceAsString(throwable: Throwable): String {
                val sw = StringWriter()
                val pw = PrintWriter(sw)
                throwable.printStackTrace(pw)
                return sw.toString()
            }
        }
    }

    ```

# ストレージから読み取り
クラッシュログの保存と違って、読み取りはFlutterで実現できます。`path_provider`を使います。

https://pub.dev/packages/path_provider

```dart
  /// ドキュメント保存ディレクトリ
  static Future<String> getFilePath() async {
    final directory = await getApplicationDocumentsDirectory();
    return directory.path;
  }
  /// 保存したファイルからテキストを取得する
  static Future<String> readFromFile(String fileName) async {
    final path = await getCrashLogFilePath();
    final file = File('$path/$fileName.txt');
    if (await file.exists()) {
      return await file.readAsString();
    }
    return '';
  }

  /// ファイル削除
  static Future<void> deleteFile(String fileName) async {
    final path = await getCrashLogFilePath();
    final file = File('$path/$fileName.txt');
    if (await file.exists()) {
      await file.delete();
    }
  }

  /// ファイル保存ディレクトリ
  static Future<String> getCrashLogFilePath() async {
    if (Platform.isIOS) {
      final path = await getFilePath();
      return path;
    }
    final directory = await getExternalStorageDirectory();
    final crashLogFilePath = '${directory?.path}/crash_logs';
    return crashLogFilePath;
  }
```