IT/kotlin
퀴즈앱 만들기 리뷰
가능성1g
2025. 5. 22. 18:54
반응형
기기 부팅시 바로 실행을 하기위해서는 브로드 캐스트를 이용해서 개발
1. 권한을 요청해야한다.
<!-- 부팅완료시 브로드캐스트 수신을 위한 권한 요청 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
2. BroadcastReceiver 를 상속받아서, OnReceive 에 구현한다. 어떤 이벤트인지 구분 필요
현재 안드로이드 버전은 백그라운드 실행에 대해서는 제한이 있다 -> 포그라운드로 실행
또한, 부팅후 무거운 실행은 금지 그래서 워커를 이용한다. -> 이것도 안된다..;;
package kr.samdogs.quizlocker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.preference.PreferenceManager
import android.util.Log
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
class BootCompleteReceiver: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when {
intent?.action == Intent.ACTION_BOOT_COMPLETED -> {
Log.d("quizlocker", "부팅이 완료됨")
//Toast.makeText(context, "퀴즈잠금화면: 부팅이 완료됨", Toast.LENGTH_LONG).show()
context?.let {
// 잠금화면 On 값인지 확인
val pref = PreferenceManager.getDefaultSharedPreferences(context)
val useLockScreen = pref.getBoolean("useLockScreen", false)
if(useLockScreen) {
//it.startForegroundService(Intent(context, LockScreenService::class.java))
// 부팅시, shortService는 시작되지 않아서 worker 이용
val workRequest = OneTimeWorkRequestBuilder<LockScreenStarterWorker>()
.setInitialDelay(5, TimeUnit.SECONDS) // 시스템 안정화 대기
.build()
WorkManager.getInstance(it).enqueue(workRequest)
}
}
}
}
}
}
화면 꺼짐을 인지해서 켜지면 락화면에 프로그램 실행을 해보기
별도에 권한 요청은 없어도 되고, 리시버에 화면꺼짐을 등록해서 서비스가 실행되게 한다!
백그라운드 제약으로 포그라운드 실행 + 알림을 구현했다.
package kr.samdogs.quizlocker
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.os.IBinder
class LockScreenService : Service() {
//화면 꺼짐 리시버
var receiver: ScreenOffReceiver? = null
private val ANDROID_CHANNEL_ID = "kr.samdogs.quizlocker"
private val NOTIFICATION_ID = 9999
//서비스 최초 생성 컬백 함수
override fun onCreate() {
super.onCreate()
if(receiver == null ) {
receiver = ScreenOffReceiver()
val filter = IntentFilter(Intent.ACTION_SCREEN_OFF)
registerReceiver(receiver, filter)
}
}
//서비스를 호출하는 클라이언트가 startService 함수 호출할때마다 불리는 컬백함수
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
if( intent != null ) {
if(intent.action == null ) {
// 서비스가 최초실행이 아닌경우, 리시버 재등록
if( receiver == null ) {
receiver = ScreenOffReceiver()
val filter = IntentFilter(Intent.ACTION_SCREEN_OFF)
registerReceiver(receiver, filter)
}
}
}
// 안드로이드 오레오(8) 이후로는 백그라운드 제약으로 포그라운드 서비스로 실행
// 상단 알림 채널 생성
val chan = NotificationChannel(ANDROID_CHANNEL_ID, "MyService", NotificationManager.IMPORTANCE_NONE)
chan.lightColor = Color.BLUE
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
//Notification 채널을 가져온다
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(chan)
val builder = Notification.Builder(this, ANDROID_CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText("SmartTracker Running")
val notification = builder.build()
//알림과 함꼐 포크라운드 서비스로 실행
//(8) 부터 포어그라운드 서비스는 알림을 해야 동작 가능
//(14) 부터 AndroidManifest.xml 에 포그라운드 타입도 추가 해야함
startForeground(NOTIFICATION_ID, notification)
return Service.START_REDELIVER_INTENT
}
override fun onDestroy() {
//서비스 종료시 브로드캐스트 리시버 해제
if (receiver != null ) {
unregisterReceiver(receiver)
}
}
override fun onBind(intent: Intent): IBinder {
TODO("Return the communication channel to the service.")
}
}
2. preference 형 화면을 만들때는 xml에 PreferenceScreen 을 이용해서 만들면 저장과 UI를 한꺼번에 해결할 수 있다.
xml 을 아래와 같이 정의하면 화면이 만들어지고,
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<CheckBoxPreference
android:defaultValue="false"
android:key="isSync"
android:summary="클라우드 데이터와 저장소를 동기화 합니다."
android:title="데이터 동기화 사용" />
<EditTextPreference
android:defaultValue="홍길동"
android:key="name"
android:selectAllOnFocus="true"
android:singleLine="true"
android:summary="사용자의 이름을 설정하세요"
android:title="이름" />
<SwitchPreference
android:defaultValue="false"
android:key="isPush"
android:summary="여러가지 유용한 알림을 받아보세요"
android:title="푸쉬알림" />
</PreferenceScreen>
화면에서는 fragment 로 import 에서 화면을 그대로 쓴다.
SharedPrefrence 를 쓸때는 별도 권한 필요없음!
package kr.samdogs.quizlocker
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.preference.MultiSelectListPreference
import android.preference.PreferenceFragment
import android.preference.SwitchPreference
import android.util.Log
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import kr.samdogs.quizlocker.databinding.ActivityFileExBinding
import kr.samdogs.quizlocker.databinding.ActivityMainBinding
import java.io.File
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
val fragment = MyPreferenceFragment()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.main)
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
fragmentManager.beginTransaction().replace(R.id.preferenceContent, fragment).commit()
// 정답/오답횟수 클리어
binding.initButton.setOnClickListener{
initAnswerCount()
}
}
class MyPreferenceFragment: PreferenceFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//환경설정 리소스파일 설정
addPreferencesFromResource(R.xml.pref)
val categoryPref = findPreference("category") as MultiSelectListPreference
categoryPref.summary = categoryPref.values.joinToString("","")
// 환경설정값이 변경될때, 요약정보를 변경하도록 리스너 등록
categoryPref.setOnPreferenceChangeListener{ preference, newValue ->
val newValueSet = newValue as? HashSet<*> ?: return@setOnPreferenceChangeListener true
categoryPref.summary = newValue.joinToString("","")
true
}
//퀴즈 잠금화면 사용 스위치 객체 가져옴
val useLockScreenPref = findPreference("useLockScreen") as SwitchPreference
//클릭시 이벤트 리스너 코드
useLockScreenPref.setOnPreferenceClickListener {
when {
useLockScreenPref.isChecked -> {
activity.startForegroundService(Intent(activity, LockScreenService::class.java))
}
else -> activity.stopService(Intent(activity, LockScreenService::class.java))
}
true
}
// 앱시작시 퀴즈잠금화면 사용이 체크되었으면 실행
if(useLockScreenPref.isChecked) {
activity.startForegroundService(Intent(activity, LockScreenService::class.java))
}
}
}
fun initAnswerCount() {
val correctAnswerPref = getSharedPreferences("correctAnswer", Context.MODE_PRIVATE)
val wrongAnswerPref = getSharedPreferences("wrongAnswer", Context.MODE_PRIVATE)
//초기화
correctAnswerPref.edit().clear().apply()
wrongAnswerPref.edit().clear().apply()
}
}
3. 잠금화면에서도 보이게 하기위해서는 간단한 함수 호출로 가능
일반데이터는 assets 라는 파일을 만들고 json 형태로 저장해서 아래와 같이 필요한 값을 불러오면 된다.
추가로, 아래는 진동기능을 써서, 진동 관련 권한 설정도 추가 했다.
package kr.samdogs.quizlocker
import android.app.KeyguardManager
import android.content.Context
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.view.WindowManager
import android.widget.SeekBar
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import kr.samdogs.quizlocker.databinding.ActivityQuizLockerBinding
import org.json.JSONArray
import org.json.JSONObject
import java.util.Random
class QuizLockerActivity : AppCompatActivity() {
private lateinit var binding: ActivityQuizLockerBinding
var quiz:JSONObject? = null
// 정답/오답 횟수 SharedPreferences 에 저장하여 관리함
val wrongAnswerPref by lazy { getSharedPreferences("wrongAnswer", Context.MODE_PRIVATE)}
val correctAnswerPref by lazy { getSharedPreferences("correctAnswer", Context.MODE_PRIVATE) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 잠금화면에서 보여지도록 설정
setShowWhenLocked(true)
// 잠금해제
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
keyguardManager.requestDismissKeyguard(this, null)
// 화면 켜진 상태로 유지
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
enableEdgeToEdge()
binding = ActivityQuizLockerBinding.inflate(layoutInflater)
setContentView(binding.main)
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
// 퀴즈 데이터 가져오기
val json = assets.open("capital.json").reader().readText()
val quizArray = JSONArray(json)
// 퀴즈 선택
quiz = quizArray.getJSONObject(Random().nextInt(quizArray.length()))
// 퀴즈를 보여준다.
binding.quizLabel.text = quiz?.getString("question")
binding.choice1.text = quiz?.getString("choice1")
binding.choice2.text = quiz?.getString("choice2")
// 정답/오답 횟수를 보여준다.
val id = quiz?.getInt("id").toString() ?: ""
binding.correctCountLabel.text = "정답횟수: ${correctAnswerPref.getInt(id, 0)}"
binding.wrongCountLabel.text = "오답횟수: ${wrongAnswerPref.getInt(id, 0)}"
// Seekbar의 값에 변경될떄 불리는 리스터
binding.seekBar.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
when {
// left 로 95% 이상 가면 choice2
progress > 95 -> {
binding.leftImageView.setImageResource(R.drawable.padlock)
binding.rightImageView.setImageResource(R.drawable.unlock)
}
progress < 5 -> {
binding.leftImageView.setImageResource(R.drawable.unlock)
binding.rightImageView.setImageResource(R.drawable.padlock)
}
// 양쪽 끝 아님
else -> {
binding.leftImageView.setImageResource(R.drawable.padlock)
binding.rightImageView.setImageResource(R.drawable.padlock)
}
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
// 터치 끝나면 호출
override fun onStopTrackingTouch(seekBar: SeekBar?) {
val progress = seekBar?.progress ?: 50
when {
progress > 95 -> checkChoice(quiz?.getString("choice2") ?: "")
progress < 5 -> checkChoice(quiz?.getString("choice1") ?: "")
else -> binding.seekBar.progress = 50
}
}
})
}
fun checkChoice(choice: String) {
quiz?.let {
when {
// 정답이면 Activity 종료
choice == it.getString("answer") -> {
// 정답시 정답수 증가
val id = it.getInt("id").toString()
var count = correctAnswerPref.getInt(id, 0)
count++
correctAnswerPref.edit().putInt(id, count).apply()
binding.correctCountLabel.text = "정답횟수: ${count}"
finish()
}
// 아니면 UI 초기화
else -> {
// 오답횟수 증가
val id = it.getInt("id").toString()
var count = wrongAnswerPref.getInt(id, 0)
count++
wrongAnswerPref.edit().putInt(id, count).apply()
binding.wrongCountLabel.text = "오답횟수: ${count}"
binding.leftImageView.setImageResource(R.drawable.padlock)
binding.rightImageView.setImageResource(R.drawable.padlock)
binding.seekBar.progress = 50
//진동알림 추가
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
vibrator.vibrate(VibrationEffect.createOneShot(1000, 100))
}
}
}
}
}
==실행화면==
반응형