// 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 = 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(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(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 { 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 } ) } } }