Android

카카오톡 잠금화면 구현

그란. 2022. 9. 29. 21:56

"카카오톡 잠금화면처럼 해주세요" 

-> 사실 난 잠금화면 기능을 사용하지 않아 어떤 기능인지 몰랐다.

 

사실 근데 이런것 할때마다 두근두근 거린다.

분명히 내가 못하는거고 미지의 세계를 헤쳐 나가면서 해야하는데 한 단계씩 점점 나아갈때마다 희열을 느낀다.

(우와 진짜 너무 재미있어!) 이런 느낌? 난 체질이 모험가인것같다.


기능 정의 

  • 사용자는 잠금화면을 설정하거나 제거할 수 있다.
  • 잠금화면을 설정하면 앱이 백그라운드에 갔다 x초 후에 돌아오면 잠금화면이 보인다. ( 모든 화면에서 마찬가지 동작 ) 
  • 잠금화면은 Dismiss 할 수 없다.
  • (기획상) 서버통신없이 로컬링으로만 작업하고 아이디별로 설정을 저장한다.

개발 과정 

  1. 먼저 Dismiss 할 수 없는 Dialog 만들기 
  2. 백그라운드에서 4초이상 갔다 온 경우 Dialog 띄우기 
  3. 사용자Id와 password를 저장한 auth 테이블을 만든다. 
    1. User 테이블에 넣을수도 있었는데 유저테이블은 서버에서 가져오면 업데이트하고 패스워드는 성격이 달라 분리 
    2. Auth 테이블에 Flow를 걸어 업데이트 됐을때 감지 
  4. 모든 Activity에서 검사해야 한다. ( 어떻게 해야하지?.. )
    1. 이 프로젝트는 1Activity 가 아니라 다수의 Activity 로 이루어짐 

실제 코드

 

1. Dissmiss 할 수 없는 Dialog 

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
    return super.onCreateDialog(savedInstanceState).also {
        it.setOnKeyListener { dialog, keyCode, event ->
            keyCode == KeyEvent.KEYCODE_BACK
        }
    }
}

비밀번호가 맞은 경우에만 Dismiss 할 수 있도록 

Password 를 argument로 받아 처리 

private const val EXTRA_PASSWORD = "EXTRA_PASSWORD"
fun newInstance(pwd: String): AuthDialogFragment {
    return newInstance().apply {
        arguments = bundleOf(
            EXTRA_PASSWORD to pwd
        )
    }
}

 

2. 백그라운드에서 4초이상 갔다 온 경우 Dialog 띄우기 

 

일단 Application의 onResume이 있었으면 좋았겠지만 없기에

액티비티라싸를 콜백을 이용하여 백그라운드에 갔는지 판단할 수 있다.

Application.ActivityLifecycleCallbacks
private const val CONST_BACKGROUND_TIME = 4000L

@JvmStatic
private var _lastActivity: WeakReference<Activity>? = null

@JvmStatic
var currentTime: Long? = null

@JvmStatic
var wasInBackground: Boolean = false

object : Application.ActivityLifecycleCallbacks {
    override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}

    override fun onActivityStarted(activity: Activity) {
        _lastActivity = WeakReference(activity)
        if (currentTime != null 
        	&& ((System.currentTimeMillis() - currentTime!!) > CONST_BACKGROUND_TIME)) {
            wasInBackground = true
        }
        currentTime = null
    }

    override fun onActivityResumed(activity: Activity) {}

    override fun onActivityPaused(activity: Activity) {
        wasInBackground = false
    }

    override fun onActivityStopped(activity: Activity) {
        if (_lastActivity?.get() === activity) {
            currentTime = System.currentTimeMillis()
            _lastActivity = null
        }
    }

    override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}

    override fun onActivityDestroyed(activity: Activity) {    }

}

이걸 Util 로 만들어서 사용하려고 한다 ( Application에 해도 된다, 단지 매번 Application에서 접근해서 가져오는게 불편해서 )

cf) count를 +1,-1 하면서 체크하는 구글에서 제공하는 소스도 있긴 하다

 

이 소스를 이해하기 위해선 액티비티 생명주기를 잘 알아야한다. 

단순히 Created -> Started -> Resumed 가 아니라

다른 액티비티로 이동했을때 어떻게 되는지를 알아야한다. 

 

aActivity -> bActivity 로 이동할때는 

b가 먼저 Started 하고 a가 Stopped 되기 때문에 (_lastActivity.get() === activity)을 타지 않는다 

( b가 _lastActivity에 할당 되고 a가 Stopped 되어 _lastActivity !== aActivity 가 된다. 

 

반면에 bActivity 에서 백그라운드로 이동하면 

현재 _lastActivity === bActivity 이므로 Stopped 안의 if문 에서 background로 이동한걸 알 수 있다.

 

요기에 추가로 4초 조건을 걸고 싶기에 시간 계산까지 해서 wasInBackground를 true 로 만든다.

 

 

3. 사용자Id와 password를 저장한 auth 테이블을 만든다. 

@Singleton
class FetchAuthUseCase @Inject constructor(
    private val repository: AuthRepository,
    private val sessionRepository: SessionRepository
) {

    @OptIn(ExperimentalCoroutinesApi::class)
    operator fun invoke(): Flow<String?> {
        return sessionRepository.fetchUserId()
            .filterNotNull()
            .flatMapLatest {
                repository.fetchPassword(it)
            }.map {
                it.password
            }

    }
}
@Query("SELECT * FROM auth WHERE auth.userId = :id")
fun fetch(id: Long): Flow<AuthEntity>
@Entity(tableName = "auth")
internal data class AuthEntity(

    @PrimaryKey
    @ColumnInfo(name = "userId") val userId: Long,

    @ColumnInfo(name = "password") val password: String?

)

유저 아이디가 없을땐 점검하지 않아야하니까 ( 로그인화면에서는 X ) 

 

잠금화면을 설정한 경우 -> password = "1234"

해제 한 경우 ->  passsword = null

 

 

4. 모든 Activity에서 검사

Activity들의 onResume 에서 1번에서 만든 LastActivityUtil의 wasInBackground 변수를 체크 

( onStarted 에서 wasInBackground를 변경했고 그 다음 단계인 onResumed에서 검사 ) 

 

모든 액티비티들이 abstract class BaseActivity를 상속하고 있음

BaseActivity에서 FetchAuthUseCase를 @Inject 하고 싶지만

abstract class 에서는 @AndroidEntryPoint를 지정 불가

 

그런데 방법이 있다!!

EntryPoint를 지정해서 넣을 수 있다.

 

@EntryPoint
@InstallIn(SingletonComponent::class)
interface AuthEntryPoint {
    fun fetchAuth(): FetchAuthUseCase
}

EntryPoint 를 만들고 

 

BaseActivity에서 

private val authEntryPoint by lazy {
    EntryPoints.get(
        this.applicationContext,
        AuthEntryPoint::class.java
    )
}

private val auth by lazy {
    authEntryPoint.fetchAuth()
}

UseCase를  Inejct 가능 ( 어떤곳이든 EntryPoint를 지정해서 넣을 수 있구나..)

 

abstract class BaseActivity{

    init{
    	  auth.invoke()
            .onEach {
                authPasssword = if (!it.isNullOrEmpty()) {
                    it
                } else {
                    null
                }
            }.launchIn(lifecycleScope)
  	}
    
     override fun onResume() {
        super.onResume()
        if (authPasssword != null && LastActivityUtils.wasInBackground) {
            AuthDialogFragment.newInstance(authPasssword!!)
                .show(supportFragmentManager, AuthDialogFragment.name)
        }
    }
    
    companion object {
        var authPasssword: String? = null
    }
}

(사실 모든 액티비티마다 authPassword를 만드는 건데. 하나의 전역변수로써 컨트롤 하는게 나을것 같다.)


추가 Tip

이런 UI 를 만들때 

 

저 암호가 입력된은 부분을 TextView 로 만들어서 처리했다 ( 그래야 로직이 편해져서 .. )

<TextView
    android:id="@+id/tv_pwd"
    android:hint="1234"
    android:inputType="numberPassword"
    android:letterSpacing="1.2"
    android:maxLength="4"
    android:textColorHint="@color/white"
/>

그리고 textColorHint를 배경과 같게 해서 안보이게 해둔다. 

(그 밑의 하단 View 들은 각각 4개 생성해서 하단에 margin을 조절하여 맞춘다.) 

 

그러면 로직은 아래와 같이 단순?해진다.

btCancel.setOnClickListener { dismissSafely() }
btDelete.setOnClickListener { tvPwd.text = tvPwd.text.dropLast(1) }
bt0.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("0") }
bt1.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("1") }
bt2.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("2") }
bt3.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("3") }
bt4.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("4") }
bt5.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("5") }
bt6.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("6") }
bt7.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("7") }
bt8.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("8") }
bt9.setOnClickListener { tvPwd.text = tvPwd.text.toString().plus("9") }