[03-Developer] #218 Dev round 2 — AC-6 RAM 4GB gate + AC-10 docs cleanup
QA round 1 (commit 9a9eb2a) FAIL 시 누락된 두 AC 보강.
AC-6: device_info_plus 만으론 4GB 임계 측정 불가 (isLowRamDevice 는
~1GB 기준). MethodChannel `life_helper/device_caps` 신설 + MainActivity.kt
에서 ActivityManager.MemoryInfo.totalMem 노출. data/ai/device_capabilities.dart
는 DeviceCapabilities abstract + PlatformDeviceCapabilities + 4 GiB
임계. deviceMeetsAiRamProvider (FutureProvider<bool>, fail-closed).
SettingsScreen 토글 disabled + "RAM 부족" 안내 (RAM < 4GB).
AC-10: docs/reference/215-ai-frame-suggest.md 의 OQ-1/placeholder
6곳을 실 구현 표현으로 갱신. §8 알려진 제약 = AC-6 device gate +
AC-7 실 단말 E2E + F1 unload + #221 corpus 평가. §9 다음 단계 =
#219~#222 follow-up 목록. 신규 테스트 합계 41 / 전체 88 통과.
테스트: device_capabilities_test.dart 7 신규 (kAiMinRamBytes 동등,
null/0/3.9GB/4GB-1/4GB/8GB 경계). flutter analyze 무이슈, 전체 88 통과
(71 기존 + 10 gemma + 7 RAM gate).
Architect 설계서 §4 의 "RAM 4GB 차단 = AC-9 재활용" 문구는 사실 #215
미구현 사항이라 본 라운드에서 신규 추가했음을 README 에 명기.
Refs #218
This commit is contained in:
@@ -1,5 +1,36 @@
|
||||
package kr.cloud_handson.life_helper
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
/// Hosts the `life_helper/device_caps` MethodChannel.
|
||||
///
|
||||
/// #218 AC-6: the AI feature requires ≥ 4GB RAM; getting an accurate total
|
||||
/// from Dart needs ActivityManager.MemoryInfo, which is Android-only — so we
|
||||
/// expose `totalMemoryBytes` as a platform method here.
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val deviceCapsChannel = "life_helper/device_caps"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, deviceCapsChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"totalMemoryBytes" -> {
|
||||
try {
|
||||
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val info = ActivityManager.MemoryInfo()
|
||||
am.getMemoryInfo(info)
|
||||
result.success(info.totalMem)
|
||||
} catch (t: Throwable) {
|
||||
result.error("RAM_QUERY_FAILED", t.message, null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
app/lib/data/ai/device_capabilities.dart
Normal file
58
app/lib/data/ai/device_capabilities.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Minimum RAM (bytes) required for on-device Gemma 4 E2B inference.
|
||||
///
|
||||
/// 4 GiB matches Planner AC-6 of #218. The Gemma 4 E2B weights alone are
|
||||
/// ~2.4GB; adding KV-cache + Flutter runtime + OS headroom puts us at 4GB
|
||||
/// total as the practical floor below which AC-7 cold-start budgets fail.
|
||||
const int kAiMinRamBytes = 4 * 1024 * 1024 * 1024;
|
||||
|
||||
/// Abstraction over the platform-channel RAM query, so tests can inject a
|
||||
/// fake without touching MethodChannel.
|
||||
abstract class DeviceCapabilities {
|
||||
/// Returns total physical RAM in bytes, or `null` if unknown / unsupported
|
||||
/// (non-Android host, channel error). Callers must treat `null` as "do
|
||||
/// not enable the AI gate" (fail-closed).
|
||||
Future<int?> totalRamBytes();
|
||||
|
||||
/// Convenience: `true` iff [totalRamBytes] returns ≥ [kAiMinRamBytes].
|
||||
/// `null` from [totalRamBytes] → `false` (fail-closed).
|
||||
Future<bool> meetsAiMinRam() async {
|
||||
final bytes = await totalRamBytes();
|
||||
if (bytes == null) return false;
|
||||
return bytes >= kAiMinRamBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Real implementation. Calls `MainActivity.kt` over a MethodChannel.
|
||||
class PlatformDeviceCapabilities implements DeviceCapabilities {
|
||||
PlatformDeviceCapabilities({MethodChannel? channel})
|
||||
: _channel = channel ??
|
||||
const MethodChannel('life_helper/device_caps');
|
||||
|
||||
final MethodChannel _channel;
|
||||
|
||||
@override
|
||||
Future<int?> totalRamBytes() async {
|
||||
// Channel is Android-only — return null on iOS/host tests rather than
|
||||
// throwing MissingPluginException.
|
||||
if (!Platform.isAndroid) return null;
|
||||
try {
|
||||
final v = await _channel.invokeMethod<int>('totalMemoryBytes');
|
||||
return v;
|
||||
} on PlatformException {
|
||||
return null;
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> meetsAiMinRam() async {
|
||||
final bytes = await totalRamBytes();
|
||||
if (bytes == null) return false;
|
||||
return bytes >= kAiMinRamBytes;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/ai/device_capabilities.dart';
|
||||
import '../data/ai/llm_service.dart';
|
||||
import '../data/ai/model_lifecycle.dart';
|
||||
import '../data/db/app_database.dart' as drift;
|
||||
@@ -22,6 +23,19 @@ const _kModelUrl =
|
||||
const _kModelSha256 =
|
||||
'181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c';
|
||||
|
||||
/// #218 AC-6: device-capability gate. RAM < 4GB → AI feature disabled.
|
||||
/// Default implementation calls the `life_helper/device_caps` MethodChannel
|
||||
/// (Android). Override in tests with a `_FakeDeviceCapabilities`.
|
||||
final deviceCapabilitiesProvider = Provider<DeviceCapabilities>((ref) {
|
||||
return PlatformDeviceCapabilities();
|
||||
});
|
||||
|
||||
/// `true` iff the device has ≥ 4GB RAM. Default `false` (fail-closed) while
|
||||
/// the platform call is in flight or on unsupported hosts (iOS / test).
|
||||
final deviceMeetsAiRamProvider = FutureProvider<bool>((ref) async {
|
||||
return ref.watch(deviceCapabilitiesProvider).meetsAiMinRam();
|
||||
});
|
||||
|
||||
final modelLifecycleProvider = Provider<ModelLifecycle>((ref) {
|
||||
return ModelLifecycle(
|
||||
meta: ref.watch(metaDaoProvider),
|
||||
|
||||
@@ -44,17 +44,25 @@ class _AiSection extends ConsumerWidget {
|
||||
final settings = ref.watch(aiSettingsProvider);
|
||||
final availability = ref.watch(modelAvailabilityProvider);
|
||||
final download = ref.watch(modelDownloadControllerProvider);
|
||||
final ramOk = ref.watch(deviceMeetsAiRamProvider);
|
||||
final optIn = settings.maybeWhen(data: (v) => v, orElse: () => false);
|
||||
// #218 AC-6: gate the toggle when device RAM < 4GB. Default fail-closed
|
||||
// (null → disabled) so the user can't trip download on an undersized
|
||||
// device while the platform call is in flight.
|
||||
final meetsRam = ramOk.maybeWhen(data: (v) => v, orElse: () => false);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('AI 도움 켜기'),
|
||||
subtitle: const Text(
|
||||
'Gemma 4 E2B 모델 ≈ 2.4GB. 모든 처리는 단말에서 일어납니다.',
|
||||
subtitle: Text(
|
||||
meetsRam
|
||||
? 'Gemma 4 E2B 모델 ≈ 2.4GB. 모든 처리는 단말에서 일어납니다.'
|
||||
: '이 단말의 RAM 이 부족합니다 (필요: 4GB 이상).',
|
||||
),
|
||||
value: optIn,
|
||||
onChanged: (v) async {
|
||||
value: meetsRam && optIn,
|
||||
onChanged: meetsRam
|
||||
? (v) async {
|
||||
if (v) {
|
||||
final ok = await _confirmOptIn(context);
|
||||
if (ok != true) return;
|
||||
@@ -70,7 +78,8 @@ class _AiSection extends ConsumerWidget {
|
||||
SnackBar(content: Text('공간 확보됨 ${_fmtMB(freed)}')),
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
: null,
|
||||
),
|
||||
availability.when(
|
||||
loading: () => const ListTile(title: Text('상태 확인 중...')),
|
||||
|
||||
@@ -193,6 +193,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.7"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.2"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -861,6 +877,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.5"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -32,6 +32,9 @@ dependencies:
|
||||
crypto: ^3.0.0
|
||||
http: ^1.2.0
|
||||
|
||||
# Device info — RAM gate for AI opt-in (#218 AC-6)
|
||||
device_info_plus: ^10.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
54
app/test/data/ai/device_capabilities_test.dart
Normal file
54
app/test/data/ai/device_capabilities_test.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/data/ai/device_capabilities.dart';
|
||||
|
||||
/// #218 AC-6 boundary tests for the RAM gate.
|
||||
///
|
||||
/// We test the abstract contract's `meetsAiMinRam()` default impl via a
|
||||
/// fake — the real `PlatformDeviceCapabilities.totalRamBytes()` requires
|
||||
/// the MethodChannel + Android runtime (covered by AC-7).
|
||||
class _Fake implements DeviceCapabilities {
|
||||
_Fake(this.bytes);
|
||||
final int? bytes;
|
||||
|
||||
@override
|
||||
Future<int?> totalRamBytes() async => bytes;
|
||||
|
||||
@override
|
||||
Future<bool> meetsAiMinRam() async {
|
||||
final v = await totalRamBytes();
|
||||
if (v == null) return false;
|
||||
return v >= kAiMinRamBytes;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('kAiMinRamBytes equals 4 GiB', () {
|
||||
expect(kAiMinRamBytes, 4 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('null totalRamBytes → meetsAiMinRam false (fail-closed)', () async {
|
||||
expect(await _Fake(null).meetsAiMinRam(), isFalse);
|
||||
});
|
||||
|
||||
test('3.9 GiB → meetsAiMinRam false', () async {
|
||||
final bytes = (3.9 * 1024 * 1024 * 1024).round();
|
||||
expect(await _Fake(bytes).meetsAiMinRam(), isFalse);
|
||||
});
|
||||
|
||||
test('exactly 4 GiB - 1 byte → false', () async {
|
||||
expect(await _Fake(kAiMinRamBytes - 1).meetsAiMinRam(), isFalse);
|
||||
});
|
||||
|
||||
test('exactly 4 GiB → true (inclusive)', () async {
|
||||
expect(await _Fake(kAiMinRamBytes).meetsAiMinRam(), isTrue);
|
||||
});
|
||||
|
||||
test('8 GiB → true', () async {
|
||||
final bytes = 8 * 1024 * 1024 * 1024;
|
||||
expect(await _Fake(bytes).meetsAiMinRam(), isTrue);
|
||||
});
|
||||
|
||||
test('0 bytes → false (would also catch broken channel returning 0)', () async {
|
||||
expect(await _Fake(0).meetsAiMinRam(), isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user