173 lines
6.6 KiB
Dart
173 lines
6.6 KiB
Dart
import 'dart:convert';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'device_service.dart';
|
||
import '../secrets.dart';
|
||
|
||
/// ライセンス状態
|
||
enum LicenseStatus {
|
||
/// 無料版(AI解析は1日50回まで利用可能)
|
||
free,
|
||
|
||
/// Pro版ライセンス有効(Pro機能すべて解放)
|
||
pro,
|
||
|
||
/// ライセンス無効化済み(不正利用・返金等)
|
||
revoked,
|
||
|
||
/// オフライン / サーバー疎通不可(キャッシュ利用)
|
||
offline,
|
||
}
|
||
|
||
/// ライセンス管理サービス
|
||
///
|
||
/// ## 状態管理の優先順位
|
||
/// 1. オンライン時: VPSに問い合わせた結果を使用 + SharedPreferencesにキャッシュ
|
||
/// 2. オフライン時: SharedPreferencesのキャッシュを使用 (Pro状態を維持)
|
||
///
|
||
/// ## ライセンスキー形式
|
||
/// PONSHU-XXXX-XXXX-XXXX (6バイト = 12hex文字, 大文字)
|
||
class LicenseService {
|
||
static const _prefLicenseKey = 'ponshu_license_key';
|
||
static const _prefCachedStatus = 'ponshu_license_status_cache';
|
||
static const _prefCachedAt = 'ponshu_license_cached_at';
|
||
static const _cacheValidSeconds = 24 * 60 * 60; // 24時間キャッシュ有効
|
||
|
||
// ========== Public API ==========
|
||
|
||
/// アプリ起動時に呼ぶ: ライセンス状態を確認して返す
|
||
static Future<LicenseStatus> checkStatus() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final savedKey = prefs.getString(_prefLicenseKey) ?? '';
|
||
|
||
// ライセンスキーが保存済み → サーバーで検証
|
||
if (savedKey.isNotEmpty) {
|
||
try {
|
||
final status = await _validateKeyWithServer(savedKey);
|
||
if (status == LicenseStatus.offline) {
|
||
// ネットワーク不通: キャッシュを上書きせずに返す
|
||
debugPrint('[License] Server unreachable, using cache');
|
||
return _getCachedStatus(prefs);
|
||
}
|
||
await _cacheStatus(prefs, status);
|
||
return status;
|
||
} catch (e) {
|
||
debugPrint('[License] Server unreachable, using cache: $e');
|
||
return _getCachedStatus(prefs);
|
||
}
|
||
}
|
||
|
||
// ライセンスキーなし → 無料版
|
||
return LicenseStatus.free;
|
||
}
|
||
|
||
/// ライセンスキーをアクティベートする
|
||
///
|
||
/// 成功: true + 空メッセージ
|
||
/// 失敗: false + エラーメッセージ
|
||
static Future<({bool success, String message})> activate(String rawKey) async {
|
||
final key = rawKey.trim().toUpperCase();
|
||
|
||
if (!_isValidKeyFormat(key)) {
|
||
return (success: false, message: 'キーの形式が正しくありません\n(例: PONSHU-XXXX-XXXX-XXXX)');
|
||
}
|
||
|
||
final status = await _validateKeyWithServer(key);
|
||
|
||
if (status == LicenseStatus.pro) {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString(_prefLicenseKey, key);
|
||
await _cacheStatus(prefs, LicenseStatus.pro);
|
||
debugPrint('[License] Activated successfully.');
|
||
return (success: true, message: '');
|
||
}
|
||
|
||
if (status == LicenseStatus.revoked) {
|
||
return (success: false, message: 'このライセンスは無効化されています。\nサポートにお問い合わせください。');
|
||
}
|
||
|
||
if (status == LicenseStatus.offline) {
|
||
return (success: false, message: 'サーバーに接続できませんでした。\nネットワーク接続を確認してください。');
|
||
}
|
||
|
||
return (success: false, message: 'ライセンスキーが見つかりません。\nご購入時のメールをご確認ください。');
|
||
}
|
||
|
||
/// ライセンスキーがローカルに保存されているか
|
||
static Future<bool> hasLicenseKey() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
return (prefs.getString(_prefLicenseKey) ?? '').isNotEmpty;
|
||
}
|
||
|
||
/// ライセンスをリセット(デバッグ用)
|
||
static Future<void> reset() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.remove(_prefLicenseKey);
|
||
await prefs.remove(_prefCachedStatus);
|
||
await prefs.remove(_prefCachedAt);
|
||
debugPrint('[License] Reset complete.');
|
||
}
|
||
|
||
// ========== Private Helpers ==========
|
||
|
||
static bool _isValidKeyFormat(String key) {
|
||
final regex = RegExp(r'^PONSHU-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$');
|
||
return regex.hasMatch(key);
|
||
}
|
||
|
||
static Future<LicenseStatus> _validateKeyWithServer(String key) async {
|
||
try {
|
||
final deviceId = await DeviceService.getDeviceId();
|
||
final response = await http.post(
|
||
Uri.parse('${Secrets.posimaiBaseUrl}/api/ponshu/license/validate'),
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: jsonEncode({'license_key': key, 'device_id': deviceId}),
|
||
).timeout(const Duration(seconds: 15));
|
||
|
||
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
|
||
|
||
if (data['valid'] == true) return LicenseStatus.pro;
|
||
if ((data['error'] as String? ?? '').contains('無効化')) return LicenseStatus.revoked;
|
||
return LicenseStatus.free;
|
||
|
||
} catch (e) {
|
||
debugPrint('[License] Validation network error: $e');
|
||
return LicenseStatus.offline;
|
||
}
|
||
}
|
||
|
||
static Future<void> _cacheStatus(SharedPreferences prefs, LicenseStatus status) async {
|
||
await prefs.setString(_prefCachedStatus, status.name);
|
||
await prefs.setString(_prefCachedAt, DateTime.now().toIso8601String());
|
||
}
|
||
|
||
static LicenseStatus _getCachedStatus(SharedPreferences prefs) {
|
||
final cached = prefs.getString(_prefCachedStatus);
|
||
final cachedAt = prefs.getString(_prefCachedAt);
|
||
|
||
if (cached == null) return LicenseStatus.free;
|
||
|
||
// オンライン時は _validateKeyWithServer が常に上書きするため、
|
||
// _getCachedStatus はオフライン時専用のフォールバックとして動作する。
|
||
//
|
||
// TTL 判定(_cacheValidSeconds = 24h):
|
||
// - free / offline は期限切れで free にフォールバック
|
||
// - pro : 購入者をオフライン時に締め出さないため永続扱い
|
||
// - revoked: 不正防止を優先するため永続扱い
|
||
// (将来 TTL を設けたい場合は isNoExpiryStatus を条件分岐ごと差し替える)
|
||
if (cachedAt != null) {
|
||
final age = DateTime.now().difference(DateTime.parse(cachedAt));
|
||
final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
|
||
if (age.inSeconds > _cacheValidSeconds && !isNoExpiryStatus) {
|
||
return LicenseStatus.free;
|
||
}
|
||
}
|
||
|
||
return LicenseStatus.values.firstWhere(
|
||
(s) => s.name == cached,
|
||
orElse: () => LicenseStatus.free,
|
||
);
|
||
}
|
||
}
|