개발린생

[Android/Compose] ForegroundService 기반 커스텀 잠금화면 구현 및 targetSDK 34 대응 이슈 본문

Dev Lab ✧.·˚/Android & iOS

[Android/Compose] ForegroundService 기반 커스텀 잠금화면 구현 및 targetSDK 34 대응 이슈

김블루 2024. 12. 20. 00:14

안드로이드 기본 잠금 화면 위에 특정 화면을 띄워야하는 기능을 구현해야해서 코드와 글을 작성하게 되었다.

 

혹시 ForegroundService targetSDK 34 이슈로 찾아온 사람을 위해 미리 말하자면,

예제 프로젝트가 컴포즈 프로젝트지만 컴포즈(컴포저블 함수 사용)로 구현한 부분은 단순 UI 쪽 밖에 없어서, 기존 XML 기반 프로젝트여도 도움이 될 것이다.

 

기본 잠금 화면 위에 특정 화면을 띄우기 위해서 중요한 기능은 아래와 같다.

  1. 다른 앱 위에 표시 권한 요청
  2. ForegroundService 기능 구현 (Android 14, SDK 34 대응)

 

이전 글에서 ForegroundService로 구현했던 기능을 WorkManager로 구현했었는데 이번엔 다시 ForegroundService를 다뤄보겠다.

▶️ 궁금하면 구경하기: https://blueland99.tistory.com/21


ForegroundService targetSDK 34 이슈

오류 로그:

java.lang.RuntimeException: Unable to start service com.blueland.lockscreen.service.LockForegroundService@3776a42 with Intent { cmp=com.blueland.lockscreen/.service.LockForegroundService }: android.app.MissingForegroundServiceTypeException: Starting FGS without a type  callerApp=ProcessRecord{a97e72a 20907:com.blueland.lockscreen/u0a1120} targetSDK=34

 

오류 설명:

Android 14 (targetSDK 34) 이상에서 ForegroundService를 시작할 때 foregroundServiceType이 명시되지 않았을 때 발생

 

해결 방법:

ForegroundService 사용 시 foregroundServiceType 명시


Android Compose 커스텀 잠금화면 구현, 밀어서 잠금 해제

주요 기능

  1. 다른 앱 위에 표시 (SYSTEM_ALERT_WINDOW 권한)
    • 사용자에게 SYSTEM_ALERT_WINDOW 권한 요청
    • 권한이 허용되면 커스텀 잠금화면(LockActivity)을 다른 앱 위에 표시
  2. 커스텀 잠금화면
    • Android Compose로 잠금화면 UI 구현
    • 현재 일시 표시, 밀어서 잠금 해제 기능 구현
  3. ForegroundService 적용
    • Android 14, SDK 34 이상의 기기에서  ForegroundService 실행 시 foregroundServiceType 명시
    • 이를 통해 시스템 정책 위반으로 인한 앱 종료 이슈 방지

다른 앱 위에 표시 (SYSTEM_ALERT_WINDOW 권한)

AndroidManifest.xml 파일에 SYSTEM_ALERT_WINDOW 권한 추가

  • SYSTEM_ALERT_WINDOW 권한:
    앱이 다른 앱 위에 UI를 표시할 수 있는 권한이며, Overlay를 사용하여 앱 화면 위에 뷰를 표시하는 기능을 제공한다.
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

 

앱 실행 시 사용자에게 SYSTEM_ALERT_WINDOW 권한 요청

  • ACTION_MANAGE_OVERLAY_PERMISSION:
    SYSTEM_ALERT_WINDOW 권한을 사용자에게 요청하며, 설정 화면으로 이동할 때 사용한다.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    private val TAG = this.javaClass.simpleName // 클래스 이름을 태그로 사용

    @Inject
    lateinit var lockServiceManager: LockServiceManager // LockServiceManager 주입

    // 오버레이 권한 요청용 런처
    private val overlayPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
        if (isOverlayPermissionGranted(this)) {
            Log.e(TAG, "Overlay permission denied") // 권한 거부 시 로그 출력
        } else {
            Log.d(TAG, "Overlay permission granted") // 권한 허용 시 로그 출력
            startLockService() // 잠금 서비스 시작
        }
    }

    // 오버레이 권한이 필요한지 확인
    private fun isOverlayPermissionGranted(context: Context): Boolean {
        return !Settings.canDrawOverlays(context) // 권한 필요 여부 반환
    }

    private fun checkPermissions() {
        // 오버레이 권한 확인 및 요청
        if (isOverlayPermissionGranted(this)) {
            Log.e(TAG, "checkPermissions: Overlay permission needed")
            val overlayIntent = Intent(
                Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                Uri.parse("package:${packageName}") // 현재 패키지의 오버레이 권한 설정 화면으로 이동
            )
            overlayPermissionLauncher.launch(overlayIntent) // 권한 요청 실행
        } else {
            startLockService() // 권한이 있으면 잠금 서비스 시작
        }
    }

    private fun startLockService() {
        // 잠금 서비스 시작
        lockServiceManager.start() 
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        checkPermissions() // 권한 확인 및 요청 시작

        setContent {
            ComposeLockScreenTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        name = "Android",
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

 

 

⚠️ SYSTEM_ALERT_WINDOW 권한은 Manifest에 선언함으로 자동 허용되는 권한이 아니다.

ACTION_MANAGE_OVERLAY_PERMISSION을 통해 사용자가 설정에거 SYSTEM_ALERT_WINDOW 권한을 활성화할 수 있도록 구현해야한다.


커스텀 잠금화면

이 부분은 구현하기 나름이라 중요한 코드라고 보지는 않지만 첨부해본다.

 

🔽 커스텀 잠금화면 코드

더보기
/**
 * 잠금 화면을 표시하는 Activity
 */
@AndroidEntryPoint
class LockActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Android O_MR1 이상에서 잠금 화면에 표시되도록 설정
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
            setShowWhenLocked(true)
        }

        setContent {
            ComposeLockScreenTheme {
                Box(modifier = Modifier.fillMaxSize()) {
                    // 배경 이미지 설정
                    Image(
                        painter = painterResource(id = R.drawable.background), // 여기에 배경 이미지 리소스 ID를 넣으세요
                        contentDescription = null,
                        modifier = Modifier.fillMaxSize(), // 이미지가 전체 화면을 채우도록 설정
                        contentScale = ContentScale.FillBounds
                    )
                    Column(
                        modifier = Modifier.padding(16.dp),
                    ) {
                        // 잠금 해제 상태를 관리하는 변수
                        var isUnlocked by remember { mutableStateOf(false) }

                        Column(
                            modifier = Modifier
                                .fillMaxWidth()
                                .weight(1f),
                            verticalArrangement = Arrangement.Center, // 중앙 정렬
                            horizontalAlignment = Alignment.CenterHorizontally,
                        ) {

                            // 현재 시간을 실시간으로 보여주는 변수
                            var currentTime by remember { mutableStateOf("") }
                            // 오늘 날짜를 실시간으로 보여주는 변수
                            var currentDate by remember { mutableStateOf("") }

                            // 시간 업데이트를 위한 LaunchedEffect
                            LaunchedEffect(Unit) {
                                while (true) {
                                    // 현재 시간 업데이트
                                    currentTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
                                    // 오늘 날짜 업데이트
                                    currentDate = SimpleDateFormat("yyyy년 MM월 dd일", Locale.getDefault()).format(Date())
                                    kotlinx.coroutines.delay(1000) // 1초마다 업데이트
                                }
                            }

                            // 현재 날짜 표시
                            Text(
                                text = currentDate, // 현재 날짜 텍스트
                                style = MaterialTheme.typography.bodyLarge,
                                modifier = Modifier.padding(vertical = 8.dp) // 위아래 패딩 추가
                            )

                            // 현재 시간 표시 (크고 중앙에 배치)
                            Text(
                                text = currentTime, // 현재 시간 텍스트
                                style = MaterialTheme.typography.headlineLarge.copy(
                                    fontSize = MaterialTheme.typography.headlineLarge.fontSize * 2 // 텍스트 크기 조정
                                ),
                                modifier = Modifier.padding(vertical = 16.dp) // 위아래 패딩 추가
                            )
                        }

                        SwipeUnlockButton(
                            modifier = Modifier.fillMaxWidth(),
                            text = "밀어서 잠금 해제", // 버튼 텍스트
                            isComplete = isUnlocked, // 잠금 해제 여부
                            onSwipe = {
                                isUnlocked = true // 스와이프 완료 시 잠금 해제 상태로 변경
                                finish() // 화면 종료
                            }
                        )
                    }
                }
            }
        }
    }
}

🔽 밀어서 잠금해제 버튼 코드

더보기
@Composable
fun SwipeUnlockButton(
    modifier: Modifier = Modifier,
    text: String,
    isComplete: Boolean,
    doneImageVector: ImageVector = Icons.Rounded.Done,
    onSwipe: () -> Unit
) {
    val buttonWidthPx = with(LocalDensity.current) { 64.dp.toPx() } // 스와이프 아이콘 너비
    var viewWidthPx by remember { mutableFloatStateOf(0f) } // 스와이프 뷰의 실제 너비
    val offsetX = remember { mutableFloatStateOf(0f) }
    val swipeComplete = remember { mutableStateOf(false) }

    val draggableState = rememberDraggableState { delta ->
        if (!swipeComplete.value) {
            offsetX.floatValue = (offsetX.floatValue + delta).coerceIn(0f, viewWidthPx - buttonWidthPx)
        }
    }

    // 텍스트 애니메이션 알파 값
    val alpha by animateFloatAsState(
        targetValue = if (swipeComplete.value) 0f else 1f,
        animationSpec = tween(300, easing = LinearEasing)
    )

    // 스와이프 뷰 너비에 따른 임계값 설정
    LaunchedEffect(offsetX.floatValue, viewWidthPx) {
        val swipeThreshold = viewWidthPx - buttonWidthPx
        if (offsetX.floatValue >= swipeThreshold && !swipeComplete.value) {
            swipeComplete.value = true
            onSwipe()
        }
    }

    Box(
        contentAlignment = Alignment.Center,
        modifier = modifier
            .fillMaxWidth()
            .height(64.dp)
            .onGloballyPositioned { coordinates ->
                viewWidthPx = coordinates.size.width.toFloat() // 스와이프 뷰 너비 가져오기
            }
            .clip(CircleShape)
            .background(
                brush = Brush.horizontalGradient(
                    colors = listOf(Color(0xFFD3D3D3), Color(0xFFB0B0B0)) // 회색 그라데이션
                )
            )
            .animateContentSize()
            .then(if (swipeComplete.value) Modifier.width(64.dp) else Modifier.fillMaxWidth())
    ) {
        SwipeIndicator(
            modifier = Modifier
                .align(Alignment.CenterStart)
                .alpha(alpha)
                .offset { IntOffset(offsetX.floatValue.roundToInt(), 0) }
                .draggable(
                    state = draggableState,
                    orientation = Orientation.Horizontal,
                    onDragStopped = {
                        if (offsetX.floatValue < viewWidthPx - buttonWidthPx) {
                            offsetX.floatValue = 0f // 완료되지 않으면 원위치로 돌아감
                        }
                    }
                )
        )
        Text(
            text = text,
            color = Color.Black, // 텍스트 색상 변경
            textAlign = TextAlign.Center,
            modifier = Modifier
                .fillMaxWidth()
                .alpha(alpha)
                .padding(horizontal = 80.dp)
                .offset { IntOffset(offsetX.floatValue.roundToInt(), 0) }
        )
        AnimatedVisibility(
            visible = swipeComplete.value && !isComplete,
            enter = fadeIn(),
            exit = fadeOut()
        ) {
            CircularProgressIndicator(
                color = Color.Black, // 색상 변경
                strokeWidth = 1.dp,
                modifier = Modifier
                    .fillMaxSize()
                    .padding(4.dp)
            )
        }
        AnimatedVisibility(
            visible = isComplete,
            enter = fadeIn(),
            exit = fadeOut()
        ) {
            Icon(
                imageVector = doneImageVector,
                contentDescription = null,
                tint = Color.Black, // 색상 변경
                modifier = Modifier
                    .align(Alignment.Center)
                    .size(44.dp)
            )
        }
    }
}

@Composable
private fun SwipeIndicator(
    modifier: Modifier = Modifier,
) {
    Box(
        contentAlignment = Alignment.Center,
        modifier = modifier
            .fillMaxHeight()
            .padding(2.dp)
            .clip(CircleShape)
            .aspectRatio(1f)
            .background(Color.White) // 원형 표시기 배경색
    ) {
        Icon(
            imageVector = Icons.Rounded.Check,
            contentDescription = null,
            tint = Color.Gray, // 색상 변경
            modifier = Modifier.size(36.dp)
        )
    }
}

외 코드들은 맨 아래 깃허브에서 참고


ForegroundService 적용

AndroidManifest.xml 파일에 foregroundServiceType과 권한 추가

  • android.permission.FOREGROUND_SERVICE:
    • ForegroundService를 시작할 수 있는 권한이다.
    • Android 9, SDK 28 이상에서는 기본 제공된다. (선언 필요 없음)
  • android.permission.FOREGROUND_SERVICE_DATA_SYNC (필요에 따라 타입 적용)
    • 데이터 동기화 유형의 ForegroundService를 실행하는 권한이다.
    • 필요에 따라 foregroundServiceType을 적용하고 권한도 동일한 타입으로 적용한다.
    • Android 14, SDK 34 이상에서는 필수로 선언해야한다.

자주 사용되는 foregroundServiceType 옵션:

  • mediaProjection: 화면 녹화, 미디어 투영
  • location: 위치 기반 서비스
  • camera: 카메라 사용
  • microphone: 마이크 사용
  • dataSync: 데이터 동기화
  • connectedDevice: 연결된 기기 관리
 

Foreground service types  |  Background work  |  Android Developers

Foreground service types Stay organized with collections Save and categorize content based on your preferences. Beginning with Android 14 (API level 34), you must declare an appropriate service type for each foreground service. That means you must declar

developer.android.com

 

<manifest ...>
	...

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

    <application ...>
		...

        <service
            android:name=".service.LockForegroundService"
            android:exported="false"
            android:foregroundServiceType="dataSync" />
            
    </application>

</manifest>

 

 

ForegroundService 실행 시 버전 분기 처리 및 foregroundServiceType 전달

  • Android 14, SDK 34 이상일 경우 foregroundServiceType 적용
  • Android 13 이하일 경우 foregroundServiceType 적용 X
class LockForegroundService : Service() {
	...

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        createNotificationChannel() // 알림 채널 생성

        val notification = createNotification() // 알림 생성
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
            startForeground(SERVICE_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) // 포그라운드 서비스 시작 (Android 14 이상)
        } else { // Android 13 이하
            startForeground(SERVICE_ID, notification) // 포그라운드 서비스 시작
        }
        registerLockReceiver() // LockReceiver 등록

        return START_STICKY // 서비스 재시작 정책 설정
    }
 

💬  포그라운드 서비스 유형은 어떤 기준으로 적용하는가

나의 경우 잠금화면에 API 호출 기능이 적용돼야해서 포그라운드 서비스 유형을 dataSync로 적용했지만, 포그라운드 서비스 사용으로 어떠한 기능을 구현하는가에 따라서 포그라운드 서비스 유형을 적용하면 된다.

구현 방법에 대해서는 이 포스트에 기재해뒀으니 포그라운드 서비스 유형에 대한 정보는 공식 사이트 참고를 권고한다.


targetSDK 34를 변경한 후 이슈가 있다거나, 포그라운드 서비스 사용을 위해 필요한 최소한의 코드들은 이 포스트를 참고하면 충분히 구현할 수 있다고 생각한다.

프로젝트의 구조와 전체 코드가 필요한 위해 참고할 수 있도록 깃허브에 올려뒀다.

 

GitHub - blueland99/ComposeLockScreen: Lock Screen App - Android XML

Lock Screen App - Android XML. Contribute to blueland99/ComposeLockScreen development by creating an account on GitHub.

github.com