- Kotlin standard library
- Glide
- Dagger, Hilt
- LoremIpsum
- Leakcanary
- Kotlin Coroutine
- Retrofit
- FlowBinding
- DataBinding, ViewBinding
- Core
- Activity
- Fragment
- ConstraintLayout
- SwipeRefreshLayout
- Material design components
- Security-crypto
- DataStore
- Biometric
- Lifecycle
- Navigation
- Hilt
- Sign-up
- Sign-in
- Auto sign-in
- Form validation
- Switch dark theme
- Show items in list
- Drag items in list
- Swipe items in list
- Delete items in list
- ViewPager2 (setPageTransform)
- Retrofit with Coroutine
- FlowBinding
- SimpleDateFormat
- OkHttpClient Interceptor
- Kotlin Gradle script(custom task)
tasks.register("lintAppModule") {
dependsOn(":app:lint")
doLast {
println("Lint check success ✅")
}
}
- Github action (CI android debug build)
name: Android Build
on: [push]
defaults:
run:
shell: bash
working-directory: .
jobs:
build:
runs-on: ubuntu-latest
name: build debug
if: "!contains(toJSON(github.event.commits.*.message), '[skip action]') && !startsWith(github.ref, 'refs/tags/')"
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Gradle cache
uses: actions/cache@v2
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Lint
run: ./gradlew lintAppModule
- name: Build debug
run: ./gradlew assembleDebug
- name: Archive artifacts
uses: actions/upload-artifact@v2
with:
path: app/build/outputs
- AAC Lifecycle(LiveData, ViewModel)
SignInViewModel.kt
typealias AutoSignIn = Boolean
class SignInViewModel @ViewModelInject constructor(
@Named(AUTHENTICATOR_TYPE) private val authenticator: Authenticator, /*@Assisted private val savedStateHandle: SavedStateHandle*/
) : ViewModel() {
// StateFlow data binding support is coming in AGP 4.3
// https://twitter.com/manuelvicnt/status/1314621067831521282
val id = MutableLiveData("")
val pw = MutableLiveData("")
private var isAutoSignInTried = false
val onSignInSuccess = EventLiveData<AutoSignIn>()
val onSignInFail = EventLiveData<AutoSignIn>()
suspend fun canAutoSignIn(): Boolean {
if (isAutoSignInTried) return false
isAutoSignInTried = true
return authenticator.canAutoSignIn()
}
fun tryManualSignIn() = viewModelScope.launch {
if (matchWithLastSignInInfo()) {
onSignInSuccess.emit(false)
} else {
onSignInFail.emit(false)
}
}
private suspend fun matchWithLastSignInInfo() = authenticator.signInWithId(id.value!!, pw.value!!)
}
- AAC DataBinding, ViewBinding
- Kotlin Coroutine(Flow, StateFlow, SharedFlow, ...)
lifecycleScope.launch {
settingManager.updateLastSignInInfo(id.value!!, pw.value!!)
findNavController().popBackStack()
}
- AAC Navigation
findNavController().navigate(
SignInFragmentDirections.actionSignInFragmentToSignUpFragment(
viewModel.id.value!!, viewModel.pw.value!!
)
)
- Dagger, Hilt
AppModule.kt
@InstallIn(ApplicationComponent::class)
@Module
object AppModule {
@Provides
@Singleton
fun provideContext(app: Application): Context = app
@Provides
@Singleton
fun provideDisplayMetrics(context: Context): DisplayMetrics = context.resources.displayMetrics
@Provides
@Singleton
fun providePixelRatio(displayMetrics: DisplayMetrics) = PixelRatio(displayMetrics)
}
AuthenticatorModule.kt
@InstallIn(ApplicationComponent::class)
@Module
abstract class AuthenticatorModule {
@Binds
@Singleton
@Named("SharedPreferences")
abstract fun bindSharedPreferencesAuthenticator(authenticator: SharedPreferencesAuthenticator): Authenticator
@Binds
@Singleton
@Named("EncryptedSharedPreferences")
abstract fun bindEncryptedSharedPreferencesAuthenticator(authenticator: EncryptedSharedPreferencesAuthenticator): Authenticator
@Binds
@Singleton
@Named("DataStorePreferences")
abstract fun bindDataStorePreferencesAuthenticator(authenticator: DataStorePreferencesAuthenticator): Authenticator
@Binds
@Singleton
@Named("EncryptedFileAuthenticator")
abstract fun bindEncryptedFileAuthenticator(authenticator: EncryptedFileAuthenticator): Authenticator
companion object {
const val AUTHENTICATOR_TYPE = "EncryptedSharedPreferences"
}
}
Authenticator.kt
interface Authenticator {
suspend fun canAutoSignIn(): Boolean
suspend fun signUpWithId(id: String, password: String)
suspend fun signInWithId(id: String, password: String): Boolean
suspend fun signOut()
}
SharedPreferencesAuthenticator.kt
class SharedPreferencesAuthenticator @Inject constructor(
context: Context, private val validator: IdValidator
) : Authenticator {
private var sharedPreferences = context.getSharedPreferences("sharedPreferences", 0)
fun replaceSharedPreferences(sharedPreferences: SharedPreferences) {
logE(sharedPreferences)
this.sharedPreferences = sharedPreferences
}
override suspend fun canAutoSignIn() = sharedPreferences.getBoolean(AUTO_SIGNIN_KEY, false)
override suspend fun signUpWithId(id: String, password: String) = sharedPreferences.edit(true) {
putString(ID_KEY, id)
putString(PW_KEY, password)
}
override suspend fun signInWithId(id: String, password: String) = validator.validateIdAndPwWithOthers(
id, password, sharedPreferences.getString(ID_KEY, ""), sharedPreferences.getString(PW_KEY, "")
).also {
if (it) {
sharedPreferences.edit(true) {
putBoolean(AUTO_SIGNIN_KEY, true)
}
}
}
override suspend fun signOut() {
sharedPreferences.edit(true) {
remove(AUTO_SIGNIN_KEY)
}
}
companion object {
private const val ID_KEY = "ID"
private const val PW_KEY = "PW"
private const val AUTO_SIGNIN_KEY = "AUTO_SIGNIN"
}
}
- DataStore
DataStorePreferencesAuthenticator.kt
class DataStorePreferencesAuthenticator @Inject constructor(context: Context, private val validator: IdValidator) :
Authenticator {
private val dataStore = context.createDataStore("DataStorePreferencesAuthenticator")
override suspend fun canAutoSignIn() = runCatching {
val pref = dataStore.data.first()
pref[AUTO_SIGNIN_KEY] == true
}.getOrDefault(false)
override suspend fun signUpWithId(id: String, password: String) {
logE("$id $password")
dataStore.edit { pref ->
pref[ID_KEY] = id
pref[PW_KEY] = password
}
}
override suspend fun signInWithId(id: String, password: String): Boolean {
return runCatching {
val pref = dataStore.data.first()
validator.validateIdAndPwWithOthers(id, password, pref[ID_KEY], pref[PW_KEY]).also {
if (it) {
dataStore.edit { pref ->
pref[AUTO_SIGNIN_KEY] = true
}
}
}
}.getOrDefault(false)
}
override suspend fun signOut() {
dataStore.edit { pref ->
pref.remove(AUTO_SIGNIN_KEY)
}
}
companion object {
private val ID_KEY = preferencesKey<String>("id")
private val PW_KEY = preferencesKey<String>("pw")
private val AUTO_SIGNIN_KEY = preferencesKey<Boolean>("autoSignIn")
}
}
- EncryptedSharedPreferences
EncryptedSharedPreferencesAuthenticator.kt
class EncryptedSharedPreferencesAuthenticator
@Inject constructor(context: Context, @Named("SharedPreferences") authenticator: Authenticator) :
Authenticator by ((authenticator as? SharedPreferencesAuthenticator
?: throw RuntimeException("Fix your type casting")).apply {
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
val encryptedSharedPreferences =
EncryptedSharedPreferences.create("EncryptedSharedPreferences", masterKey, context, AES256_SIV, AES256_GCM)
replaceSharedPreferences(encryptedSharedPreferences)
})
EncryptedFile
class EncryptedFileAuthenticator @Inject constructor(context: Context, private val validator: IdValidator) :
Authenticator {
private val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
private val file = File(context.getExternalFilesDir(null), "data.txt")
private val encryptedFile = EncryptedFile.Builder(
context, file, masterKey, AES256_GCM_HKDF_4KB
).build()
override suspend fun canAutoSignIn(): Boolean = withContext(Dispatchers.IO) {
file.exists()
}
override suspend fun signUpWithId(id: String, password: String) {
encryptedFile.openFileOutput().use {
it.write("$id\n$password\n".toByteArray())
}
}
override suspend fun signInWithId(id: String, password: String): Boolean = withContext(Dispatchers.IO) {
createFileIfNotExist()
val content = encryptedFile.openFileInput().bufferedReader().useLines {
it.fold("") { acc, line -> acc + "$line\n" }
}
val lastId = content.split("\n").firstOrNull()
val lastPw = content.split("\n")[1]
validator.validateIdAndPwWithOthers(id, password, lastId, lastPw)
}
private fun createFileIfNotExist() {
if (!file.exists()) file.createNewFile()
}
override suspend fun signOut() {
deleteFile()
}
private suspend fun deleteFile() = withContext(Dispatchers.IO) {
file.delete()
}
}
- AndroidX Biometric
BioAuth.kt
@Singleton
class BioAuth @Inject constructor(private val context: Context) {
private val promptInfo = BiometricPrompt.PromptInfo.Builder().apply {
this.setTitle("Title")
this.setDescription("Description")
setNegativeButtonText("Cancel")
setAllowedAuthenticators(AUTHENTICATORS)
}.build()
val biometricEnabled: Boolean
get() = BiometricManager.from(context).canAuthenticate(AUTHENTICATORS) == BIOMETRIC_SUCCESS
suspend fun authenticate(fragment: Fragment) = suspendCancellableCoroutine<Boolean> { continuation ->
BiometricPrompt(fragment, ContextCompat.getMainExecutor(context), object : AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
continuation.resume(false)
}
override fun onAuthenticationSucceeded(result: AuthenticationResult) {
continuation.resume(true)
}
override fun onAuthenticationFailed() {
continuation.resume(false)
}
}).authenticate(promptInfo)
}
companion object {
private const val AUTHENTICATORS = BIOMETRIC_WEAK
}
}
- Kotlin gradle script
- Kotlin stdlib
- ConstraintLayout
- MDC
- RecyclerView
- ItemTouchHelper
- SwipeMenuTouchListener
SwipeMenuTouchListener.kt
class SwipeMenuTouchListener(
private val menuWidth: Float, private val callback: Callback
) : OnTouchListener {
private var dx = 0f
override fun onTouch(view: View, e: MotionEvent): Boolean {
when (e.actionMasked) {
MotionEvent.ACTION_MOVE -> {
callback.onContentXChanged(e.rawX + dx)
}
MotionEvent.ACTION_DOWN -> {
dx = view.x - e.rawX
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
if (e.rawX + dx < -menuWidth) {
callback.onContentXAnimated(-menuWidth)
callback.onMenuOpened()
} else {
callback.onContentXAnimated(0f)
callback.onMenuClosed()
}
}
}
return false
}
interface Callback {
fun onContentXChanged(x: Float)
fun onContentXAnimated(x: Float)
fun onMenuOpened()
fun onMenuClosed()
}
}
- OnDebounceClickListener
OnDebounceClickListener.kt
class OnDebounceClickListener(private val listener: OnClickListener) : View.OnClickListener {
override fun onClick(v: View?) {
val now = System.currentTimeMillis()
if (now < lastTime + INTERVAL) return
lastTime = now
v?.run(listener)
}
companion object {
private const val INTERVAL: Long = 300L
private var lastTime: Long = 0
}
}
infix fun View.onDebounceClick(listener: OnClickListener) {
this.setOnClickListener(OnDebounceClickListener {
it.run(listener)
})
}
- EventLiveData
EventLiveData.kt
class EventLiveData<T> : MutableLiveData<T>() {
private val pending = AtomicBoolean(false)
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
super.observe(owner) {
if (pending.compareAndSet(true, false)) {
observer.onChanged(it)
}
}
}
@MainThread
fun emit(value: T) {
pending.set(true)
setValue(value)
}
}
- PixelRatio
PixelRatio.kt
@Singleton
class PixelRatio @Inject constructor(private val displayMetrics: DisplayMetrics) {
val screenWidth: Int
get() = displayMetrics.widthPixels
val screenHeight: Int
get() = displayMetrics.heightPixels
@Px
fun toPixel(dp: Int) = (dp * displayMetrics.density).roundToInt()
fun toDP(@Px pixel: Int) = (pixel / displayMetrics.density).roundToInt()
}
We can test PixelRatio
with mocking Android instance(DisplayMetrics
) with Mockito.
class PixelRatioTest {
private lateinit var pixelRatio: PixelRatio
private val mockWidth = 1000
private val mockHeight = 2000
@Before
fun setup() {
val mockDisplayMetrics = mock(DisplayMetrics::class.java).apply {
widthPixels = mockWidth
heightPixels = mockHeight
density = 3f
}
pixelRatio = PixelRatio(mockDisplayMetrics)
}
@Test
fun `screenWidth, screenHeight should be same with real screen size`() {
Truth.assertThat(pixelRatio.screenWidth).isEqualTo(mockWidth)
Truth.assertThat(pixelRatio.screenHeight).isEqualTo(mockHeight)
}
@Test
fun `toDP, toPixel`() {
Truth.assertThat(pixelRatio.toDP(3)).isEqualTo(1)
Truth.assertThat(pixelRatio.toPixel(1)).isEqualTo(3)
}
}
- ViewModel
- Suspend function
- OnBackPressedCallback
MainFragment.kt
private val backPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
isEnabled = false
if (isCardShowing) {
hideCard()
} else {
onBackPressed()
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback)
...
}
override fun onDestroyView() {
super.onDestroyView()
backPressedCallback.remove()
}
- MaterialContainerTransform
- LeakCanary and Android Studio memory profiler
- ShapeableImageView, ShapeAppearanceModel
@BindingAdapter("app:useCircleOutlineWithRadius")
fun ShapeableImageView.useCircleOutlineWithRadius(radius: Float) {
shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(radius)
}
- Glide
@BindingAdapter("app:url", requireAll = false)
fun ImageView.loadUrlAsync(url: String?) {
val anim = CircularProgressDrawable(context).apply {
strokeWidth = 4f
setColorSchemeColors(
*listOf(
R.color.colorPrimary, R.color.colorSecondary, R.color.colorError70
).map { context.getColor(it) }.toIntArray()
)
start()
}
if (url == null) {
Glide.with(this).load(anim).into(this)
} else {
Glide.with(this).load(url)
.transition(withCrossFade(DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build()))
.placeholder(anim).into(this)
}
}
- FragmentFactory
MainFragmentFactory.kt
class MainFragmentFactory(activity: Activity) : FragmentFactory() {
@EntryPoint
@InstallIn(ActivityComponent::class)
interface MainFragmentFactoryEntryPoint {
fun pixelRatio(): PixelRatio
fun loremIpsum(): LoremIpsum
@Named(AUTHENTICATOR_TYPE)
fun authenticator(): Authenticator
fun bioAuth(): BioAuth
}
private val entryPoint = EntryPointAccessors.fromActivity(activity, MainFragmentFactoryEntryPoint::class.java)
override fun instantiate(classLoader: ClassLoader, className: String): Fragment {
return when (loadFragmentClass(classLoader, className)) {
MainFragment::class.java -> MainFragment(
entryPoint.pixelRatio(), entryPoint.loremIpsum(), entryPoint.authenticator()
)
SignInFragment::class.java -> SignInFragment(entryPoint.bioAuth())
SignUpFragment::class.java -> SignUpFragment(entryPoint.authenticator())
else -> super.instantiate(classLoader, className)
}
}
companion object {
fun getInstance(activity: Activity): MainFragmentFactory {
return MainFragmentFactory(activity)
}
}
}
- FragmentStateAdapter
FrameAdapter.kt
class FrameAdapter(private val fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int {
return 3
}
override fun createFragment(position: Int): Fragment {
val classLoader = fragment.requireActivity().classLoader
val factory = MainFragmentFactory.getInstance(fragment.requireActivity())
return when (position) {
0 -> factory.instantiate(classLoader, ProfileFragment::class.java.name)
1 -> factory.instantiate(classLoader, MainFragment::class.java.name)
2 -> factory.instantiate(classLoader, SettingsFragment::class.java.name)
else -> throw RuntimeException("What the...")
}
}
}
- ViewPager2
FrameFragment.kt
private fun configurePager() = mBinding.pager.run {
offscreenPageLimit = 3
adapter = FrameAdapter(this@FrameFragment)
registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
viewModel.onPageSelected(position)
}
})
setPageTransformer { page, position ->
page.pivotX = if (position < 0) page.width.toFloat() else 0f
page.pivotY = page.height * 0.5f
page.rotationY = 50f * position
}
}
- Retrofit
RetrofitModule.kt
@Module
@InstallIn(ApplicationComponent::class)
class RetrofitModule {
private val loggingInterceptor = HttpLoggingInterceptor(Logger.DEFAULT).apply {
this.level = HttpLoggingInterceptor.Level.BODY
}
private val baseClient = OkHttpClient.Builder().addInterceptor(loggingInterceptor).build()
@Provides
@Singleton
@Named(REQRES_QUALIFIER)
fun provideReqresRetrofit(nativeLib: NativeLib) =
Retrofit.Builder().baseUrl(nativeLib.reqresBaseUrl).client(baseClient)
.addConverterFactory(GsonConverterFactory.create()).build()
@Provides
@Singleton
@Named(KAKAO_QUALIFIER)
fun provideKakaoRetrofit(nativeLib: NativeLib): Retrofit {
val kakaoNetworkInterceptor = object : Interceptor {
override fun intercept(chain: Chain): Response {
logE(nativeLib.kakaoApiKey)
val req =
chain.request().newBuilder().addHeader("Authorization", "KakaoAK " + nativeLib.kakaoApiKey).build()
return chain.proceed(req)
}
}
val kakaoClient = baseClient.newBuilder().addNetworkInterceptor(kakaoNetworkInterceptor).build()
return Retrofit.Builder().baseUrl(nativeLib.kakaoBaseUrl).client(kakaoClient)
.addConverterFactory(GsonConverterFactory.create()).build()
}
companion object {
const val REQRES_QUALIFIER = "Reqres"
const val KAKAO_QUALIFIER = "Kakao"
}
}
- SimpleDateFormat
KakaoSearchAdapter.kt
object KakaoSearchAdapter : ModelAdapter<KakaoSearchDTO, KakaoSearchEntity> {
private val timeZone = TimeZone.getTimeZone("Asia/Seoul")
private val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.KOREA).apply {
timeZone = timeZone
}
override fun toEntity(source: KakaoSearchDTO): KakaoSearchEntity {
val cal = Calendar.getInstance(timeZone).apply {
time = formatter.parse(source.datetime) ?: Date()
}
return KakaoSearchEntity(source.contents, cal, source.title, source.url)
}
override fun toDTO(source: KakaoSearchEntity): KakaoSearchDTO {
return KakaoSearchDTO(source.contents, formatter.format(source.datetime.time), source.title, source.url)
}
}
- FlowBinding
SearchFragment.kt
private fun configureEditText() = mBinding.editText.run {
textChanges().debounce(1500L).onEach {
if (it.isNotEmpty()) viewModel.search(it.toString())
}.launchIn(lifecycleScope)
}
- assignment #1 2020.10.15
- assignment #2 2020.10.19
- assignment #3 2020.11.20
- assignment #6 2020.11.29
Thanks goes to these wonderful people (emoji key):
MJ Studio 💻 |
This project follows the all-contributors specification. Contributions of any kind welcome!