Skip to content

Commit 15c663a

Browse files
committed
feat(nfc): 添加flutter_nfc_kit支持并优化NFC认证流程
实现NFC卡片认证功能,替换原有nfc_manager实现 添加卡片录入界面和认证对话框 优化Android原生NFC处理逻辑
1 parent 470c35a commit 15c663a

File tree

9 files changed

+390
-120
lines changed

9 files changed

+390
-120
lines changed

android/app/build.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ android {
2424
applicationId = "com.example.classaware"
2525
// You can update the following values to match your application needs.
2626
// For more information, see: https://flutter.dev/to/review-gradle-config.
27-
minSdk = flutter.minSdkVersion
27+
minSdk = 26
2828
targetSdk = flutter.targetSdkVersion
2929
versionCode = flutter.versionCode
3030
versionName = flutter.versionName
@@ -42,3 +42,8 @@ android {
4242
flutter {
4343
source = "../.."
4444
}
45+
46+
dependencies {
47+
implementation("androidx.activity:activity:1.9.2")
48+
implementation("androidx.activity:activity-ktx:1.9.2")
49+
}

android/app/src/main/kotlin/com/example/classaware/MainActivity.kt

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.example.classaware
33
import android.content.ComponentName
44
import android.content.Context
55
import android.content.Intent
6+
import android.app.PendingIntent
7+
import android.nfc.NfcAdapter
68
import android.content.pm.LauncherApps
79
import android.content.pm.PackageManager
810
import android.content.pm.ResolveInfo
@@ -17,6 +19,7 @@ import io.flutter.embedding.android.FlutterActivity
1719
import io.flutter.embedding.engine.FlutterEngine
1820
import io.flutter.plugin.common.MethodChannel
1921
import java.io.ByteArrayOutputStream
22+
import im.nfc.flutter_nfc_kit.FlutterNfcKitPlugin
2023

2124
class MainActivity : FlutterActivity() {
2225
private val CHANNEL = "com.example.classaware/launcher"
@@ -38,12 +41,52 @@ class MainActivity : FlutterActivity() {
3841
result.error("ERROR", "Failed to get launchable apps", e.message)
3942
}
4043
}
44+
"getAppIcon" -> {
45+
try {
46+
val packageName = call.argument<String>("packageName")
47+
if (packageName.isNullOrEmpty()) {
48+
result.error("ERROR", "Missing packageName", null)
49+
} else {
50+
Thread {
51+
try {
52+
val bytes = getApplicationIconBytes(packageName)
53+
runOnUiThread { result.success(bytes) }
54+
} catch (e: Exception) {
55+
runOnUiThread { result.error("ERROR", "Failed to get app icon", e.message) }
56+
}
57+
}.start()
58+
}
59+
} catch (e: Exception) {
60+
result.error("ERROR", "Failed to get app icon", e.message)
61+
}
62+
}
4163
else -> {
4264
result.notImplemented()
4365
}
4466
}
4567
}
4668
}
69+
70+
override fun onResume() {
71+
super.onResume()
72+
val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(this)
73+
val pendingIntent: PendingIntent = PendingIntent.getActivity(
74+
this, 0, Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), PendingIntent.FLAG_MUTABLE
75+
)
76+
adapter?.enableForegroundDispatch(this, pendingIntent, null, null)
77+
}
78+
79+
override fun onPause() {
80+
super.onPause()
81+
val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(this)
82+
adapter?.disableForegroundDispatch(this)
83+
}
84+
85+
override fun onNewIntent(intent: Intent) {
86+
super.onNewIntent(intent)
87+
val tag: android.nfc.Tag? = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
88+
tag?.apply(FlutterNfcKitPlugin::handleTag)
89+
}
4790

4891
// 使用LauncherApps服务获取应用 - 参考Lawnchair实现
4992
private fun getLaunchableAppsWithLauncherService(): List<Map<String, String>> {
@@ -70,17 +113,11 @@ class MainActivity : FlutterActivity() {
70113

71114
try {
72115
val appName = activityInfo.label.toString()
73-
// 获取应用图标的Base64编码
74-
val iconBase64 = getAppIconBase64(componentName)
75-
76116
appList.add(mapOf(
77117
"name" to appName,
78-
"packageName" to packageName,
79-
"icon" to iconBase64
118+
"packageName" to packageName
80119
))
81-
} catch (e: Exception) {
82-
// 跳过无法获取信息的应用
83-
}
120+
} catch (_: Exception) {}
84121
}
85122
}
86123
} catch (e: Exception) {
@@ -91,19 +128,21 @@ class MainActivity : FlutterActivity() {
91128
return appList.sortedBy { it["name"] }
92129
}
93130

94-
/**
95-
* 获取应用图标的Base64编码
96-
*/
97-
private fun getAppIconBase64(componentName: ComponentName): String {
131+
private fun getApplicationIconBytes(packageName: String): ByteArray? {
98132
return try {
99-
val packageManager = packageManager
100-
val drawable = packageManager.getActivityIcon(componentName)
101-
val bitmap = drawableToBitmap(drawable)
133+
val pm = packageManager
134+
val drawable = pm.getApplicationIcon(packageName)
135+
val src = drawableToBitmap(drawable)
136+
val density = resources.displayMetrics.density
137+
val sizePx = (48 * density).toInt().coerceAtLeast(32)
138+
val bmp = if (src.width != sizePx || src.height != sizePx) {
139+
Bitmap.createScaledBitmap(src, sizePx, sizePx, true)
140+
} else src
102141
val outputStream = ByteArrayOutputStream()
103-
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
104-
Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
105-
} catch (e: Exception) {
106-
"" // 返回空字符串表示无图标
142+
bmp.compress(Bitmap.CompressFormat.PNG, 80, outputStream)
143+
outputStream.toByteArray()
144+
} catch (_: Exception) {
145+
null
107146
}
108147
}
109148

android/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ subprojects {
1919
project.evaluationDependsOn(":app")
2020
}
2121

22+
// Force a single version of androidx.activity to avoid duplicate R classes
23+
subprojects {
24+
configurations.all {
25+
resolutionStrategy {
26+
force(
27+
"androidx.activity:activity:1.9.2",
28+
"androidx.activity:activity-ktx:1.9.2"
29+
)
30+
}
31+
}
32+
}
33+
2234
tasks.register<Delete>("clean") {
2335
delete(rootProject.layout.buildDirectory)
2436
}

lib/main.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import 'screens/schedule_screen.dart';
77
import 'screens/settings_screen.dart';
88
import 'package:shared_preferences/shared_preferences.dart';
99
import 'services/auth_service.dart';
10+
import 'package:flutter_nfc_kit/flutter_nfc_kit.dart';
11+
import 'dart:async';
1012

1113
// 页面缓存包装器,防止页面重复构建
1214
class AutomaticKeepAliveWrapper extends StatefulWidget {
@@ -93,6 +95,7 @@ class _MainScreenState extends State<MainScreen> with TickerProviderStateMixin {
9395
final _selectIndex = ValueNotifier(0);
9496
bool _switchingPage = false;
9597
late final VoidCallback _authListener;
98+
StreamSubscription? _nfcSub;
9699

97100
final List<Widget> _screens = const [
98101
HomeScreen(),
@@ -109,13 +112,15 @@ class _MainScreenState extends State<MainScreen> with TickerProviderStateMixin {
109112
_pageController = PageController(initialPage: _selectIndex.value);
110113
_authListener = _onAuthActiveChanged;
111114
AuthService.instance.authActive.addListener(_authListener);
115+
_setupNfcEvents();
112116
}
113117

114118
@override
115119
void dispose() {
116120
_pageController.dispose();
117121
_selectIndex.dispose();
118122
AuthService.instance.authActive.removeListener(_authListener);
123+
_nfcSub?.cancel();
119124
super.dispose();
120125
}
121126

@@ -282,4 +287,19 @@ class _MainScreenState extends State<MainScreen> with TickerProviderStateMixin {
282287
),
283288
);
284289
}
290+
Future<void> _setupNfcEvents() async {
291+
final prefs = await SharedPreferences.getInstance();
292+
final allowNfc = prefs.getBool('auth_use_nfc') ?? false;
293+
final uids = prefs.getStringList('auth_nfc_uids') ?? const [];
294+
if (!allowNfc || uids.isEmpty) return;
295+
_nfcSub = FlutterNfcKit.tagStream.listen((tag) async {
296+
final id = (tag.id ?? '').trim();
297+
if (id.isEmpty) return;
298+
final prefs2 = await SharedPreferences.getInstance();
299+
final wl = prefs2.getStringList('auth_nfc_uids') ?? const [];
300+
if (wl.map((e) => e.toLowerCase()).contains(id.toLowerCase())) {
301+
await AuthService.instance.grantAdminSession();
302+
}
303+
});
304+
}
285305
}

lib/screens/apps_screen.dart

Lines changed: 71 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart';
44
import '../services/auth_service.dart';
55
import 'package:installed_apps/installed_apps.dart';
66
import 'package:flutter/services.dart';
7+
import 'package:flutter/foundation.dart';
78
import 'dart:convert';
89
import '../utils/logger.dart';
910

@@ -25,6 +26,8 @@ class _AppsScreenState extends State<AppsScreen> with AutomaticKeepAliveClientMi
2526

2627
// MethodChannel for native Android communication
2728
static const platform = MethodChannel('com.example.classaware/launcher');
29+
final Map<String, Uint8List?> _iconCache = {};
30+
final Map<String, Future<Uint8List?>> _iconFutures = {};
2831

2932
@override
3033
bool get wantKeepAlive => true; // 保持页面状态,避免重复加载应用列表
@@ -68,8 +71,12 @@ class _AppsScreenState extends State<AppsScreen> with AutomaticKeepAliveClientMi
6871

6972
for (var appData in launchableApps) {
7073
final Map<String, dynamic> app = Map<String, dynamic>.from(appData);
71-
72-
// 原生Android代码已经包含图标数据
74+
final iconStr = (app['icon'] ?? '').toString();
75+
if (iconStr.isNotEmpty) {
76+
try {
77+
app['iconBytes'] = base64Decode(iconStr);
78+
} catch (_) {}
79+
}
7380
apps.add(app);
7481
}
7582

@@ -238,9 +245,12 @@ class _AppsScreenState extends State<AppsScreen> with AutomaticKeepAliveClientMi
238245
SizedBox(height: 12.h),
239246
Expanded(
240247
child: ListView.builder(
248+
cacheExtent: 200,
241249
itemCount: quickApps.length,
242250
itemBuilder: (context, index) {
243251
final app = quickApps[index];
252+
final dpr = MediaQuery.of(context).devicePixelRatio;
253+
final cacheSize = (48.w * dpr).round();
244254
return RepaintBoundary( // 添加重绘边界优化
245255
child: GestureDetector(
246256
onTap: () => _launchApp(app),
@@ -257,27 +267,7 @@ class _AppsScreenState extends State<AppsScreen> with AutomaticKeepAliveClientMi
257267
),
258268
child: ClipRRect(
259269
borderRadius: BorderRadius.circular(12.r),
260-
child: app['icon'] != null && app['icon'].toString().isNotEmpty
261-
? Image.memory(
262-
base64Decode(app['icon']),
263-
width: 48.w,
264-
height: 48.w,
265-
fit: BoxFit.contain,
266-
cacheWidth: (48.w * MediaQuery.of(context).devicePixelRatio).round(),
267-
cacheHeight: (48.w * MediaQuery.of(context).devicePixelRatio).round(),
268-
errorBuilder: (context, error, stackTrace) {
269-
return Icon(
270-
Icons.apps,
271-
size: 24.sp,
272-
color: Theme.of(context).colorScheme.onSurfaceVariant,
273-
);
274-
},
275-
)
276-
: Icon(
277-
Icons.apps,
278-
size: 24.sp,
279-
color: Theme.of(context).colorScheme.onSurfaceVariant,
280-
),
270+
child: _buildAppIcon(app['packageName'] as String, 48.w, cacheSize, fallbackColor: Theme.of(context).colorScheme.onSurfaceVariant, fallbackSize: 24.sp),
281271
),
282272
),
283273
SizedBox(height: 4.h),
@@ -402,9 +392,12 @@ class _AppsScreenState extends State<AppsScreen> with AutomaticKeepAliveClientMi
402392
final textHeight = fontSize * 1.2 * 2;
403393
final tileHeight = iconSize + 4.h + textHeight + 12.0;
404394
final aspect = (minTile / tileHeight).clamp(0.65, 1.0);
395+
final dpr = MediaQuery.of(context).devicePixelRatio;
396+
final cacheSize = (iconSize * dpr).round();
405397
return GridView.builder(
406398
physics: const BouncingScrollPhysics(),
407399
shrinkWrap: true,
400+
cacheExtent: tileHeight * 2,
408401
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
409402
crossAxisCount: cols,
410403
childAspectRatio: aspect,
@@ -434,27 +427,7 @@ class _AppsScreenState extends State<AppsScreen> with AutomaticKeepAliveClientMi
434427
),
435428
child: ClipRRect(
436429
borderRadius: BorderRadius.circular(12.r),
437-
child: app['icon'] != null && app['icon'].toString().isNotEmpty
438-
? Image.memory(
439-
base64Decode(app['icon']),
440-
width: 48.w,
441-
height: 48.w,
442-
fit: BoxFit.contain,
443-
cacheWidth: (48.w * MediaQuery.of(context).devicePixelRatio).round(),
444-
cacheHeight: (48.w * MediaQuery.of(context).devicePixelRatio).round(),
445-
errorBuilder: (context, error, stackTrace) {
446-
return Icon(
447-
Icons.apps,
448-
color: Theme.of(context).colorScheme.primary,
449-
size: 28.w,
450-
);
451-
},
452-
)
453-
: Icon(
454-
Icons.apps,
455-
color: Theme.of(context).colorScheme.primary,
456-
size: 28.w,
457-
),
430+
child: _buildAppIcon(app['packageName'] as String, 48.w, cacheSize, fallbackColor: Theme.of(context).colorScheme.primary, fallbackSize: 28.w),
458431
),
459432
),
460433
SizedBox(height: 4.h),
@@ -499,4 +472,58 @@ class _AppsScreenState extends State<AppsScreen> with AutomaticKeepAliveClientMi
499472
return false;
500473
}
501474
}
475+
476+
Widget _buildAppIcon(String packageName, double size, int cacheSize, {required Color fallbackColor, required double fallbackSize}) {
477+
final cached = _iconCache[packageName];
478+
if (cached != null && cached.isNotEmpty) {
479+
return Image.memory(
480+
cached,
481+
width: size,
482+
height: size,
483+
fit: BoxFit.contain,
484+
cacheWidth: cacheSize,
485+
cacheHeight: cacheSize,
486+
filterQuality: FilterQuality.none,
487+
gaplessPlayback: true,
488+
);
489+
}
490+
final future = _iconFutures[packageName] ?? _requestIcon(packageName);
491+
_iconFutures[packageName] = future;
492+
return FutureBuilder<Uint8List?>(
493+
future: future,
494+
builder: (context, snap) {
495+
if (snap.connectionState == ConnectionState.done && snap.data != null && snap.data!.isNotEmpty) {
496+
return Image.memory(
497+
snap.data!,
498+
width: size,
499+
height: size,
500+
fit: BoxFit.contain,
501+
cacheWidth: cacheSize,
502+
cacheHeight: cacheSize,
503+
filterQuality: FilterQuality.none,
504+
gaplessPlayback: true,
505+
);
506+
}
507+
return Icon(
508+
Icons.apps,
509+
color: fallbackColor,
510+
size: fallbackSize,
511+
);
512+
},
513+
);
514+
}
515+
516+
Future<Uint8List?> _requestIcon(String packageName) async {
517+
try {
518+
final bytes = await platform.invokeMethod<Uint8List>('getAppIcon', {
519+
'packageName': packageName,
520+
});
521+
if (bytes == null || bytes.isEmpty) return null;
522+
_iconCache[packageName] = bytes;
523+
return bytes;
524+
} catch (_) {
525+
_iconCache[packageName] = null;
526+
return null;
527+
}
528+
}
502529
}

0 commit comments

Comments
 (0)