참여 기록 SDK (Android)

선택 사항이에요. 넣으면 연결한 테스터가 앱을 열 때 하루 한 번 실행 기록이 와서 초록 도장이 자동으로 찍혀요. 테스터는 매일 체크인 글을 쓰지 않아도 되고, 개발자는 신청서에 “앱 내 실행 기록 기준”이라고 근거를 댈 수 있어요.

보내는 것과 보내지 않는 것

넣는 방법 (3단계)

  1. 아래 파일을 app/src/main/java/dev/firstguest/FirstGuest.kt로 저장해요. 외부 라이브러리는 필요 없어요.
  2. AndroidManifest.xml의 <application> 안에 연결용 Activity를 추가해요.
  3. 첫 화면 Activity의 onResume()에서 FirstGuest.ping(this)를 불러요.

AndroidManifest.xml

<activity
    android:name="dev.firstguest.FirstGuestPairActivity"
    android:exported="true"
    android:theme="@android:style/Theme.Translucent.NoTitleBar">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="firstguest" android:host="pair" />
    </intent-filter>
</activity>

MainActivity.kt

override fun onResume() {
    super.onResume()
    dev.firstguest.FirstGuest.ping(this)
}

FirstGuest.kt

// First Guest 참여 기록 SDK (Android, 파일 하나 · 외부 라이브러리 없음)
//
// 하는 일: 테스터가 First Guest 사이트에서 "앱과 연결하기"를 누르면 이 앱이 열리며 연결 토큰을 저장한다.
// 그 뒤 앱이 열릴 때 하루 한 번 {token, package, versionCode} 만 보낸다. 기기 ID·광고 ID·위치는 보내지 않는다.
// 연결하지 않은 사용자(일반 사용자)에게는 아무것도 보내지 않는다.
//
// 설치
// 1) 이 파일을 app/src/main/java/dev/firstguest/ 에 넣는다 (package 이름은 그대로 둔다).
// 2) AndroidManifest.xml <application> 안에 추가:
//
//    <activity
//        android:name="dev.firstguest.FirstGuestPairActivity"
//        android:exported="true"
//        android:theme="@android:style/Theme.Translucent.NoTitleBar">
//        <intent-filter>
//            <action android:name="android.intent.action.VIEW" />
//            <category android:name="android.intent.category.DEFAULT" />
//            <category android:name="android.intent.category.BROWSABLE" />
//            <data android:scheme="firstguest" android:host="pair" />
//        </intent-filter>
//    </activity>
//
//    인터넷 권한이 없다면: <uses-permission android:name="android.permission.INTERNET" />
// 3) 첫 화면 Activity 의 onResume() 에서 FirstGuest.ping(this) 를 부른다.
// 4) 프로덕션 출시 전에 빼도 되고, 남겨 두어도 연결된 테스터만 보낸다.
//    Play Console "데이터 보안"에는 '앱 활동(앱 상호작용)'을 '앱 기능' 목적으로 수집한다고 신고한다.

package dev.firstguest

import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.widget.Toast
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone

object FirstGuest {
    private const val PREFS = "first_guest"
    private const val DEFAULT_SERVER = "https://firstguest.vercel.app"

    internal fun save(context: Context, token: String, server: String?) {
        context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
            .putString("token", token)
            .putString("server", server?.takeIf { it.startsWith("https://") } ?: DEFAULT_SERVER)
            .remove("last")
            .apply()
    }

    /** 앱이 열릴 때 부른다. 연결된 테스터만, KST 하루에 한 번 보낸다. */
    @JvmStatic
    fun ping(context: Context) {
        val app = context.applicationContext
        val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
        val token = prefs.getString("token", null) ?: return
        val server = prefs.getString("server", DEFAULT_SERVER) ?: DEFAULT_SERVER
        val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { timeZone = TimeZone.getTimeZone("Asia/Seoul") }.format(Date())
        if (prefs.getString("last", null) == today) return

        @Suppress("DEPRECATION")
        val versionCode = try {
            app.packageManager.getPackageInfo(app.packageName, 0).versionCode
        } catch (e: Exception) { 0 }

        Thread {
            try {
                val conn = URL("$server/api/v1/ping").openConnection() as HttpURLConnection
                conn.requestMethod = "POST"
                conn.connectTimeout = 8000
                conn.readTimeout = 8000
                conn.doOutput = true
                conn.setRequestProperty("Content-Type", "application/json")
                val body = """{"token":"${token.replace("\"", "")}","package":"${app.packageName}","versionCode":$versionCode}"""
                conn.outputStream.use { it.write(body.toByteArray()) }
                val code = conn.responseCode
                if (code == 200) prefs.edit().putString("last", today).apply()
                // 404(토큰 없음)·409(테스트 끝남)면 더 보내지 않는다.
                if (code == 404 || code == 409) prefs.edit().remove("token").apply()
                conn.disconnect()
            } catch (_: Exception) {
                // 네트워크 오류는 다음 실행 때 다시 시도한다.
            }
        }.start()
    }
}

/** firstguest://pair?token=…&server=… 딥링크를 받아 저장하고 바로 닫힌다. */
class FirstGuestPairActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val uri = intent?.data
        val token = uri?.getQueryParameter("token")
        if (token != null && token.length in 16..64) {
            FirstGuest.save(this, token, uri.getQueryParameter("server"))
            FirstGuest.ping(this)
            Toast.makeText(this, "First Guest와 연결했어요", Toast.LENGTH_SHORT).show()
        }
        packageManager.getLaunchIntentForPackage(packageName)?.let { startActivity(it) }
        finish()
    }
}

테스터 쪽 흐름

테스터가 휴대폰으로 First Guest의 내 테스트 화면에서 ‘앱과 연결하기’를 누르면 firstguest://pair 링크로 앱이 열리고 연결 번호가 저장돼요. 그 뒤로는 앱을 열기만 하면 돼요. 14일이 끝나거나 테스트를 그만두면 서버가 더 받지 않고, SDK도 연결 번호를 지워요.

Play Console 데이터 보안 신고

이 SDK는 테스터가 연결했을 때 ‘앱 활동 › 앱 상호작용’을 ‘앱 기능’ 목적으로 First Guest 서버에 보내요. 데이터 보안 양식에 맞게 신고하고, 프로덕션 출시 전에 빼도 돼요(빼지 않아도 연결하지 않은 사용자에게서는 보내지 않아요).

API

직접 만들고 싶다면 POST https://firstguest.vercel.app/api/v1/ping에 {"token":"…","package":"com.example.app","versionCode":12}를 보내면 돼요. 응답: 200 기록됨, 400 형식 오류, 404 모르는 연결 번호, 409 진행 중이 아니거나 14일이 끝남.