diff --git a/flutter/android/app/build.gradle.kts b/flutter/android/app/build.gradle.kts index 14eed18..a2f0e6c 100644 --- a/flutter/android/app/build.gradle.kts +++ b/flutter/android/app/build.gradle.kts @@ -1,3 +1,8 @@ +import java.net.InetAddress +import java.net.URI +import java.net.URISyntaxException +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +10,137 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// 배포 판(release)의 서명 — 우리 서명 키로만 한다(앱은 스토어에 올리지 않고 사내에 직접 나눠 준다 — flutter/README "사내 배포"). +// android/key.properties(storeFile · storePassword · keyAlias · keyPassword)가 저장소 밖의 keystore 를 가리킨다. +// key.properties · keystore 는 커밋하지 않는다(android/.gitignore). 값(비밀번호)은 어떤 오류 글에도 싣지 않는다. +// 키가 갖춰지지 않았으면 release 작업을 멈춘다(맨 아래 확인) — flutter create 기본처럼 debug 키로 조용히 서명하면, +// 그 판을 깐 휴대폰에는 우리 키로 서명한 다음 판을 덮어 설치할 수 없다(앱을 지우고 다시 설치해야 한다). +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties() +val releaseSigningProblem: String? = run { + if (!keystorePropertiesFile.isFile) return@run "android/key.properties 가 없습니다." + try { + keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) } + } catch (e: Exception) { + // .properties 는 \ 를 이스케이프로 읽는다 — Windows 경로의 \u… 는 여기서 실패한다 + return@run "android/key.properties 를 읽지 못했습니다. 경로의 \\ 는 / 로 적어 주세요." + } + val blank = listOf("storeFile", "storePassword", "keyAlias", "keyPassword") + .filter { keystoreProperties.getProperty(it).isNullOrBlank() } + if (blank.isNotEmpty()) { + return@run "android/key.properties 에 비어 있는 칸이 있습니다: ${blank.joinToString(", ")}." + } + // 상대 경로는 android/ 기준(key.properties 가 있는 곳)이다 — 절대 경로로 적기를 권한다 + if (!rootProject.file(keystoreProperties.getProperty("storeFile").trim()).isFile) { + return@run "android/key.properties 의 storeFile 이 가리키는 keystore 파일이 없습니다. 경로의 \\ 는 / 로 적어 주세요." + } + null +} + +// ── 배포 판의 API 주소 판정 (순수 함수 — 아래 releaseApiUrlProblem 이 부른다) ── +// 앱 안에 그대로 들어가는 flutter/.env(pubspec 의 asset)의 API_URL 을 앱과 같은 규칙으로 읽고, 운영 포털의 API 주소인지 본다. +// Gradle 스크립트라 저장소의 시험(flutter test · CI 는 Gradle 을 돌리지 않는다)으로는 돌리지 못한다 — 고치면 flutter/README "사내 배포 › 4. release 빌드" 의 +// 확인(키 없이 · 개발 주소인 채로 `flutter build apk --release`)으로 다시 본다. 판정 글에는 주소 값을 싣지 않는다(무엇이 틀렸는지만). + +/** 앱이 읽는 키 — 짝: Dart `lib/core/api/api_config.dart` 의 `kApiUrlEnvKey`(없으면 앱은 개발 주소 `kDefaultApiUrl` 로 돈다) */ +val apiUrlEnvKey = "API_URL" + +private val dotenvComment = Regex("""#[^'"]*$""") +private val dotenvCommentWithQuotes = Regex("""#.*$""") +private val dotenvLeadingExport = Regex("""^ *export ?""") +private val dotenvSurroundQuotes = Regex("""^(["'])(.*?[^\\])\1""") +private val dotenvBashVar = Regex("""(\\)?(\$)(?:\{)?([a-zA-Z_][\w]*)+(?:\})?""") + +/** Dart 의 `String.trim` — 공백과 함께 BOM(U+FEFF)도 뗀다 */ +fun dartTrim(text: String): String = text.trim { it.isWhitespace() || it == '\uFEFF' } + +/** + * flutter_dotenv 5.2.1 의 `Parser.parse` 를 옮겼다 — 앱이 `.env` 를 읽는 규칙 그대로(줄은 `\n` 으로만 나눈다). + * 같은 키는 처음 것, 따옴표 밖의 `#` 주석 · 앞의 `export` · 둘러싼 따옴표를 떼고, 홑따옴표가 아니면 앞에서 읽은 `$이름` 을 채운다. + * 짝: `pubspec.lock` 의 flutter_dotenv 판 — 판을 올리면 그 판의 `lib/src/parser.dart` 와 다시 견준다 + */ +fun parseDotenv(lines: List): Map { + val out = LinkedHashMap() + for (line in lines) { + val stripped = dartTrim(line.replace(dotenvComment, "")) + if (stripped.isEmpty() || !stripped.contains('=')) continue + val idx = stripped.indexOf('=') + val key = dartTrim(stripped.substring(0, idx).replace(dotenvLeadingExport, "")) + if (key.isEmpty()) continue + val rhs = dartTrim(stripped.substring(idx + 1)) + val quoted = dotenvSurroundQuotes.find(rhs) + val quote = quoted?.groupValues?.get(1).orEmpty() + var value = quoted?.groupValues?.get(2) ?: dartTrim(dartTrim(rhs.replace(dotenvCommentWithQuotes, ""))) + if (quote == "'") { + value = value.replace("\\'", "'") + } else { + if (quote == "\"") value = value.replace("\\\"", "\"").replace("\\n", "\n") + value = dotenvBashVar.replace(value) { m -> + if (m.groupValues[1] == "\\") m.value else out[m.groupValues[3]].orEmpty() + }.replace("\\\$", "\$") + } + out.putIfAbsent(key, value) + } + return out +} + +/** + * 개발 주소 — localhost · `.localhost` · `.test` 로 끝나는 이름 · 루프백(127.0.0.0/8 · ::1) · 0.0.0.0 · 안드로이드 에뮬레이터의 PC 주소 10.0.2.2. + * [host] 는 소문자 · 대괄호 없이 받는다. IPv6 는 IP 글자일 때만 읽는다(이름을 묻지 않는다 — DNS 없음) + */ +fun isDevelopmentHost(host: String): Boolean { + if (host == "localhost" || host.endsWith(".localhost") || host.endsWith(".test")) return true + if (host == "10.0.2.2" || host == "0.0.0.0") return true + if (Regex("""127(\.\d{1,3}){3}""").matches(host)) return true + if (!host.contains(':')) return false + return try { + InetAddress.getByName(host).let { it.isLoopbackAddress || it.isAnyLocalAddress } + } catch (e: Exception) { + false + } +} + +/** API 주소의 경로로 받는 것 — 같은 주소 배포라 늘 포털 출처의 `/api`(끝의 `/` 하나까지) */ +val releaseApiPaths = setOf("/api", "/api/") + +/** + * 배포 판에 넣을 API 주소의 문제 — 없으면 null. 운영 포털의 API 주소(`https://<포털>/api`)여야 한다: + * 앞뒤 빈칸 없음(앱은 파서가 준 값을 그대로 쓴다 — 따옴표 안의 빈칸은 파서가 떼지 않는다) · https · 개발 주소 아님 · + * 사용자 정보 · 쿼리 · 조각 없음(APK 안의 .env 는 누구나 풀어 볼 수 있고, 사용자 정보는 dart:io 가 Basic 으로 보낸다) · 경로 `/api`. + * 글은 고정 글이다 — 주소 값을 싣지 않는다(무엇이 틀렸는지만) + */ +fun apiUrlProblemForRelease(apiUrl: String?): String? { + if (apiUrl.isNullOrEmpty()) return "flutter/.env 에 API_URL 이 없습니다." + if (apiUrl != dartTrim(apiUrl)) return "flutter/.env 의 API_URL 값 앞뒤에 빈칸이 있습니다." + val uri = try { + URI(apiUrl) + } catch (e: URISyntaxException) { + return "flutter/.env 의 API_URL 을 주소로 읽지 못했습니다." + } + if (!uri.scheme.equals("https", ignoreCase = true)) return "flutter/.env 의 API_URL 이 https 주소가 아닙니다." + val host = uri.host.orEmpty().removePrefix("[").removeSuffix("]").trimEnd('.').lowercase() + if (host.isEmpty()) return "flutter/.env 의 API_URL 에서 호스트를 읽지 못했습니다." + if (isDevelopmentHost(host)) return "flutter/.env 의 API_URL 이 개발 주소입니다." + if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) { + return "flutter/.env 의 API_URL 에 사용자 정보 · 쿼리 · 조각이 있습니다." + } + if (uri.rawPath !in releaseApiPaths) return "flutter/.env 의 API_URL 경로가 /api 가 아닙니다." + return null +} +// ── 배포 판의 API 주소 판정 끝 ── + +// 앱에 들어갈 .env 를 지금 읽는다(Flutter 가 같은 파일을 asset 으로 싣는다). 없거나 읽지 못해도 release 를 막는다 — 값이 없으면 앱은 개발 주소로 돈다 +val appEnvFile = rootProject.file("../.env") +val releaseApiUrlProblem: String? = if (!appEnvFile.isFile) { + "flutter/.env 가 없습니다." +} else { + try { + apiUrlProblemForRelease(parseDotenv(appEnvFile.readText(Charsets.UTF_8).split("\n"))[apiUrlEnvKey]) + } catch (e: Exception) { + "flutter/.env 를 읽지 못했습니다." + } +} + android { namespace = "kr.it.arasystem.app" compileSdk = flutter.compileSdkVersion @@ -30,11 +166,22 @@ android { versionName = flutter.versionName } + signingConfigs { + // 키가 갖춰졌을 때만 만든다 — 빈 값으로 만들면 debug 빌드까지 설정 단계에서 실패한다 + if (releaseSigningProblem == null) { + create("release") { + storeFile = rootProject.file(keystoreProperties.getProperty("storeFile").trim()) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias").trim() + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + 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") + // 우리 키 — 갖춰지지 않았으면 null(서명 없음)이고, 맨 아래 확인이 release 작업을 멈춘다 + signingConfig = signingConfigs.findByName("release") } } } @@ -45,19 +192,35 @@ flutter { // 새 메일 알림(FCM)의 Firebase 설정 — android/app/google-services.json 은 저장소에 넣지 않는다(android/.gitignore · flutter/README "새 메일 알림"). // 있으면 google-services 플러그인이 리소스로 바꾸고(Dart 의 Firebase.initializeApp 이 읽는다), 없으면 알림 없이 빌드한다 — 앱은 "받을 수 없는 휴대폰"으로 돈다. -// 배포 판(release 변형)을 만드는 작업이 하나라도 있으면 멈춘다 — 새 메일 알림 없이 나가지 않게. -// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build, -// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다. -// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 이 조건도 함께 바꾼다. +// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 아래 확인의 조건도 함께 바꾼다. val firebaseConfig = file("google-services.json") if (firebaseConfig.exists()) { apply(plugin = "com.google.gms.google-services") -} else { +} + +// 배포 판(release 변형)에 갖출 것 — 하나라도 빠졌으면 release 작업이 하나라도 있을 때 멈추고, 빠진 것을 한 번에 알린다. +// - google-services.json: 새 메일 알림 없이 나가지 않게 +// - 우리 서명 키(key.properties): debug 키로 서명한 판이 나가지 않게(위 releaseSigningProblem) +// - 운영 API 주소(flutter/.env 의 API_URL): 개발 주소 · http 로 만든 판이 나가지 않게(위 releaseApiUrlProblem — 글에 주소 값을 싣지 않는다) +// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build, +// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다. debug · profile 빌드는 그대로 된다. +val releaseBlockers: List = listOfNotNull( + if (firebaseConfig.exists()) { + null + } else { + "android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\")." + }, + releaseSigningProblem?.let { + "$it 배포 판은 우리 서명 키로만 서명합니다 (flutter/README \"사내 배포\")." + }, + releaseApiUrlProblem?.let { + "$it 배포 판은 운영 포털의 API 주소로만 만듭니다 (flutter/README \"사내 배포\")." + }, +) +if (releaseBlockers.isNotEmpty()) { gradle.taskGraph.whenReady { if (allTasks.any { it.project == project && it.name.contains("Release") }) { - throw GradleException( - "android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\").", - ) + throw GradleException(releaseBlockers.joinToString("\n")) } } } diff --git a/flutter/lib/core/api/user_agent.dart b/flutter/lib/core/api/user_agent.dart index 88f3579..d8fefa3 100644 --- a/flutter/lib/core/api/user_agent.dart +++ b/flutter/lib/core/api/user_agent.dart @@ -44,23 +44,14 @@ String _clean(String? value) { : kept; } -/// 이 기기의 앱 판 · 패키지 이름(업데이트 화면의 Play 스토어 주소) · User-Agent · 안드로이드 판(API — 새 메일 알림의 권한을 가른다) — main 에서 한 번. +/// 이 기기의 앱 판 · User-Agent · 안드로이드 판(API — 새 메일 알림의 권한을 가른다) — main 에서 한 번. /// 기기 정보를 읽지 못하면 아는 것만 싣는다. -Future< - ({ - String? appVersion, - String? packageName, - String userAgent, - int? androidSdkInt, - }) -> +Future<({String? appVersion, String userAgent, int? androidSdkInt})> loadAppDeviceInfo() async { String? appVersion; - String? packageName; try { final PackageInfo info = await PackageInfo.fromPlatform(); appVersion = info.version; - packageName = info.packageName.isEmpty ? null : info.packageName; } on PlatformException { appVersion = null; } @@ -69,7 +60,6 @@ loadAppDeviceInfo() async { ); return ( appVersion: appVersion, - packageName: packageName, userAgent: device.userAgent, androidSdkInt: device.androidSdkInt, ); diff --git a/flutter/lib/core/app_download/app_download_launcher.dart b/flutter/lib/core/app_download/app_download_launcher.dart new file mode 100644 index 0000000..7ee639b --- /dev/null +++ b/flutter/lib/core/app_download/app_download_launcher.dart @@ -0,0 +1,73 @@ +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/app_download/app_download_link.dart'; +import 'package:arasystem_app/core/app_download/app_download_texts.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다 +typedef AppDownloadLaunch = Future Function(Uri url, LaunchMode mode); + +Future _launchExternal(Uri url, LaunchMode mode) => + launchUrl(url, mode: mode); + +/// [새 버전 받기] — 포털의 휴대폰 앱 받기 화면(`app_download_link.dart`)을 바깥 브라우저로 연다. +/// 업데이트 화면과 설정 › 앱 정보가 이 런처 하나를 함께 쓴다(같은 주소 · 같은 실패 알림). +/// +/// - 바깥 앱(`LaunchMode.externalApplication` — 휴대폰의 기본 브라우저)으로 연다. 받기 화면은 로그인한 직원만 받으므로 +/// 브라우저의 웹메일 로그인을 쓴다(앱의 토큰은 넘기지 않는다). +/// - `canLaunchUrl` 은 쓰지 않는다(debug 판 안드로이드가 묻는 주소를 로그에 남긴다 — 인증 앱 열기와 같은 규칙). +/// 못 열면 플러그인 예외를 버리고 거짓 — 화면이 [failureMessage] 를 알린다 +class AppDownloadLauncher { + AppDownloadLauncher({ + required String apiBaseUrl, + AppDownloadLaunch launch = _launchExternal, + }) : pageUrl = appDownloadPageUrlOf(apiBaseUrl), + _launch = launch; + + /// 받기 화면 주소 — 앱의 API 주소로 만들지 못했으면 null(열지 않고 알린다) + final Uri? pageUrl; + + final AppDownloadLaunch _launch; + + /// 열었으면 참 + Future open() async { + final Uri? url = pageUrl; + if (url == null) return false; + try { + return await _launch(url, LaunchMode.externalApplication); + } on Object { + // PlatformException(받을 앱 없음 · 화면 없음) 등 — 화면은 주소를 알려 직접 들어가게 한다 + return false; + } + } + + /// 열지 못했을 때의 알림 글 — 직원이 브라우저에 직접 넣을 주소(스킴 없이)를 담는다 + String get failureMessage { + final Uri? url = pageUrl; + return url == null + ? AppDownloadTexts.openFailedNoAddress + : AppDownloadTexts.openFailed(appDownloadPageAddressOf(url)); + } +} + +/// 받기 화면 런처 — 앱의 API 주소(`apiConfigProvider`)로 주소를 만든다. 시험은 가짜로 덮어쓴다 +final Provider appDownloadLauncherProvider = + Provider( + (Ref ref) => + AppDownloadLauncher(apiBaseUrl: ref.watch(apiConfigProvider).baseUrl), + ); + +/// [새 버전 받기]를 누른 뒤 — 받기 화면을 열고, 열지 못하면 주소를 담아 알린다(두 화면이 같은 알림을 쓴다). +/// [messenger] 는 누른 화면에서 기다리기 전에 잡아 둔다(브라우저가 뜨는 동안 화면이 바뀔 수 있다) +Future openAppDownloadPage( + WidgetRef ref, + ScaffoldMessengerState messenger, +) async { + final AppDownloadLauncher launcher = ref.read(appDownloadLauncherProvider); + if (await launcher.open()) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: KeepAllText(launcher.failureMessage))); +} diff --git a/flutter/lib/core/app_download/app_download_link.dart b/flutter/lib/core/app_download/app_download_link.dart new file mode 100644 index 0000000..def9df9 --- /dev/null +++ b/flutter/lib/core/app_download/app_download_link.dart @@ -0,0 +1,30 @@ +/// 포털의 휴대폰 앱 받기 화면 주소 — 앱을 스토어에 올리지 않고 사내에서 직접 나눠 주는 곳(2026-11-08). +/// 앱의 업데이트 화면 · 설정 › 앱 정보의 [새 버전 받기]가 이 주소를 바깥 브라우저로 연다(앱 보드 `App1Push` ⑩ · ④). +/// +/// 같은 주소 배포라 앱의 API 주소(`.env` 의 `API_URL` — `ApiConfig.baseUrl`)는 늘 포털 출처의 `/api` 다. +/// 그래서 받는 곳 주소를 서버에서 받지 않고(앱 설정 계약은 싣지 않는다) API 주소의 출처에 받기 화면 경로를 붙여 만든다. +library; + +/// 받기 화면 경로 — 짝: 웹 `frontend/src/shared/constants/mobile-app.ts` 의 `MOBILE_APP_PATH`(한쪽을 바꾸면 둘 다) +const String kAppDownloadPagePath = '/app'; + +/// API 주소에서 받기 화면 주소를 만든다 — `https://portal.arasystem.it.kr/api` → `https://portal.arasystem.it.kr/app`. +/// +/// 출처(스킴 · 호스트 · 포트)만 남긴다: API 경로(`/api`) · 쿼리 · 조각 · 사용자 정보는 버리고, 스킴의 기본 포트는 적지 않는다. +/// http · https 주소가 아니거나 호스트가 없으면 null — 화면은 주소 없이 알린다 +Uri? appDownloadPageUrlOf(String apiBaseUrl) { + final Uri? api = Uri.tryParse(apiBaseUrl.trim()); + if (api == null) return null; + if (api.scheme != 'http' && api.scheme != 'https') return null; + if (api.host.isEmpty) return null; + return Uri( + scheme: api.scheme, + host: api.host, + port: api.hasPort ? api.port : null, + path: kAppDownloadPagePath, + ); +} + +/// 알림에 보이는 주소 — 스킴 없이(`portal.arasystem.it.kr/app` · `10.0.2.2:5173/app`). +/// 직원이 휴대폰 브라우저의 주소 칸에 그대로 넣는다 +String appDownloadPageAddressOf(Uri url) => '${url.authority}${url.path}'; diff --git a/flutter/lib/core/app_download/app_download_texts.dart b/flutter/lib/core/app_download/app_download_texts.dart new file mode 100644 index 0000000..828fdca --- /dev/null +++ b/flutter/lib/core/app_download/app_download_texts.dart @@ -0,0 +1,16 @@ +/// 휴대폰 앱 받기 화면을 여는 [새 버전 받기]의 글 — 업데이트 화면(`features/app_update`)과 설정 › 앱 정보(`features/settings`)가 함께 쓴다. +/// +/// 앱 보드 `App1Push` ⑩ · ④ 앱 정보 절(2026-11-08 확정 — 캔버스 v68)의 글 그대로다(디자인 README 「화면 문구 — 사용자에게 말하듯」). +abstract final class AppDownloadTexts { + /// 업데이트 화면의 단추 · 설정 › 앱 정보의 줄 + static const String getNewVersion = '새 버전 받기'; + + /// 브라우저를 열지 못했을 때(알림) — [address] 는 받기 화면 주소를 스킴 없이(`portal.arasystem.it.kr/app`). + /// 직원이 휴대폰 브라우저에 직접 넣는다 + static String openFailed(String address) => + '브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 $address에 들어가 새 버전을 받아 주세요.'; + + /// 받기 화면 주소를 만들지 못했을 때(앱의 API 주소가 http · https 주소가 아니다 — 보드에 없는 경우. + /// 그런 판은 서버에도 닿지 못해 업데이트 화면 · 설정 탭에 오지 않는다) + static const String openFailedNoAddress = '브라우저를 열지 못했습니다. 관리자에게 문의해 주세요.'; +} diff --git a/flutter/lib/core/constants/app_constants.dart b/flutter/lib/core/constants/app_constants.dart index 382d0fa..87b51e5 100644 --- a/flutter/lib/core/constants/app_constants.dart +++ b/flutter/lib/core/constants/app_constants.dart @@ -1,7 +1,7 @@ /// 앱 전체에서 쓰는 이름 · 식별 값. /// /// 식별자(Android applicationId · iOS bundle id `kr.it.arasystem.app`)는 플랫폼 설정 파일에 있다 — -/// 스토어에 올린 뒤에는 바꿀 수 없다. +/// 직원 휴대폰에 설치한 뒤에는 바꿀 수 없다(식별자가 다르면 다른 앱이라 새 판이 그 위에 설치되지 않는다). library; /// 홈 화면 · 작업 전환 화면에 보이는 앱 이름 diff --git a/flutter/lib/core/constants/app_info_provider.dart b/flutter/lib/core/constants/app_info_provider.dart index 31f9ec4..5900f14 100644 --- a/flutter/lib/core/constants/app_info_provider.dart +++ b/flutter/lib/core/constants/app_info_provider.dart @@ -5,9 +5,3 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; final Provider appVersionProvider = Provider( (Ref ref) => null, ); - -/// 앱 식별자(패키지 이름 — Android applicationId `kr.it.arasystem.app`). main 이 기기 정보를 읽어 덮어쓴다. -/// 업데이트 화면의 [Play 스토어 열기]가 쓴다 — 읽지 못했으면 null(단추를 누르면 열지 못했다고 알린다) -final Provider appPackageNameProvider = Provider( - (Ref ref) => null, -); diff --git a/flutter/lib/features/app_update/data/app_store_launcher.dart b/flutter/lib/features/app_update/data/app_store_launcher.dart deleted file mode 100644 index 6853542..0000000 --- a/flutter/lib/features/app_update/data/app_store_launcher.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:url_launcher/url_launcher.dart'; - -/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다 -typedef StoreLaunch = Future Function(Uri url, LaunchMode mode); - -Future _launchExternal(Uri url, LaunchMode mode) => - launchUrl(url, mode: mode); - -/// Play 스토어의 이 앱 쪽 주소(앱이 없을 때 여는 웹 주소) -const String kPlayStoreWebDetails = - 'https://play.google.com/store/apps/details'; - -/// [Play 스토어 열기] — 이 앱의 Play 스토어 쪽을 연다(업데이트 화면). -/// -/// 먼저 Play 스토어 앱(`market://details?id=…` — 브라우저가 아닌 앱만), 열리지 않으면 웹 주소를 바깥 앱(브라우저)으로. -/// 패키지 이름은 앱 정보에서 읽은 값이다(하드코딩하지 않는다). `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소를 로그에 남긴다 — -/// 인증 앱 열기와 같은 규칙). 못 열면 플러그인 예외를 버리고 거짓 — 화면이 알린다 -class AppStoreLauncher { - const AppStoreLauncher({StoreLaunch launch = _launchExternal}) - : _launch = launch; - - final StoreLaunch _launch; - - /// 열었으면 참 - Future open(String packageName) async { - final Uri market = Uri( - scheme: 'market', - host: 'details', - queryParameters: {'id': packageName}, - ); - if (await _tryLaunch(market, LaunchMode.externalNonBrowserApplication)) { - return true; - } - final Uri web = Uri.parse( - kPlayStoreWebDetails, - ).replace(queryParameters: {'id': packageName}); - return _tryLaunch(web, LaunchMode.externalApplication); - } - - Future _tryLaunch(Uri url, LaunchMode mode) async { - try { - return await _launch(url, mode); - } on Object { - return false; - } - } -} - -/// [Play 스토어 열기] — 시험은 가짜로 덮어쓴다 -final Provider appStoreLauncherProvider = - Provider((Ref ref) => const AppStoreLauncher()); diff --git a/flutter/lib/features/app_update/presentation/app_update_texts.dart b/flutter/lib/features/app_update/presentation/app_update_texts.dart index bc27ef6..3cb7a0c 100644 --- a/flutter/lib/features/app_update/presentation/app_update_texts.dart +++ b/flutter/lib/features/app_update/presentation/app_update_texts.dart @@ -1,22 +1,18 @@ -/// 업데이트 화면의 글 — 앱 보드 `App1Push` ⑩(2026-11-07 확정)의 글 그대로다. 제목은 `core/constants/screen_titles.dart` 의 `ScreenTitles.update`. +/// 업데이트 화면의 글 — 앱 보드 `App1Push` ⑩(2026-11-08 사내 직접 배포로 고쳐 확정 — 캔버스 v68)의 글 그대로다. +/// 제목은 `core/constants/screen_titles.dart` 의 `ScreenTitles.update`, [새 버전 받기]와 브라우저를 열지 못했을 때의 알림은 +/// 설정 › 앱 정보와 함께 쓰므로 `core/app_download/app_download_texts.dart` 에 있다. /// /// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. abstract final class AppUpdateTexts { static const String message = - '지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.'; - static const String openStore = 'Play 스토어 열기'; + '지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. 새 버전을 받아 설치한 뒤 다시 열어 주세요.'; /// 지금 쓰는 판 — "지금 쓰는 버전 1.0.0" static String currentVersion(String version) => '지금 쓰는 버전 $version'; /// 문의 줄 — 연락처(`GET /api/support-contact`)가 있으면 물음 다음 줄에 연락처 - static const String supportQuestion = 'Play 스토어에 업데이트가 보이지 않나요?'; + static const String supportQuestion = '새 버전을 받거나 설치하기 어려우신가요?'; /// 연락처를 정하지 않았거나 받지 못했을 때(보드에 없는 경우 — 로그인 화면의 문의 줄과 같은 꼴) - static const String supportFallback = - 'Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요.'; - - /// Play 스토어를 열지 못했을 때(알림) - static const String openStoreFailed = - 'Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.'; + static const String supportFallback = '새 버전을 받거나 설치하기 어려우신가요? 관리자에게 문의해 주세요.'; } diff --git a/flutter/lib/features/app_update/presentation/pages/app_update_page.dart b/flutter/lib/features/app_update/presentation/pages/app_update_page.dart index d63e1ed..3f0fc56 100644 --- a/flutter/lib/features/app_update/presentation/pages/app_update_page.dart +++ b/flutter/lib/features/app_update/presentation/pages/app_update_page.dart @@ -1,11 +1,12 @@ import 'dart:async'; +import 'package:arasystem_app/core/app_download/app_download_launcher.dart'; +import 'package:arasystem_app/core/app_download/app_download_texts.dart'; import 'package:arasystem_app/core/constants/app_info_provider.dart'; import 'package:arasystem_app/core/constants/screen_titles.dart'; import 'package:arasystem_app/core/theme/app_spacing.dart'; import 'package:arasystem_app/core/theme/app_theme.dart'; import 'package:arasystem_app/core/theme/app_typography.dart'; -import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart'; import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart'; import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; @@ -17,9 +18,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱의 판이 서버의 최소 판보다 낮으면 라우터가 어느 화면에서든 이리로 옮긴다 /// (로그인 전 화면도 — 옛 앱이 로그인 API 를 잘못 부르지 않게. `core/router/session_redirect.dart`). /// -/// - 로그인 첫 단계처럼 큰 브랜드 띠(로고만) 아래에 제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄. -/// - [Play 스토어 열기]: Play 스토어 앱 → 안 되면 웹 주소. 열지 못하면 알린다. -/// - 뒤로 가기는 앱을 닫는다(이 화면으로는 `go` 로만 오니 쌓인 화면이 없다). 업데이트하면 Play 가 앱을 끝내고, +/// - 로그인 첫 단계처럼 큰 브랜드 띠(로고만) 아래에 제목 · 글 · [새 버전 받기] · 지금 쓰는 판 · 문의 줄. +/// - [새 버전 받기]: 포털의 휴대폰 앱 받기 화면(`<포털 출처>/app`)을 바깥 브라우저로 연다 — 앱은 스토어에 올리지 않고 +/// 사내에서 직접 나눠 준다(2026-11-08). 열지 못하면 그 주소를 담아 알린다(설정 › 앱 정보와 같은 런처 · 알림 — `core/app_download/`). +/// - 뒤로 가기는 앱을 닫는다(이 화면으로는 `go` 로만 오니 쌓인 화면이 없다). 새 판을 설치하면 안드로이드가 이 앱을 끝내고, /// 서버가 최소 판을 내리면 다음 확인에서 세션이 고른 화면으로 돌아간다. class AppUpdatePage extends ConsumerStatefulWidget { const AppUpdatePage({super.key}); @@ -35,18 +37,8 @@ class _AppUpdatePageState extends ConsumerState { unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); } - Future _openStore() async { - final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); - final String? packageName = ref.read(appPackageNameProvider); - final bool opened = - packageName != null && - await ref.read(appStoreLauncherProvider).open(packageName); - if (opened) return; - messenger - ..hideCurrentSnackBar() - ..showSnackBar( - const SnackBar(content: KeepAllText(AppUpdateTexts.openStoreFailed)), - ); + void _openDownloadPage() { + unawaited(openAppDownloadPage(ref, ScaffoldMessenger.of(context))); } @override @@ -79,12 +71,12 @@ class _AppUpdatePageState extends ConsumerState { ], ), FilledButton( - onPressed: () => unawaited(_openStore()), + onPressed: _openDownloadPage, child: const Row( mainAxisSize: MainAxisSize.min, spacing: AppSpacing.s8, children: [ - Text(AppUpdateTexts.openStore), + Text(AppDownloadTexts.getNewVersion), Icon(Icons.open_in_new, size: AppSize.iconSmall), ], ), diff --git a/flutter/lib/features/settings/presentation/pages/settings_page.dart b/flutter/lib/features/settings/presentation/pages/settings_page.dart index db6258a..0603eac 100644 --- a/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:arasystem_app/core/app_download/app_download_launcher.dart'; +import 'package:arasystem_app/core/app_download/app_download_texts.dart'; import 'package:arasystem_app/core/constants/app_info_provider.dart'; import 'package:arasystem_app/core/modules/module_routes.dart'; import 'package:arasystem_app/core/theme/app_spacing.dart'; @@ -18,11 +20,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// 설정 탭 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄). +/// 설정 탭 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄 · 앱 정보 절). /// 글자는 테마 크기로 맞췄다(보드 15.5 → 16 · 14 → 15 · 13 → 13 — 앱 디자인 README 의 크기 정리와 같다). /// -/// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 · 앱 정보(앱 버전 · 오픈소스 라이선스) · -/// 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃]. +/// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 · +/// 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃]. +/// [새 버전 받기]는 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 바깥 브라우저로 연다(같은 런처 · 같은 실패 알림 — `core/app_download/`). /// [로그아웃]은 확인 창부터 연다(처음 초점은 [취소] — 로그아웃하면 이 휴대폰의 새 메일 알림도 멈춘다고 알린다). /// 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다. class SettingsPage extends ConsumerStatefulWidget { @@ -65,6 +68,10 @@ class _SettingsPageState extends ConsumerState { await ref.read(sessionProvider.notifier).signOut(); } + void _openDownloadPage() { + unawaited(openAppDownloadPage(ref, ScaffoldMessenger.of(context))); + } + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -118,6 +125,11 @@ class _SettingsPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _VersionRow(version: version), + _LinkRow( + label: AppDownloadTexts.getNewVersion, + opensOutside: true, + onTap: _openDownloadPage, + ), _LinkRow( label: SettingsTexts.licenses, onTap: () => @@ -321,13 +333,21 @@ class _VersionRow extends StatelessWidget { } } -/// 누르면 다른 화면으로 가는 한 줄 — 이름 · 오른쪽 화살표 +/// 누르는 한 줄 — 이름 · 오른쪽 끝 아이콘. 앱 안의 다른 화면으로 가면 화살표, 바깥(브라우저)으로 열면 바깥으로 여는 아이콘 +/// (보드: 화살표 20 · 바깥 아이콘 18 을 20 칸 가운데에 — 두 아이콘의 가운데가 맞는다) class _LinkRow extends StatelessWidget { - const _LinkRow({required this.label, required this.onTap}); + const _LinkRow({ + required this.label, + required this.onTap, + this.opensOutside = false, + }); final String label; final VoidCallback? onTap; + /// 바깥 앱(브라우저)으로 연다 — 끝 아이콘이 바깥으로 여는 아이콘 + final bool opensOutside; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -352,10 +372,15 @@ class _LinkRow extends StatelessWidget { children: [ Expanded(child: Text(label, style: theme.textTheme.bodyLarge)), ExcludeSemantics( - child: Icon( - Icons.chevron_right, - size: AppSize.iconMedium, - color: extra.textMuted, + child: SizedBox.square( + dimension: AppSize.iconMedium, + child: Icon( + opensOutside ? Icons.open_in_new : Icons.chevron_right, + size: opensOutside + ? AppSize.iconSmall + : AppSize.iconMedium, + color: extra.textMuted, + ), ), ), ], diff --git a/flutter/lib/features/settings/presentation/settings_texts.dart b/flutter/lib/features/settings/presentation/settings_texts.dart index a8d7c34..ee385f0 100644 --- a/flutter/lib/features/settings/presentation/settings_texts.dart +++ b/flutter/lib/features/settings/presentation/settings_texts.dart @@ -1,5 +1,6 @@ /// 설정 모듈의 화면 글 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)의 글 그대로다. /// 새 메일 알림 줄의 글은 받은편지함 권하기 줄과 함께 쓰므로 `core/push/push_texts.dart` 에 있다(보드 `App1Push`). +/// 앱 정보의 [새 버전 받기] 줄과 그 실패 알림은 업데이트 화면과 함께 쓰므로 `core/app_download/app_download_texts.dart` 에 있다(보드 `App1Push` ④). /// /// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. abstract final class SettingsTexts { diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 62bb31d..c2bb7ec 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -41,13 +41,8 @@ Future main() async { firstRun: PrefsFirstRunMarker(), ); await tokenStore.prepare(); - final ({ - String? appVersion, - String? packageName, - String userAgent, - int? androidSdkInt, - }) - device = await loadAppDeviceInfo(); + final ({String? appVersion, String userAgent, int? androidSdkInt}) device = + await loadAppDeviceInfo(); // 새 메일 알림(FCM) — Firebase 를 띄우고 플러그인의 소식 줄(FID · 받은 알림 · 누른 알림)을 지금 듣는다. // 띄우지 못하면(google-services.json 없이 빌드 · 5초 넘음) 알림 없이 돈다 — 설정 줄은 "이 휴대폰에서는 받을 수 없음" @@ -70,7 +65,6 @@ Future main() async { tokenStoreProvider.overrideWithValue(tokenStore), userAgentProvider.overrideWithValue(device.userAgent), appVersionProvider.overrideWithValue(device.appVersion), - appPackageNameProvider.overrideWithValue(device.packageName), downloadedFileStoreProvider.overrideWithValue(downloadedFiles), pickedFileStoreProvider.overrideWithValue(pickedFiles), pushMessagingProvider.overrideWithValue(pushMessaging), diff --git a/flutter/test/core/app_download/app_download_launcher_test.dart b/flutter/test/core/app_download/app_download_launcher_test.dart new file mode 100644 index 0000000..8eb344e --- /dev/null +++ b/flutter/test/core/app_download/app_download_launcher_test.dart @@ -0,0 +1,101 @@ +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/app_download/app_download_launcher.dart'; +import 'package:arasystem_app/core/app_download/app_download_texts.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// [새 버전 받기]의 런처 — 업데이트 화면 · 설정 › 앱 정보가 함께 쓴다(화면은 각 화면 시험이 본다) +void main() { + /// 다른 앱으로 넘긴 주소 · 방식 + final List<({Uri url, LaunchMode mode})> launched = + <({Uri url, LaunchMode mode})>[]; + + setUp(launched.clear); + + AppDownloadLauncher launcherOf( + String apiBaseUrl, { + required Future Function() answer, + }) => AppDownloadLauncher( + apiBaseUrl: apiBaseUrl, + launch: (Uri url, LaunchMode mode) { + launched.add((url: url, mode: mode)); + return answer(); + }, + ); + + test('받기 화면을 바깥 앱(브라우저)으로 한 번 연다 — 열었으면 참', () async { + final AppDownloadLauncher launcher = launcherOf( + 'https://portal.arasystem.it.kr/api', + answer: () async => true, + ); + + expect(await launcher.open(), isTrue); + expect(launched, hasLength(1)); + expect( + launched.single.url, + Uri.parse('https://portal.arasystem.it.kr/app'), + ); + expect(launched.single.mode, LaunchMode.externalApplication); + }); + + test('열지 못하면 거짓 — 알림 글은 받기 화면 주소를 스킴 없이 담는다', () async { + final AppDownloadLauncher launcher = launcherOf( + 'https://portal.arasystem.it.kr/api', + answer: () async => false, + ); + + expect(await launcher.open(), isFalse); + expect( + launcher.failureMessage, + '브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 portal.arasystem.it.kr/app에 들어가 새 버전을 받아 주세요.', + ); + }); + + test('여는 쪽이 던지면(받을 앱 없음) 삼키고 거짓 — 예외의 주소를 밖으로 내보내지 않는다', () async { + final AppDownloadLauncher launcher = launcherOf( + 'https://portal.arasystem.it.kr/api', + answer: () async => throw PlatformException( + code: 'ACTIVITY_NOT_FOUND', + message: + 'No Activity found to handle https://portal.arasystem.it.kr/app', + ), + ); + + expect(await launcher.open(), isFalse); + expect(launched, hasLength(1)); + }); + + test('API 주소로 받기 화면 주소를 만들지 못하면 열지 않고 거짓 — 주소 없는 알림 글', () async { + final AppDownloadLauncher launcher = launcherOf( + 'portal.arasystem.it.kr/api', + answer: () async => true, + ); + + expect(launcher.pageUrl, isNull); + expect(await launcher.open(), isFalse); + expect(launched, isEmpty); + expect(launcher.failureMessage, AppDownloadTexts.openFailedNoAddress); + }); + + test('프로바이더는 앱의 API 주소(apiConfigProvider)로 받기 화면 주소를 만든다', () { + final ProviderContainer container = ProviderContainer( + retry: noAutomaticRetry, + overrides: [ + apiConfigProvider.overrideWithValue( + const ApiConfig(baseUrl: 'http://10.0.2.2:5173/api'), + ), + ], + ); + addTearDown(container.dispose); + + expect( + container.read(appDownloadLauncherProvider).pageUrl, + Uri.parse('http://10.0.2.2:5173/app'), + ); + }); +} diff --git a/flutter/test/core/app_download/app_download_link_test.dart b/flutter/test/core/app_download/app_download_link_test.dart new file mode 100644 index 0000000..f336d9b --- /dev/null +++ b/flutter/test/core/app_download/app_download_link_test.dart @@ -0,0 +1,119 @@ +import 'package:arasystem_app/core/app_download/app_download_link.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 포털의 휴대폰 앱 받기 화면 주소 — 앱의 API 주소(같은 주소 배포 — 포털 출처의 `/api`)에서 `/api` 를 뗀 출처 + `/app` +void main() { + group('받기 화면 주소', () { + test('운영 — https://portal.arasystem.it.kr/api 의 출처에 /app', () { + expect( + appDownloadPageUrlOf('https://portal.arasystem.it.kr/api'), + Uri.parse('https://portal.arasystem.it.kr/app'), + ); + expect( + appDownloadPageUrlOf('https://portal.arasystem.it.kr/api/'), + Uri.parse('https://portal.arasystem.it.kr/app'), + reason: '끝의 / 가 붙어도 같다', + ); + expect( + appDownloadPageUrlOf(' https://portal.arasystem.it.kr/api '), + Uri.parse('https://portal.arasystem.it.kr/app'), + reason: '앞뒤 공백은 뗀다', + ); + }); + + test('개발 — 포트는 그대로 둔다(에뮬레이터 · vite 개발 서버)', () { + expect( + appDownloadPageUrlOf('http://10.0.2.2:3000/api'), + Uri.parse('http://10.0.2.2:3000/app'), + ); + expect( + appDownloadPageUrlOf('http://10.0.2.2:5173/api'), + Uri.parse('http://10.0.2.2:5173/app'), + ); + expect( + appDownloadPageUrlOf('http://localhost:5173/api'), + Uri.parse('http://localhost:5173/app'), + ); + expect( + appDownloadPageUrlOf('http://[::1]:3000/api'), + Uri.parse('http://[::1]:3000/app'), + reason: 'IPv6 주소는 대괄호째', + ); + }); + + test('스킴의 기본 포트는 적지 않고, 그 밖의 포트는 남긴다', () { + expect( + appDownloadPageUrlOf('https://portal.arasystem.it.kr:443/api'), + Uri.parse('https://portal.arasystem.it.kr/app'), + ); + expect( + appDownloadPageUrlOf('http://portal.arasystem.it.kr:80/api'), + Uri.parse('http://portal.arasystem.it.kr/app'), + ); + expect( + appDownloadPageUrlOf('https://portal.arasystem.it.kr:8443/api'), + Uri.parse('https://portal.arasystem.it.kr:8443/app'), + ); + }); + + test('출처만 남긴다 — 쿼리 · 조각 · 사용자 정보는 버리고 대문자는 소문자로', () { + final Uri? url = appDownloadPageUrlOf( + 'HTTPS://user:pw@Portal.Arasystem.IT.KR/api?x=1#y', + ); + expect(url, Uri.parse('https://portal.arasystem.it.kr/app')); + expect(url!.userInfo, isEmpty); + expect(url.hasQuery, isFalse); + expect(url.hasFragment, isFalse); + expect(url.toString(), 'https://portal.arasystem.it.kr/app'); + }); + + test('http · https 주소가 아니거나 호스트가 없으면 null', () { + for (final String input in [ + '', + ' ', + 'portal.arasystem.it.kr/api', + '/api', + 'not a url', + 'ftp://portal.arasystem.it.kr/api', + 'file:///api', + 'market://details?id=kr.it.arasystem.app', + 'https:///api', + 'http://[::1', + ]) { + expect(appDownloadPageUrlOf(input), isNull, reason: input); + } + }); + + test('경로는 웹의 받기 화면 경로와 같다', () { + expect(kAppDownloadPagePath, '/app'); + }); + }); + + group('알림에 보이는 주소 — 스킴 없이', () { + test('호스트 · 포트 · 경로', () { + expect( + appDownloadPageAddressOf( + Uri.parse('https://portal.arasystem.it.kr/app'), + ), + 'portal.arasystem.it.kr/app', + ); + expect( + appDownloadPageAddressOf(Uri.parse('http://10.0.2.2:5173/app')), + '10.0.2.2:5173/app', + ); + expect( + appDownloadPageAddressOf(Uri.parse('http://[::1]:3000/app')), + '[::1]:3000/app', + ); + }); + + test('만든 주소를 그대로 보인다', () { + expect( + appDownloadPageAddressOf( + appDownloadPageUrlOf('https://portal.arasystem.it.kr/api')!, + ), + 'portal.arasystem.it.kr/app', + ); + }); + }); +} diff --git a/flutter/test/features/app_update/app_update_page_test.dart b/flutter/test/features/app_update/app_update_page_test.dart index 1d3b18e..28b0896 100644 --- a/flutter/test/features/app_update/app_update_page_test.dart +++ b/flutter/test/features/app_update/app_update_page_test.dart @@ -1,10 +1,12 @@ import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/app_download/app_download_launcher.dart'; +import 'package:arasystem_app/core/app_download/app_download_texts.dart'; import 'package:arasystem_app/core/constants/app_info_provider.dart'; import 'package:arasystem_app/core/constants/screen_titles.dart'; -import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart'; import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart'; import 'package:arasystem_app/features/app_update/presentation/pages/app_update_page.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -12,19 +14,19 @@ import 'package:url_launcher/url_launcher.dart'; import '../../support/fakes.dart'; import '../../support/login_fakes.dart'; -/// 시험 앱의 패키지 이름 -const String _package = 'kr.it.arasystem.app'; +/// 운영의 앱 API 주소 — 받기 화면은 그 출처의 `/app` +const String _apiBaseUrl = 'https://portal.arasystem.it.kr/api'; -/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다 +/// 업데이트 화면 — 앱 보드 `App1Push` ⑩(2026-11-08 사내 직접 배포). 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다 void main() { late LoginHarness h; - /// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다(주소마다) + /// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다 final List<({Uri url, LaunchMode mode})> launched = <({Uri url, LaunchMode mode})>[]; - late bool Function(Uri url) opens; + late Future Function(Uri url) opens; - Future launch(Uri url, LaunchMode mode) async { + Future launch(Uri url, LaunchMode mode) { launched.add((url: url, mode: mode)); return opens(url); } @@ -32,7 +34,7 @@ void main() { setUp(() { h = LoginHarness(); launched.clear(); - opens = (Uri _) => true; + opens = (Uri _) async => true; h.server.on( 'GET', ApiPaths.appConfig, @@ -43,25 +45,24 @@ void main() { ); }); - Future pump(WidgetTester tester, {String? packageName = _package}) => + Future pump(WidgetTester tester, {String apiBaseUrl = _apiBaseUrl}) => h.pump( tester, overrides: [ appVersionProvider.overrideWithValue('1.0.0'), - appPackageNameProvider.overrideWithValue(packageName), - appStoreLauncherProvider.overrideWithValue( - AppStoreLauncher(launch: launch), + appDownloadLauncherProvider.overrideWithValue( + AppDownloadLauncher(apiBaseUrl: apiBaseUrl, launch: launch), ), ], ); - Finder storeButton() => - find.widgetWithText(FilledButton, AppUpdateTexts.openStore); + Finder downloadButton() => + find.widgetWithText(FilledButton, AppDownloadTexts.getNewVersion); Finder snack(String text) => find.descendant(of: find.byType(SnackBar), matching: findCopy(text)); - testWidgets('제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄(연락처)', ( + testWidgets('제목 · 글 · [새 버전 받기] · 지금 쓰는 판 · 문의 줄(연락처)', ( WidgetTester tester, ) async { h.supportContact('정보보안팀 02-1234-5678'); @@ -70,12 +71,21 @@ void main() { expect(find.byType(AppUpdatePage), findsOneWidget); expect(find.text(ScreenTitles.update), findsOneWidget); expect(findCopy(AppUpdateTexts.message), findsOneWidget); - expect(storeButton(), findsOneWidget); + expect(downloadButton(), findsOneWidget); + expect( + find.descendant( + of: downloadButton(), + matching: find.byIcon(Icons.open_in_new), + ), + findsOneWidget, + reason: '바깥(브라우저)으로 연다', + ); expect(find.text(AppUpdateTexts.currentVersion('1.0.0')), findsOneWidget); expect( findCopy('${AppUpdateTexts.supportQuestion}\n정보보안팀 02-1234-5678'), findsOneWidget, ); + expect(launched, isEmpty, reason: '누르기 전에는 열지 않는다'); expect(h.notifier.shownCodes, isEmpty); }); @@ -86,54 +96,72 @@ void main() { expect(findCopy(AppUpdateTexts.supportFallback), findsOneWidget); }); - testWidgets('[Play 스토어 열기] — Play 스토어 앱부터, 열리지 않으면 웹 주소를 바깥 앱으로', ( + testWidgets('[새 버전 받기] — 포털의 휴대폰 앱 받기 화면을 바깥 브라우저로 한 번 연다', ( WidgetTester tester, ) async { - opens = (Uri url) => url.scheme != 'market'; await pump(tester); - await tester.tap(storeButton()); - await tester.pumpAndSettle(); - - expect(launched, hasLength(2)); - expect(launched.first.url.toString(), 'market://details?id=$_package'); - expect(launched.first.mode, LaunchMode.externalNonBrowserApplication); - expect( - launched.last.url.toString(), - 'https://play.google.com/store/apps/details?id=$_package', - ); - expect(launched.last.mode, LaunchMode.externalApplication); - expect(snack(AppUpdateTexts.openStoreFailed), findsNothing); - }); - - testWidgets('Play 스토어 앱이 열리면 웹 주소는 열지 않는다', (WidgetTester tester) async { - await pump(tester); - - await tester.tap(storeButton()); + await tester.tap(downloadButton()); await tester.pumpAndSettle(); expect(launched, hasLength(1)); - expect(launched.single.url.scheme, 'market'); + expect( + launched.single.url, + Uri.parse('https://portal.arasystem.it.kr/app'), + ); + expect(launched.single.mode, LaunchMode.externalApplication); + expect(find.byType(SnackBar), findsNothing); + expect(find.byType(AppUpdatePage), findsOneWidget, reason: '화면은 그대로'); }); - testWidgets('둘 다 열지 못하면 직접 찾아 업데이트해 달라고 알린다', (WidgetTester tester) async { - opens = (Uri _) => false; + testWidgets('브라우저를 열지 못하면 받기 화면 주소를 스킴 없이 담아 알린다', ( + WidgetTester tester, + ) async { + opens = (Uri _) async => false; await pump(tester); - await tester.tap(storeButton()); + await tester.tap(downloadButton()); await tester.pumpAndSettle(); - expect(launched, hasLength(2)); - expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget); + expect(launched, hasLength(1)); + expect( + snack(AppDownloadTexts.openFailed('portal.arasystem.it.kr/app')), + findsOneWidget, + ); + expect( + AppDownloadTexts.openFailed('portal.arasystem.it.kr/app'), + '브라우저를 열지 못했습니다. 휴대폰의 브라우저에서 portal.arasystem.it.kr/app에 들어가 새 버전을 받아 주세요.', + reason: '보드 ⑩ 의 알림 글 그대로', + ); }); - testWidgets('패키지 이름을 읽지 못했으면 열지 않고 알린다', (WidgetTester tester) async { - await pump(tester, packageName: null); + testWidgets('여는 쪽이 던져도(받을 앱 없음) 삼키고 같은 알림 — 개발 주소는 포트까지 보인다', ( + WidgetTester tester, + ) async { + opens = (Uri url) async => + throw PlatformException(code: 'ACTIVITY_NOT_FOUND', message: '$url'); + await pump(tester, apiBaseUrl: 'http://10.0.2.2:5173/api'); - await tester.tap(storeButton()); + await tester.tap(downloadButton()); + await tester.pumpAndSettle(); + + expect(launched.single.url, Uri.parse('http://10.0.2.2:5173/app')); + expect( + snack(AppDownloadTexts.openFailed('10.0.2.2:5173/app')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('API 주소로 받기 화면 주소를 만들지 못하면 열지 않고 관리자에게 문의해 달라고 알린다', ( + WidgetTester tester, + ) async { + await pump(tester, apiBaseUrl: 'portal.arasystem.it.kr/api'); + + await tester.tap(downloadButton()); await tester.pumpAndSettle(); expect(launched, isEmpty); - expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget); + expect(snack(AppDownloadTexts.openFailedNoAddress), findsOneWidget); }); } diff --git a/flutter/test/features/settings/settings_page_test.dart b/flutter/test/features/settings/settings_page_test.dart index 1432494..f34d779 100644 --- a/flutter/test/features/settings/settings_page_test.dart +++ b/flutter/test/features/settings/settings_page_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/app_download/app_download_launcher.dart'; +import 'package:arasystem_app/core/app_download/app_download_texts.dart'; import 'package:arasystem_app/core/push/notification_system.dart'; import 'package:arasystem_app/core/push/push_messaging.dart'; import 'package:arasystem_app/core/push/push_preferences.dart'; @@ -9,6 +11,7 @@ import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../support/fakes.dart'; import '../../support/login_fakes.dart'; @@ -101,6 +104,115 @@ void main() { }); }); + group('설정 › 앱 정보 › [새 버전 받기] — 앱 보드 App1Push ④', () { + /// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다 + final List<({Uri url, LaunchMode mode})> launched = + <({Uri url, LaunchMode mode})>[]; + late bool opens; + + setUp(() { + launched.clear(); + opens = true; + h.app.overrides.add( + appDownloadLauncherProvider.overrideWithValue( + AppDownloadLauncher( + apiBaseUrl: 'https://portal.arasystem.it.kr/api', + launch: (Uri url, LaunchMode mode) async { + launched.add((url: url, mode: mode)); + return opens; + }, + ), + ), + ); + }); + + Finder downloadRow() => find.ancestor( + of: find.text(AppDownloadTexts.getNewVersion), + matching: find.byType(InkWell), + ); + + Finder rowIcon(String label, IconData icon) => find.descendant( + of: find.ancestor(of: find.text(label), matching: find.byType(InkWell)), + matching: find.byIcon(icon), + ); + + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: findCopy(text)); + + testWidgets('줄 차례 — 앱 버전 · 새 버전 받기 · 오픈소스 라이선스, 끝 아이콘은 바깥으로 여는 아이콘', ( + WidgetTester tester, + ) async { + await h.pump(tester); + await h.openSettings(tester); + + final double version = tester + .getTopLeft(find.text(SettingsTexts.appVersion)) + .dy; + final double download = tester + .getTopLeft(find.text(AppDownloadTexts.getNewVersion)) + .dy; + final double licenses = tester + .getTopLeft(find.text(SettingsTexts.licenses)) + .dy; + expect(version, lessThan(download)); + expect(download, lessThan(licenses)); + expect( + rowIcon(AppDownloadTexts.getNewVersion, Icons.open_in_new), + findsOneWidget, + ); + expect( + rowIcon(AppDownloadTexts.getNewVersion, Icons.chevron_right), + findsNothing, + ); + expect( + rowIcon(SettingsTexts.licenses, Icons.chevron_right), + findsOneWidget, + ); + expect( + tester.getSize(downloadRow()).height, + greaterThanOrEqualTo(52), + reason: '한 줄 칸 52 — 앱 버전 · 오픈소스 라이선스와 같다', + ); + expect(launched, isEmpty); + }); + + testWidgets('누르면 업데이트 화면과 같은 받기 화면을 바깥 브라우저로 연다 — 설정에 머문다', ( + WidgetTester tester, + ) async { + await h.pump(tester); + await h.openSettings(tester); + + await tapVisible(tester, find.text(AppDownloadTexts.getNewVersion)); + + expect(launched, hasLength(1)); + expect( + launched.single.url, + Uri.parse('https://portal.arasystem.it.kr/app'), + ); + expect(launched.single.mode, LaunchMode.externalApplication); + expect(h.app.location(), '/settings'); + expect(find.byType(SnackBar), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('브라우저를 열지 못하면 업데이트 화면과 같은 알림 — 받기 화면 주소를 담는다', ( + WidgetTester tester, + ) async { + opens = false; + await h.pump(tester); + await h.openSettings(tester); + + await tapVisible(tester, find.text(AppDownloadTexts.getNewVersion)); + + expect(launched, hasLength(1)); + expect( + snack(AppDownloadTexts.openFailed('portal.arasystem.it.kr/app')), + findsOneWidget, + ); + expect(h.app.location(), '/settings'); + }); + }); + group('[로그아웃] — 확인 창부터', () { testWidgets('처음 초점은 [취소] — [취소]면 머물고 로그아웃하지 않는다', ( WidgetTester tester,