generated from ttipo/project-base
feat(flutter): [새 버전 받기]를 포털 받기 화면으로 바꾸고 release 는 우리 키 · 운영 주소로만 만든다
스토어에 올리지 않으니 업데이트 화면의 Play 스토어 열기를 포털 /app 을 바깥 브라우저로 여는 것으로 바꾸고, 설정 › 앱 정보에도 같은 줄을 더했다. release 빌드는 우리 서명 키(key.properties) · google-services.json · 운영 포털의 https API 주소가 갖춰지지 않으면 까닭을 알리고 멈춘다 — debug 키로 조용히 서명되거나 개발 주소가 든 판이 직원에게 나가지 않게(알리는 글에 주소 값은 싣지 않는다). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>): Map<String, String> {
|
||||
val out = LinkedHashMap<String, String>()
|
||||
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<String> = 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user