This commit is contained in:
2026-03-05 18:11:08 +09:00
parent 83546e5e10
commit 70869d7e1e
33 changed files with 1359 additions and 935 deletions
+1
View File
@@ -0,0 +1 @@
/build
+53
View File
@@ -0,0 +1,53 @@
plugins {
id ("com.android.library")
id ("kotlin-android")
}
android {
namespace = "kr.bums.lunatic.utils.gdrive"
compileSdk = 36
defaultConfig {
minSdk = 24
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.activity:activity-ktx:1.8.2") // ActivityResultLauncher용
// 구글 로그인
implementation("com.google.android.gms:play-services-auth:20.7.0")
// 구글 드라이브 API
implementation("com.google.api-client:google-api-client-android:1.33.0") {
exclude(group = "org.apache.httpcomponents")
}
implementation("com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0") {
exclude(group = "org.apache.httpcomponents")
}
// implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("com.google.android.material:material:1.13.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,24 @@
package bums.lunatic.utils.gdrive
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("bums.lunatic.utils.gdrive", appContext.packageName)
}
}
+1
View File
@@ -0,0 +1 @@
<manifest package="kr.bums.lunatic.utils.gdrive"/>
@@ -0,0 +1,76 @@
package kr.gdrive.bums.lunatic.utils
import android.content.Context
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.http.ByteArrayContent
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes
import com.google.api.services.drive.model.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GDriveBackupTask(
private val context: Context,
private val account: GoogleSignInAccount
) {
// 💡 백업 실행 (IO 스레드에서 돌아야 함)
suspend fun executeBackup(payload: BackupPayload): Result<String> = withContext(Dispatchers.IO) {
try {
val credential = GoogleAccountCredential.usingOAuth2(context, listOf(DriveScopes.DRIVE_APPDATA))
credential.selectedAccount = account.account
val driveService = Drive.Builder(
NetHttpTransport(), GsonFactory.getDefaultInstance(), credential
).setApplicationName("LunaticLauncherBackup").build()
// 1. 매니페스트 업데이트
uploadFile(driveService, "appDataFolder", "manifest.json", payload.manifestJson)
// 2. 폴더 가져오기 or 생성
val folderId = getOrCreateFolder(driveService, payload.folderName)
// 3. 파일들 업로드
payload.files.forEach { (fileName, jsonContent) ->
uploadFile(driveService, folderId, fileName, jsonContent)
}
Result.success("백업이 완료되었습니다. (${payload.folderName})")
} catch (e: Exception) {
e.printStackTrace()
Result.failure(e)
}
}
private fun getOrCreateFolder(driveService: Drive, folderName: String): String {
val query = "mimeType='application/vnd.google-apps.folder' and name='$folderName' and 'appDataFolder' in parents and trashed=false"
val fileList = driveService.files().list().setSpaces("appDataFolder").setQ(query).execute()
if (fileList.files.isNotEmpty()) return fileList.files[0].id
val folderMetadata = File().apply {
name = folderName
mimeType = "application/vnd.google-apps.folder"
parents = listOf("appDataFolder")
}
return driveService.files().create(folderMetadata).setFields("id").execute().id
}
private fun uploadFile(driveService: Drive, parentId: String, fileName: String, contentStr: String) {
val content = ByteArrayContent.fromString("application/json", contentStr)
val query = "name='$fileName' and '$parentId' in parents and trashed=false"
val fileList = driveService.files().list().setSpaces("appDataFolder").setQ(query).execute()
if (fileList.files.isNotEmpty()) {
driveService.files().update(fileList.files[0].id, null, content).execute()
} else {
val fileMetadata = File().apply {
name = fileName
parents = listOf(parentId)
}
driveService.files().create(fileMetadata, content).execute()
}
}
}
@@ -0,0 +1,79 @@
package kr.gdrive.bums.lunatic.utils
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.Scope
import com.google.api.services.drive.DriveScopes
class GDriveLoginManager(
private val activity: ComponentActivity,
private val onLoginResult: (Boolean, GoogleSignInAccount?, String?) -> Unit
) {
// 필수 권한: 숨겨진 App Data 폴더 접근 권한
private val driveScope = Scope(DriveScopes.DRIVE_APPDATA)
private val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestScopes(driveScope)
.build()
private val signInClient = GoogleSignIn.getClient(activity, signInOptions)
// 액티비티 생성 시점에 등록되어야 하는 런처
private val signInLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try {
val account = task.getResult(Exception::class.java)
if (account != null && GoogleSignIn.hasPermissions(account, driveScope)) {
onLoginResult(true, account, null)
} else {
onLoginResult(false, null, "드라이브 접근 권한이 부족합니다.")
}
} catch (e: Exception) {
onLoginResult(false, null, "로그인 실패: ${e.message}")
}
} else {
onLoginResult(false, null, "로그인이 취소되었습니다.")
}
}
/**
* 이미 로그인된 유효한 계정이 있는지 확인 (백그라운드에서 유용함)
*/
fun getSignedInAccount(context: Context = activity): GoogleSignInAccount? {
val account = GoogleSignIn.getLastSignedInAccount(context)
return if (account != null && GoogleSignIn.hasPermissions(account, driveScope)) {
account
} else {
null
}
}
/**
* 로그인 화면 띄우기
*/
fun signIn() {
val account = getSignedInAccount()
if (account != null) {
onLoginResult(true, account, null)
} else {
signInLauncher.launch(signInClient.signInIntent)
}
}
/**
* 로그아웃 (연결 해제)
*/
fun signOut(onComplete: () -> Unit) {
signInClient.signOut().addOnCompleteListener { onComplete() }
}
}
@@ -0,0 +1,16 @@
package kr.gdrive.bums.lunatic.utils
sealed class GDriveState {
object Idle : GDriveState()
object StartingLogin : GDriveState()
object Uploading : GDriveState()
class Success(val message: String) : GDriveState()
class Error(val message: String, val exception: Exception? = null) : GDriveState()
}
// 메인 앱에서 백업할 데이터를 담아 보낼 데이터 클래스
data class BackupPayload(
val manifestJson: String, // 최상단에 저장될 버전 정보
val folderName: String, // 예: "2026-03-05" (날짜 폴더명)
val files: Map<String, String> // 파일명(키)과 JSON 텍스트(값)의 쌍
)
@@ -0,0 +1,17 @@
package bums.lunatic.utils.gdrive
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}