388 lines
17 KiB
Kotlin
388 lines
17 KiB
Kotlin
|
|
// Main.kt
|
||
|
|
|
||
|
|
import androidx.compose.foundation.background
|
||
|
|
import androidx.compose.foundation.clickable
|
||
|
|
import androidx.compose.foundation.layout.*
|
||
|
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||
|
|
import androidx.compose.foundation.rememberScrollState
|
||
|
|
import androidx.compose.foundation.shape.CircleShape
|
||
|
|
import androidx.compose.foundation.verticalScroll
|
||
|
|
import androidx.compose.material.*
|
||
|
|
import androidx.compose.material.icons.Icons
|
||
|
|
import androidx.compose.material.icons.filled.DateRange
|
||
|
|
import androidx.compose.runtime.*
|
||
|
|
import androidx.compose.ui.Alignment
|
||
|
|
import androidx.compose.ui.Modifier
|
||
|
|
import androidx.compose.ui.draw.clip
|
||
|
|
import androidx.compose.ui.graphics.Color
|
||
|
|
import androidx.compose.ui.text.font.FontWeight
|
||
|
|
import androidx.compose.ui.text.style.TextAlign
|
||
|
|
import androidx.compose.ui.unit.dp
|
||
|
|
import androidx.compose.ui.unit.sp
|
||
|
|
import androidx.compose.ui.window.Dialog
|
||
|
|
import androidx.compose.ui.window.Window
|
||
|
|
import androidx.compose.ui.window.application
|
||
|
|
import io.jsonwebtoken.Jwts
|
||
|
|
import io.jsonwebtoken.SignatureAlgorithm
|
||
|
|
import io.ktor.client.*
|
||
|
|
import io.ktor.client.call.*
|
||
|
|
import io.ktor.client.engine.cio.*
|
||
|
|
import io.ktor.client.plugins.contentnegotiation.*
|
||
|
|
import io.ktor.client.plugins.logging.LogLevel
|
||
|
|
import io.ktor.client.plugins.logging.Logger
|
||
|
|
import io.ktor.client.plugins.logging.Logging
|
||
|
|
import io.ktor.client.request.*
|
||
|
|
import io.ktor.http.HttpHeaders
|
||
|
|
import io.ktor.serialization.kotlinx.json.*
|
||
|
|
import kotlinx.coroutines.async
|
||
|
|
import kotlinx.coroutines.coroutineScope
|
||
|
|
import kotlinx.coroutines.launch
|
||
|
|
import kotlinx.serialization.Serializable
|
||
|
|
import kotlinx.serialization.json.Json
|
||
|
|
import java.awt.FileDialog
|
||
|
|
import java.awt.Frame
|
||
|
|
import java.io.File
|
||
|
|
import java.nio.file.Files
|
||
|
|
import java.nio.file.Paths
|
||
|
|
import java.security.KeyFactory
|
||
|
|
import java.security.PrivateKey
|
||
|
|
import java.security.spec.PKCS8EncodedKeySpec
|
||
|
|
import java.text.NumberFormat
|
||
|
|
import java.time.DayOfWeek
|
||
|
|
import java.time.Instant
|
||
|
|
import java.time.LocalDate
|
||
|
|
import java.time.YearMonth
|
||
|
|
import java.time.format.DateTimeFormatter
|
||
|
|
import java.time.temporal.TemporalAdjusters
|
||
|
|
import java.util.*
|
||
|
|
import java.util.prefs.Preferences
|
||
|
|
|
||
|
|
// --- 상수 정의 ---
|
||
|
|
private const val ISSUER_ID_KEY = "APP_STORE_ISSUER_ID"
|
||
|
|
private const val KEY_ID_KEY = "APP_STORE_KEY_ID"
|
||
|
|
private const val APP_ID_KEY = "APP_STORE_APP_ID" // Vendor Number 대신 App ID 사용
|
||
|
|
|
||
|
|
// --- 데이터 클래스 (JSON 파싱용) ---
|
||
|
|
@Serializable
|
||
|
|
data class AnalyticsMetric(
|
||
|
|
val data: List<MetricData> = emptyList()
|
||
|
|
)
|
||
|
|
|
||
|
|
@Serializable
|
||
|
|
data class MetricData(
|
||
|
|
val dataPoints: DataPoints
|
||
|
|
)
|
||
|
|
|
||
|
|
@Serializable
|
||
|
|
data class DataPoints(
|
||
|
|
val totals: Totals? = null
|
||
|
|
)
|
||
|
|
|
||
|
|
@Serializable
|
||
|
|
data class Totals(
|
||
|
|
val count: Long? = null
|
||
|
|
)
|
||
|
|
|
||
|
|
// --- 화면에 표시할 최종 분석 결과 데이터 클래스 ---
|
||
|
|
data class AnalyticsResult(
|
||
|
|
val installations: Long = 0,
|
||
|
|
val redownloads: Long = 0,
|
||
|
|
val updates: Long = 0
|
||
|
|
)
|
||
|
|
|
||
|
|
// --- API 클라이언트 ---
|
||
|
|
suspend fun fetchAnalyticsMetric(
|
||
|
|
client: HttpClient,
|
||
|
|
token: String,
|
||
|
|
appId: String,
|
||
|
|
metricType: String,
|
||
|
|
date: String
|
||
|
|
): Long {
|
||
|
|
try {
|
||
|
|
val response: AnalyticsMetric = client.get("https://api.appstoreconnect.apple.com/v1/apps/$appId/metrics") {
|
||
|
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||
|
|
parameter("granularity", "DAY")
|
||
|
|
parameter("filter[metricType]", metricType)
|
||
|
|
parameter("filter[days]", date)
|
||
|
|
}.body()
|
||
|
|
return response.data.firstOrNull()?.dataPoints?.totals?.count ?: 0L
|
||
|
|
} catch (e: Exception) {
|
||
|
|
println("Error fetching metric $metricType: ${e.message}")
|
||
|
|
throw e // 오류를 상위로 전파하여 처리
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@Composable
|
||
|
|
fun SimpleDatePicker(
|
||
|
|
initialDate: LocalDate,
|
||
|
|
onDateSelected: (LocalDate) -> Unit,
|
||
|
|
onDismissRequest: () -> Unit
|
||
|
|
) {
|
||
|
|
var displayedYearMonth by remember { mutableStateOf(YearMonth.from(initialDate)) }
|
||
|
|
Dialog(onDismissRequest = onDismissRequest) {
|
||
|
|
Surface(
|
||
|
|
shape = MaterialTheme.shapes.medium,
|
||
|
|
modifier = Modifier.padding(16.dp).widthIn(max = 320.dp),
|
||
|
|
elevation = 8.dp
|
||
|
|
) {
|
||
|
|
Column(
|
||
|
|
modifier = Modifier.padding(16.dp),
|
||
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
||
|
|
) {
|
||
|
|
Row(
|
||
|
|
modifier = Modifier.fillMaxWidth(),
|
||
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||
|
|
verticalAlignment = Alignment.CenterVertically
|
||
|
|
) {
|
||
|
|
Button(onClick = { displayedYearMonth = displayedYearMonth.minusMonths(1) }) { Text("<") }
|
||
|
|
Text(
|
||
|
|
text = displayedYearMonth.format(DateTimeFormatter.ofPattern("yyyy년 MM월")),
|
||
|
|
style = MaterialTheme.typography.h6
|
||
|
|
)
|
||
|
|
Button(onClick = { displayedYearMonth = displayedYearMonth.plusMonths(1) }) { Text(">") }
|
||
|
|
}
|
||
|
|
Spacer(Modifier.height(16.dp))
|
||
|
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||
|
|
listOf("일", "월", "화", "수", "목", "금", "토").forEach { day ->
|
||
|
|
Text(
|
||
|
|
text = day,
|
||
|
|
modifier = Modifier.weight(1f),
|
||
|
|
textAlign = TextAlign.Center,
|
||
|
|
fontWeight = FontWeight.Bold
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Spacer(Modifier.height(8.dp))
|
||
|
|
val daysInMonth = displayedYearMonth.lengthOfMonth()
|
||
|
|
val firstDayOfMonth = displayedYearMonth.atDay(1)
|
||
|
|
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
|
||
|
|
LazyVerticalGrid(
|
||
|
|
columns = androidx.compose.foundation.lazy.grid.GridCells.Fixed(7),
|
||
|
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||
|
|
) {
|
||
|
|
items(firstDayOfWeek) { Box(Modifier.size(40.dp)) }
|
||
|
|
items(daysInMonth) { day ->
|
||
|
|
val date = displayedYearMonth.atDay(day + 1)
|
||
|
|
val isSelected = date == initialDate
|
||
|
|
Box(
|
||
|
|
modifier = Modifier
|
||
|
|
.size(40.dp)
|
||
|
|
.clip(CircleShape)
|
||
|
|
.background(if (isSelected) MaterialTheme.colors.primary else Color.Transparent)
|
||
|
|
.clickable { onDateSelected(date) },
|
||
|
|
contentAlignment = Alignment.Center
|
||
|
|
) {
|
||
|
|
Text(
|
||
|
|
text = "${day + 1}",
|
||
|
|
color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@Composable
|
||
|
|
fun App(
|
||
|
|
filePath: String,
|
||
|
|
onFilePathChange: (String) -> Unit
|
||
|
|
) {
|
||
|
|
var error by remember { mutableStateOf<String?>(null) }
|
||
|
|
val prefs = remember { Preferences.userRoot().node("com.example.analyticsreportgenerator") }
|
||
|
|
|
||
|
|
var issuerId by remember { mutableStateOf(prefs.get(ISSUER_ID_KEY, "")) }
|
||
|
|
var keyId by remember { mutableStateOf(prefs.get(KEY_ID_KEY, "")) }
|
||
|
|
var appId by remember { mutableStateOf(prefs.get(APP_ID_KEY, "")) } // Vendor Number 대신 App ID
|
||
|
|
|
||
|
|
var statusMessage by remember { mutableStateOf("분석할 앱의 정보를 입력하고 날짜를 선택하세요.") }
|
||
|
|
var analyticsResult by remember { mutableStateOf<AnalyticsResult?>(null) }
|
||
|
|
|
||
|
|
val scope = rememberCoroutineScope()
|
||
|
|
val dailyFormat = DateTimeFormatter.ISO_LOCAL_DATE
|
||
|
|
var reportDate by remember { mutableStateOf(LocalDate.now().minusDays(2)) }
|
||
|
|
var showDatePicker by remember { mutableStateOf(false) }
|
||
|
|
|
||
|
|
val client = remember {
|
||
|
|
HttpClient(CIO) {
|
||
|
|
install(Logging) {
|
||
|
|
logger = object : Logger {
|
||
|
|
override fun log(message: String) {
|
||
|
|
println("===== KTOR API LOG =====\n$message\n========================")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
level = LogLevel.ALL
|
||
|
|
}
|
||
|
|
// JSON 파싱을 위한 ContentNegotiation 플러그인 설치
|
||
|
|
install(ContentNegotiation) {
|
||
|
|
json(Json {
|
||
|
|
ignoreUnknownKeys = true // API 응답에 모르는 필드가 있어도 무시
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
DisposableEffect(Unit) { onDispose { client.close() } }
|
||
|
|
val privateKey: PrivateKey? by remember(filePath) { derivedStateOf { if (filePath.isNotBlank()) { try { error = null; loadPrivateKey(filePath) } catch (e: Exception) { error = "개인키 파일 로드 실패: ${e.message}"; null } } else { null } } }
|
||
|
|
fun isReady(): Boolean {
|
||
|
|
if (privateKey == null) { error = "개인키 파일(.p8)을 선택하세요."; return false }
|
||
|
|
if (issuerId.isBlank() || keyId.isBlank()) { error = "Issuer ID와 Key ID를 입력하세요."; return false }
|
||
|
|
if (appId.isBlank()) { error = "분석을 위해 App ID를 입력하세요."; return false }
|
||
|
|
error = null; return true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (showDatePicker) {
|
||
|
|
SimpleDatePicker(
|
||
|
|
initialDate = reportDate,
|
||
|
|
onDismissRequest = { showDatePicker = false },
|
||
|
|
onDateSelected = { date ->
|
||
|
|
reportDate = date
|
||
|
|
showDatePicker = false
|
||
|
|
}
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
Row(modifier = Modifier.fillMaxSize()) {
|
||
|
|
Column(modifier = Modifier.width(350.dp).fillMaxHeight().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||
|
|
Text("Issuer ID"); TextField(value = issuerId, onValueChange = {issuerId = it; prefs.put(ISSUER_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp))
|
||
|
|
Text("Key ID"); TextField(value = keyId, onValueChange = {keyId = it; prefs.put(KEY_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp))
|
||
|
|
Text("App ID (앱 고유 ID)"); TextField(value = appId, onValueChange = {appId = it; prefs.put(APP_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp))
|
||
|
|
|
||
|
|
Text("개인키(.p8) 파일 (버튼을 클릭하여 파일 선택)")
|
||
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
|
|
TextField(value = filePath, onValueChange = onFilePathChange, singleLine = true, modifier = Modifier.weight(1f), readOnly = true)
|
||
|
|
Spacer(Modifier.width(8.dp))
|
||
|
|
Button(onClick = {
|
||
|
|
val dialog = FileDialog(Frame(), "개인키(.p8) 파일을 선택하세요", FileDialog.LOAD).apply {
|
||
|
|
setFile("*.p8")
|
||
|
|
isVisible = true
|
||
|
|
}
|
||
|
|
if (dialog.directory != null && dialog.file != null) {
|
||
|
|
onFilePathChange(dialog.directory + dialog.file)
|
||
|
|
}
|
||
|
|
}) { Text("찾기...") }
|
||
|
|
}
|
||
|
|
|
||
|
|
Divider(modifier = Modifier.padding(vertical = 16.dp))
|
||
|
|
|
||
|
|
Text("조회 날짜 (YYYY-MM-DD)")
|
||
|
|
Box(modifier = Modifier.clickable { showDatePicker = true }) {
|
||
|
|
OutlinedTextField(
|
||
|
|
value = reportDate.format(dailyFormat), onValueChange = { }, readOnly = true, modifier = Modifier.fillMaxWidth(),
|
||
|
|
trailingIcon = { Icon(imageVector = Icons.Default.DateRange, contentDescription = "날짜 선택") },
|
||
|
|
enabled = false,
|
||
|
|
colors = TextFieldDefaults.outlinedTextFieldColors(
|
||
|
|
disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current),
|
||
|
|
disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled),
|
||
|
|
disabledLabelColor = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium),
|
||
|
|
disabledTrailingIconColor = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.IconOpacity)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
Spacer(Modifier.height(16.dp))
|
||
|
|
Button(
|
||
|
|
onClick = {
|
||
|
|
if (!isReady()) return@Button
|
||
|
|
scope.launch {
|
||
|
|
try {
|
||
|
|
analyticsResult = null
|
||
|
|
val dateString = reportDate.format(dailyFormat)
|
||
|
|
statusMessage = "[$dateString] 앱 분석 데이터 로딩 중..."
|
||
|
|
error = null
|
||
|
|
val reportToken = createJWT(privateKey!!, issuerId, keyId, listOf("GET:/v1/apps/$appId/metrics"))
|
||
|
|
|
||
|
|
// 3가지 메트릭을 동시에 요청 (병렬 처리)
|
||
|
|
coroutineScope {
|
||
|
|
val installsDeferred = async { fetchAnalyticsMetric(client, reportToken, appId, "INSTALLATIONS", dateString) }
|
||
|
|
val redownloadsDeferred = async { fetchAnalyticsMetric(client, reportToken, appId, "REDOWNLOADS", dateString) }
|
||
|
|
val updatesDeferred = async { fetchAnalyticsMetric(client, reportToken, appId, "UPDATES", dateString) }
|
||
|
|
|
||
|
|
analyticsResult = AnalyticsResult(
|
||
|
|
installations = installsDeferred.await(),
|
||
|
|
redownloads = redownloadsDeferred.await(),
|
||
|
|
updates = updatesDeferred.await()
|
||
|
|
)
|
||
|
|
}
|
||
|
|
statusMessage = "[$dateString] 분석 완료"
|
||
|
|
} catch (e: Exception) {
|
||
|
|
println("===== 작업 실패 ====="); e.printStackTrace(); error = "작업 오류: ${e.message}"; statusMessage = "오류: ${e.message}"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
modifier = Modifier.fillMaxWidth(), enabled = privateKey != null
|
||
|
|
) { Text("앱 분석 통계 조회") }
|
||
|
|
Spacer(Modifier.height(16.dp))
|
||
|
|
if (error != null) { Text(text = error ?: "", color = Color.Red, modifier = Modifier.padding(top = 8.dp)) }
|
||
|
|
}
|
||
|
|
Divider(modifier = Modifier.fillMaxHeight().width(1.dp))
|
||
|
|
|
||
|
|
ReportView(
|
||
|
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||
|
|
result = analyticsResult,
|
||
|
|
statusMessage = statusMessage
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@Composable
|
||
|
|
fun ReportView(
|
||
|
|
modifier: Modifier = Modifier,
|
||
|
|
result: AnalyticsResult?,
|
||
|
|
statusMessage: String
|
||
|
|
) {
|
||
|
|
Column(modifier.padding(16.dp)) {
|
||
|
|
Text(
|
||
|
|
text = statusMessage,
|
||
|
|
style = MaterialTheme.typography.subtitle1,
|
||
|
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
|
||
|
|
modifier = Modifier.padding(bottom = 16.dp)
|
||
|
|
)
|
||
|
|
if (result != null) {
|
||
|
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||
|
|
AnalyticsCard("최초 설치 (Installations)", result.installations, MaterialTheme.colors.primary)
|
||
|
|
AnalyticsCard("재다운로드 (Re-downloads)", result.redownloads, Color(0xFF00897B))
|
||
|
|
AnalyticsCard("업데이트 (Updates)", result.updates, Color(0xFF5E35B1))
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||
|
|
Text("표시할 데이터가 없습니다.", color = Color.Gray)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@Composable
|
||
|
|
fun AnalyticsCard(title: String, value: Long, color: Color) {
|
||
|
|
Card(elevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
|
||
|
|
Column(Modifier.padding(16.dp)) {
|
||
|
|
Text(title, style = MaterialTheme.typography.h6)
|
||
|
|
Spacer(Modifier.height(8.dp))
|
||
|
|
Text(
|
||
|
|
text = NumberFormat.getInstance().format(value),
|
||
|
|
style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold),
|
||
|
|
color = color
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fun loadPrivateKey(path: String): PrivateKey {
|
||
|
|
val keyBytes = Files.readAllBytes(Paths.get(path)); val keyPem = String(keyBytes).replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "").replace("\\s".toRegex(), ""); val decoded = Base64.getDecoder().decode(keyPem); val keySpec = PKCS8EncodedKeySpec(decoded); val kf = KeyFactory.getInstance("EC"); return kf.generatePrivate(keySpec)
|
||
|
|
}
|
||
|
|
|
||
|
|
fun createJWT(privateKey: PrivateKey, issuerId: String, keyId: String, scope: List<String>?): String {
|
||
|
|
val now = Instant.now(); val expire = now.plusSeconds(20 * 60); val builder = Jwts.builder().setHeaderParam("alg", "ES256").setHeaderParam("kid", keyId).setHeaderParam("typ", "JWT").setIssuer(issuerId).setAudience("appstoreconnect-v1").setIssuedAt(Date.from(now)).setExpiration(Date.from(expire)); if (!scope.isNullOrEmpty()) { builder.claim("scope", scope) }; return builder.signWith(privateKey, SignatureAlgorithm.ES256).compact()
|
||
|
|
}
|
||
|
|
|
||
|
|
fun main() = application {
|
||
|
|
var filePath by remember { mutableStateOf("") }
|
||
|
|
Window(onCloseRequest = ::exitApplication, title = "App Analytics 리포트 생성기") {
|
||
|
|
MaterialTheme {
|
||
|
|
App(
|
||
|
|
filePath = filePath,
|
||
|
|
onFilePathChange = { filePath = it }
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|