2026-04-10 15:05:53 +00:00
|
|
|
|
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状態を維持)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// ## ライセンスキー形式
|
2026-04-10 21:42:35 +00:00
|
|
|
|
/// PONSHU-XXXX-XXXX-XXXX (6バイト = 12hex文字, 大文字)
|
2026-04-10 15:05:53 +00:00
|
|
|
|
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);
|
2026-04-10 21:42:35 +00:00
|
|
|
|
if (status == LicenseStatus.offline) {
|
|
|
|
|
|
// ネットワーク不通: キャッシュを上書きせずに返す
|
|
|
|
|
|
debugPrint('[License] Server unreachable, using cache');
|
|
|
|
|
|
return _getCachedStatus(prefs);
|
|
|
|
|
|
}
|
2026-04-10 15:05:53 +00:00
|
|
|
|
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サポートにお問い合わせください。');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 21:42:35 +00:00
|
|
|
|
if (status == LicenseStatus.offline) {
|
|
|
|
|
|
return (success: false, message: 'サーバーに接続できませんでした。\nネットワーク接続を確認してください。');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
|
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(
|
2026-04-10 15:16:52 +00:00
|
|
|
|
Uri.parse('${Secrets.posimaiBaseUrl}/api/ponshu/license/validate'),
|
2026-04-10 15:05:53 +00:00
|
|
|
|
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;
|
2026-04-10 15:16:52 +00:00
|
|
|
|
return LicenseStatus.free;
|
2026-04-10 15:05:53 +00:00
|
|
|
|
|
|
|
|
|
|
} 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;
|
|
|
|
|
|
|
|
|
|
|
|
// キャッシュが古すぎる場合はfreeにフォールバック
|
2026-04-10 23:14:37 +00:00
|
|
|
|
// pro と revoked は期限切れにしない(proは購入者を締め出さない、revokedは誤って復活させない)
|
2026-04-10 15:05:53 +00:00
|
|
|
|
if (cachedAt != null) {
|
|
|
|
|
|
final age = DateTime.now().difference(DateTime.parse(cachedAt));
|
2026-04-10 23:14:37 +00:00
|
|
|
|
final isPermanentStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
|
|
|
|
|
|
if (age.inSeconds > _cacheValidSeconds && !isPermanentStatus) {
|
2026-04-10 15:05:53 +00:00
|
|
|
|
return LicenseStatus.free;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Pro キャッシュはオフラインでも維持(購入者を締め出さない)
|
|
|
|
|
|
return LicenseStatus.values.firstWhere(
|
|
|
|
|
|
(s) => s.name == cached,
|
|
|
|
|
|
orElse: () => LicenseStatus.free,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|