From 7f6448ae9be6b99d7cfa86683355c3c2dfe20006 Mon Sep 17 00:00:00 2001 From: sihoo Date: Mon, 3 Aug 2026 15:46:51 +0900 Subject: [PATCH] =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EC=96=B4=20=EC=B6=9C=EC=8B=9C=20=EC=A4=80=EB=B9=84:=20?= =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EC=84=9C=EB=AA=85,=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=AA=85=20=EB=B3=80=EA=B2=BD,=20HTTPS=20?= =?UTF-8?q?=EC=A0=84=ED=99=98,=20NFC=20=ED=83=9C=EA=B7=B8=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 릴리즈 빌드 준비 - android/app/build.gradle.kts: 디버그 키로 서명하던 release 빌드 타입을 android/key.properties 기반 정식 릴리즈 키스토어(upload-keystore.jks) 서명으로 변경 - applicationId를 com.example.school_attendance → com.backsanhighschool.app 로 변경 (com.example.* 패키지는 구글 플레이 업로드 자체가 거부되기 때문) ## Firebase 재등록 - 새 패키지명(com.backsanhighschool.app)으로 Firebase Android 앱을 새로 등록 - google-services.json, lib/firebase_options.dart, firebase.json을 새 앱 ID 기준으로 재생성 ## HTTPS 전환 - lib/config.dart: baseUrl을 http:// → https:// 로 변경 (서버에 이미 발급되어 있었지만 프록시에 연결이 안 되어 있던 Let's Encrypt 인증서를 nginx-proxy-manager에 연결하고 HTTP→HTTPS 강제 리다이렉트 설정) - AndroidManifest.xml: 더 이상 필요 없는 usesCleartextTraffic="true" 제거 ## NFC 태그 쓰기 기능 (신규) - lib/screens/nfc_tag_writer_screen.dart 추가: 관리자가 빈 NFC 스티커에 "POCKET_번호" 텍스트를 쓰고, 태그 하드웨어 UID를 서버(/api/pockets/register)에 등록해서 태그 복제(내용만 베낀 위조 태그)를 방지 - lib/nfc_poccket_checkin_screen.dart: 체크인 시 태그 UID를 함께 서버로 전송해서 등록된 진짜 주머니 번호로 검증하도록 변경 - lib/screens/student_dashboard.dart: 개발자 메뉴에 "NFC 태그 쓰기" 카드 추가, 기존 "가상 NFC 태깅" 카드는 "NFC 태그"로 이름 변경 Co-Authored-By: Claude Sonnet 5 --- android/app/build.gradle.kts | 27 ++- android/app/google-services.json | 19 +++ android/app/src/main/AndroidManifest.xml | 3 +- firebase.json | 2 +- lib/config.dart | 2 +- lib/firebase_options.dart | 2 +- lib/nfc_poccket_checkin_screen.dart | 31 ++-- lib/screens/nfc_tag_writer_screen.dart | 204 +++++++++++++++++++++++ lib/screens/student_dashboard.dart | 18 +- 9 files changed, 280 insertions(+), 28 deletions(-) create mode 100644 lib/screens/nfc_tag_writer_screen.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 24a4fe9..ccf181a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. @@ -5,6 +8,12 @@ plugins { id("com.google.gms.google-services") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.example.school_attendance" compileSdk = 36 @@ -17,8 +26,7 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.school_attendance" + applicationId = "com.backsanhighschool.app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -27,11 +35,20 @@ android { versionName = flutter.versionName } + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("release") } } } diff --git a/android/app/google-services.json b/android/app/google-services.json index 2af4fd3..c02d642 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -5,6 +5,25 @@ "storage_bucket": "school-display-ff28f.firebasestorage.app" }, "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:137322214849:android:2595999513206bdeb16b40", + "android_client_info": { + "package_name": "com.backsanhighschool.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBR6CZrAafCMssuifI9So42uSOSuPASe84" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, { "client_info": { "mobilesdk_app_id": "1:137322214849:android:52d9ca6efcb9613eb16b40", diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 316cea1..bd809ec 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,8 +11,7 @@ + android:icon="@mipmap/ic_launcher"> 프로젝트 설정 > Cloud Messaging > 웹 구성 > 웹 푸시 인증서에서 발급) diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index 1df426b..ee5b1db 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -61,7 +61,7 @@ class DefaultFirebaseOptions { static const FirebaseOptions android = FirebaseOptions( apiKey: 'AIzaSyBR6CZrAafCMssuifI9So42uSOSuPASe84', - appId: '1:137322214849:android:52d9ca6efcb9613eb16b40', + appId: '1:137322214849:android:2595999513206bdeb16b40', messagingSenderId: '137322214849', projectId: 'school-display-ff28f', storageBucket: 'school-display-ff28f.firebasestorage.app', diff --git a/lib/nfc_poccket_checkin_screen.dart b/lib/nfc_poccket_checkin_screen.dart index 41ac678..12ea5cd 100644 --- a/lib/nfc_poccket_checkin_screen.dart +++ b/lib/nfc_poccket_checkin_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:http/http.dart' as http; import 'package:nfc_manager/nfc_manager.dart'; +import 'package:nfc_manager/nfc_manager_android.dart'; import 'package:nfc_manager_ndef/nfc_manager_ndef.dart'; import 'package:permission_handler/permission_handler.dart'; import 'config.dart' show baseUrl; @@ -104,8 +105,8 @@ class _NfcPocketCheckInScreenState extends State { cleanPocketNumber = rawPocketData.trim(); } - // 2. 파이썬 서버로 출석 정보 보내기 - await _sendAttendanceToBackend(cleanPocketNumber); + // 2. 파이썬 서버로 출석 정보 보내기 (복제 방지용 태그 UID도 함께 전송) + await _sendAttendanceToBackend(cleanPocketNumber, tagUid: _getTagUid(tag)); } catch (e) { _showSnackBar("❌ 태그 읽기 실패: $e", Colors.red); } finally { @@ -119,8 +120,18 @@ class _NfcPocketCheckInScreenState extends State { ); } + /// 태그의 공장 각인 하드웨어 UID를 16진수 문자열로 반환한다 (쓰기로 바꿀 수 없어 복제 방지 기준으로 씀). + String? _getTagUid(NfcTag tag) { + final id = NfcTagAndroid.from(tag)?.id; + if (id == null || id.isEmpty) return null; + return id.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + } + /// 🚚 파이썬 백엔드로 출석 데이터 HTTP POST 전송 - Future _sendAttendanceToBackend(String pocketNumber) async { + Future _sendAttendanceToBackend( + String pocketNumber, { + String? tagUid, + }) async { final url = Uri.parse("$baseUrl/attendance"); try { @@ -131,6 +142,7 @@ class _NfcPocketCheckInScreenState extends State { "studentId": widget.studentId, "studentName": widget.studentName, "pocketNumber": pocketNumber, // 주머니 번호 전달 + "tagUid": tagUid, // 🔒 서버가 태그 진위를 대조할 하드웨어 UID }), ); @@ -287,19 +299,6 @@ class _NfcPocketCheckInScreenState extends State { textAlign: TextAlign.center, style: TextStyle(color: Colors.grey, fontSize: 14), ), - const SizedBox(height: 40), - - // 🧪 [디버그 전용] NFC 태그나 카드가 없을 때 흐름을 테스트하기 위한 가상 태그 버튼 - if (!_isProcessing) - OutlinedButton.icon( - onPressed: () => _sendAttendanceToBackend("POCKET_TEST_01"), - icon: const Icon(Icons.science_outlined), - label: const Text("가상 태그로 테스트 (NFC 없이)"), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.purple, - side: const BorderSide(color: Colors.purple), - ), - ), ], ), ), diff --git a/lib/screens/nfc_tag_writer_screen.dart b/lib/screens/nfc_tag_writer_screen.dart new file mode 100644 index 0000000..bc61ecd --- /dev/null +++ b/lib/screens/nfc_tag_writer_screen.dart @@ -0,0 +1,204 @@ +// 🏷️ (관리자용) 주머니 NFC 스티커 초기 설정 화면. 빈 태그에 "POCKET_번호" 텍스트를 써넣는다. +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:nfc_manager/nfc_manager.dart'; +import 'package:nfc_manager/nfc_manager_android.dart'; +import 'package:nfc_manager/ndef_record.dart'; +import 'package:nfc_manager_ndef/nfc_manager_ndef.dart'; +import '../config.dart'; + +class NfcTagWriterScreen extends StatefulWidget { + const NfcTagWriterScreen({super.key}); + + @override + State createState() => _NfcTagWriterScreenState(); +} + +class _NfcTagWriterScreenState extends State { + final TextEditingController _numberController = TextEditingController( + text: "1", + ); + bool _isWriting = false; + String _statusMessage = "주머니 번호를 입력하고 '쓰기 시작'을 눌러주세요."; + + @override + void dispose() { + NfcManager.instance.stopSession(); + _numberController.dispose(); + super.dispose(); + } + + void _startWriteSession() async { + final String number = _numberController.text.trim(); + if (number.isEmpty) { + _showSnackBar("⚠️ 주머니 번호를 입력해주세요.", Colors.orange); + return; + } + final String pocketLabel = "POCKET_$number"; + + bool isAvailable = await NfcManager.instance.isAvailable(); + if (!isAvailable) { + _showSnackBar("❌ NFC가 꺼져있거나 지원되지 않습니다.", Colors.red); + return; + } + + setState(() { + _isWriting = true; + _statusMessage = "📡 [$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요."; + }); + + NfcManager.instance.startSession( + pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693}, + onDiscovered: (NfcTag tag) async { + try { + final ndef = Ndef.from(tag); + if (ndef == null) { + _showSnackBar("❌ 이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", Colors.red); + return; + } + if (!ndef.isWritable) { + _showSnackBar("❌ 이 태그는 쓰기 잠금(read-only) 상태입니다.", Colors.red); + return; + } + + await ndef.write( + message: NdefMessage(records: [_createTextRecord(pocketLabel)]), + ); + + // 🔒 태그 복제 방지: 하드웨어 UID를 서버에 등록해서 이 물리 태그만 [pocketLabel]로 인정되게 한다. + final String? tagUid = _getTagUid(tag); + if (tagUid != null) { + await _registerTagUid(tagUid, pocketLabel); + } + + if (mounted) { + _showSnackBar( + tagUid != null + ? "✅ [$pocketLabel] 쓰기 + UID 등록 성공!" + : "⚠️ [$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.", + tagUid != null ? Colors.green : Colors.orange, + ); + // 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다. + final int? n = int.tryParse(number); + setState(() { + if (n != null) _numberController.text = (n + 1).toString(); + _statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요."; + }); + } + } catch (e) { + _showSnackBar("❌ 쓰기 실패: $e", Colors.red); + } finally { + await NfcManager.instance.stopSession(); + if (mounted) setState(() => _isWriting = false); + } + }, + ); + } + + /// 태그의 공장 각인 하드웨어 UID를 16진수 문자열로 반환한다 (쓰기로 바꿀 수 없어 복제 방지 기준으로 씀). + String? _getTagUid(NfcTag tag) { + final id = NfcTagAndroid.from(tag)?.id; + if (id == null || id.isEmpty) return null; + return id.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + } + + /// 서버에 "이 UID는 이 주머니 번호다"를 등록한다. + Future _registerTagUid(String tagUid, String pocketLabel) async { + try { + await http.post( + Uri.parse("$baseUrl/api/pockets/register"), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}), + ); + } catch (e) { + _showSnackBar("❌ 서버에 UID 등록 실패: $e", Colors.red); + } + } + + /// NFC Forum Text Record Type Definition에 맞춰 "언어코드+텍스트" 페이로드를 만든다. + NdefRecord _createTextRecord(String text) { + const languageCode = 'en'; + final languageBytes = utf8.encode(languageCode); + final textBytes = utf8.encode(text); + final payload = Uint8List.fromList([ + languageBytes.length, // 상태 바이트: UTF-8 + 언어코드 길이 + ...languageBytes, + ...textBytes, + ]); + return NdefRecord( + typeNameFormat: TypeNameFormat.wellKnown, + type: Uint8List.fromList([0x54]), // 'T' = Text Record + identifier: Uint8List(0), + payload: payload, + ); + } + + void _showSnackBar(String text, Color color) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(text), backgroundColor: color)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("🏷️ NFC 주머니 태그 쓰기"), + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + ), + body: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _numberController, + keyboardType: TextInputType.number, + enabled: !_isWriting, + decoration: const InputDecoration( + labelText: "주머니 번호", + prefixText: "POCKET_", + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 32), + Icon( + _isWriting ? Icons.nfc : Icons.edit_note_rounded, + size: 80, + color: _isWriting ? Colors.orange : Colors.deepPurple, + ), + const SizedBox(height: 16), + Text( + _statusMessage, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 15), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + ), + onPressed: _isWriting ? null : _startWriteSession, + child: Text( + _isWriting ? "태그를 기다리는 중..." : "쓰기 시작", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/student_dashboard.dart b/lib/screens/student_dashboard.dart index 0595dab..6e28926 100644 --- a/lib/screens/student_dashboard.dart +++ b/lib/screens/student_dashboard.dart @@ -9,6 +9,7 @@ import 'login_screen.dart'; import 'teacher_attendance_page.dart'; import 'admin_dashboard.dart'; import 'student_management_screen.dart'; +import 'nfc_tag_writer_screen.dart'; // ------------------------------------------------------------- // 2. 학생 대시보드 (StudentDashboard) @@ -33,7 +34,7 @@ class StudentDashboard extends StatefulWidget { class _StudentDashboardState extends State { bool _isLoading = false; - // 1️⃣ 가상 NFC 태깅 카드 → 실제 NFC 주머니 체크인 화면으로 이동 + // 1️⃣ NFC 태그 카드 → 실제 NFC 주머니 체크인 화면으로 이동 void _openPocketCheckIn(BuildContext context) { Navigator.push( context, @@ -303,7 +304,7 @@ class _StudentDashboardState extends State { icon: widget.isDeviceMatched ? Icons.contactless_rounded : Icons.lock_rounded, - title: '가상 NFC 태깅', + title: 'NFC 태그', subtitle: widget.isDeviceMatched ? '출석 및 폰 수거 완료' : '⚠️ 본인 인증 기기 전용', @@ -387,6 +388,19 @@ class _StudentDashboardState extends State { color: Colors.red[600]!, onTap: () => _showDeleteUserDialog(context), ), + if (isDeveloper) + _buildModernCard( + icon: Icons.edit_note_rounded, + title: 'NFC 태그 쓰기', + subtitle: '주머니 스티커 초기 설정', + color: Colors.deepPurple, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const NfcTagWriterScreen(), + ), + ), + ), ], ), ),