├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── kts │ │ └── github │ │ ├── app │ │ └── GithubApp.kt │ │ ├── data │ │ ├── auth │ │ │ ├── AppAuth.kt │ │ │ ├── AuthRepository.kt │ │ │ ├── TokenStorage.kt │ │ │ └── models │ │ │ │ └── TokensModel.kt │ │ ├── github │ │ │ ├── GithubApi.kt │ │ │ ├── UserRepository.kt │ │ │ └── models │ │ │ │ └── RemoteGithubUser.kt │ │ └── network │ │ │ ├── AuthorizationFailedInterceptor.kt │ │ │ ├── AuthorizationInterceptor.kt │ │ │ └── Networking.kt │ │ ├── ui │ │ ├── MainActivity.kt │ │ ├── auth │ │ │ ├── AuthFragment.kt │ │ │ └── AuthViewModel.kt │ │ └── user_info │ │ │ ├── UserInfoFragment.kt │ │ │ └── UserInfoViewModel.kt │ │ └── utils │ │ ├── FlowExtensions.kt │ │ ├── FragmentExtensions.kt │ │ └── NavExtensions.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── layout │ ├── activity_main.xml │ ├── fragment_auth.xml │ └── fragment_user_info.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── navigation │ └── nav_graph.xml │ └── values │ ├── colors.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea 4 | /local.properties 5 | /.idea/caches 6 | /.idea/libraries 7 | /.idea/modules.xml 8 | /.idea/workspace.xml 9 | /.idea/navEditor.xml 10 | /.idea/assetWizardSettings.xml 11 | .DS_Store 12 | /build 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | local.properties -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android OAuth с помощью библиотеки AppAuth 2 | 3 | Разработан в качестве материала к статье: https://habr.com/ru/company/kts/blog/654029/ 4 | 5 | Доклад Android Broadcast: https://www.youtube.com/watch?v=PFZ3cwxn9Wk 6 | 7 | Функционал: 8 | - AppAuth 9 | - chrome custom tabs 10 | - логин 11 | - логаут 12 | - обновление токена (пример, не работает на сервисе github) 13 | 14 | **Важно**: это не production-ready приложение, пример разработан исключительно для демонстрации работы с библиотекой AppAuth. Разбиение на слои, архитекрурные сущности проведено условно. Пример необходимо адаптировать к приложению индивидуально. 15 | 16 | Чтобы протестировать приложение: 17 | - [зарегистрируйте](https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app) OAuth-приложение в github 18 | - заполните поля CLIENT_ID, CLIENT_SECRET, CALLBACK_URL внутри [AppAuth](app/src/main/java/com/kts/github/data/auth/AppAuth.kt) в соотвествии с параметрами зарегистрированного приложения 19 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: "androidx.navigation.safeargs.kotlin" 5 | 6 | android { 7 | compileSdkVersion 31 8 | defaultConfig { 9 | applicationId "ru.kts.github" 10 | minSdkVersion 21 11 | targetSdkVersion 31 12 | versionCode 1 13 | versionName "1.0" 14 | 15 | manifestPlaceholders = [ 16 | appAuthRedirectScheme: "ru.kts.oauth" 17 | ] 18 | } 19 | 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | 27 | kotlinOptions { 28 | jvmTarget = '1.8' 29 | } 30 | 31 | buildFeatures { 32 | viewBinding = true 33 | } 34 | } 35 | 36 | dependencies { 37 | implementation 'androidx.core:core-ktx:1.7.0' 38 | implementation 'androidx.appcompat:appcompat:1.4.1' 39 | implementation 'androidx.constraintlayout:constraintlayout:2.1.3' 40 | 41 | implementation 'com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.4.7' 42 | 43 | //Glide 44 | implementation 'com.github.bumptech.glide:glide:4.12.0' 45 | 46 | //RecyclerView 47 | implementation 'androidx.recyclerview:recyclerview:1.2.1' 48 | implementation 'com.hannesdorfmann:adapterdelegates4:4.3.0' 49 | 50 | def lifecycleVersion = '2.4.0' 51 | 52 | //ViewModel 53 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" 54 | implementation "androidx.fragment:fragment-ktx:1.4.1" 55 | //LiveData 56 | implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" 57 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" 58 | //Navigation 59 | implementation "androidx.navigation:navigation-fragment-ktx:2.4.0" 60 | 61 | //Moshi 62 | def moshiVersion = '1.13.0' 63 | implementation "com.squareup.moshi:moshi:$moshiVersion" 64 | implementation "com.squareup.moshi:moshi-kotlin:$moshiVersion" 65 | kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion" 66 | 67 | //Retrofit 68 | def retrofitVersion = '2.9.0' 69 | implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" 70 | implementation "com.squareup.retrofit2:converter-moshi:$retrofitVersion" 71 | implementation "com.squareup.okhttp3:logging-interceptor:4.9.2" 72 | 73 | def coroutinesVersion = "1.5.1" 74 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" 75 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" 76 | 77 | //AppAuth 78 | implementation 'net.openid:appauth:0.9.1' 79 | 80 | implementation 'com.jakewharton.timber:timber:5.0.1' 81 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/app/GithubApp.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.app 2 | 3 | import android.app.Application 4 | import com.kts.github.data.network.Networking 5 | import timber.log.Timber 6 | 7 | class GithubApp : Application() { 8 | override fun onCreate() { 9 | super.onCreate() 10 | Networking.init(this) 11 | Timber.plant(Timber.DebugTree()) 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/auth/AppAuth.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.auth 2 | 3 | import android.net.Uri 4 | import androidx.core.net.toUri 5 | import com.kts.github.data.auth.models.TokensModel 6 | import net.openid.appauth.AuthorizationRequest 7 | import net.openid.appauth.AuthorizationService 8 | import net.openid.appauth.AuthorizationServiceConfiguration 9 | import net.openid.appauth.ClientAuthentication 10 | import net.openid.appauth.ClientSecretPost 11 | import net.openid.appauth.EndSessionRequest 12 | import net.openid.appauth.GrantTypeValues 13 | import net.openid.appauth.ResponseTypeValues 14 | import net.openid.appauth.TokenRequest 15 | import kotlin.coroutines.suspendCoroutine 16 | 17 | object AppAuth { 18 | 19 | private val serviceConfiguration = AuthorizationServiceConfiguration( 20 | Uri.parse(AuthConfig.AUTH_URI), 21 | Uri.parse(AuthConfig.TOKEN_URI), 22 | null, // registration endpoint 23 | Uri.parse(AuthConfig.END_SESSION_URI) 24 | ) 25 | 26 | fun getAuthRequest(): AuthorizationRequest { 27 | val redirectUri = AuthConfig.CALLBACK_URL.toUri() 28 | 29 | return AuthorizationRequest.Builder( 30 | serviceConfiguration, 31 | AuthConfig.CLIENT_ID, 32 | AuthConfig.RESPONSE_TYPE, 33 | redirectUri 34 | ) 35 | .setScope(AuthConfig.SCOPE) 36 | .build() 37 | } 38 | 39 | fun getEndSessionRequest(): EndSessionRequest { 40 | return EndSessionRequest.Builder(serviceConfiguration) 41 | .setPostLogoutRedirectUri(AuthConfig.LOGOUT_CALLBACK_URL.toUri()) 42 | .build() 43 | } 44 | 45 | fun getRefreshTokenRequest(refreshToken: String): TokenRequest { 46 | return TokenRequest.Builder( 47 | serviceConfiguration, 48 | AuthConfig.CLIENT_ID 49 | ) 50 | .setGrantType(GrantTypeValues.REFRESH_TOKEN) 51 | .setScopes(AuthConfig.SCOPE) 52 | .setRefreshToken(refreshToken) 53 | .build() 54 | } 55 | 56 | suspend fun performTokenRequestSuspend( 57 | authService: AuthorizationService, 58 | tokenRequest: TokenRequest, 59 | ): TokensModel { 60 | return suspendCoroutine { continuation -> 61 | authService.performTokenRequest(tokenRequest, getClientAuthentication()) { response, ex -> 62 | when { 63 | response != null -> { 64 | //получение токена произошло успешно 65 | val tokens = TokensModel( 66 | accessToken = response.accessToken.orEmpty(), 67 | refreshToken = response.refreshToken.orEmpty(), 68 | idToken = response.idToken.orEmpty() 69 | ) 70 | continuation.resumeWith(Result.success(tokens)) 71 | } 72 | //получение токенов произошло неуспешно, показываем ошибку 73 | ex != null -> { continuation.resumeWith(Result.failure(ex)) } 74 | else -> error("unreachable") 75 | } 76 | } 77 | } 78 | } 79 | 80 | private fun getClientAuthentication(): ClientAuthentication { 81 | return ClientSecretPost(AuthConfig.CLIENT_SECRET) 82 | } 83 | 84 | private object AuthConfig { 85 | const val AUTH_URI = "https://github.com/login/oauth/authorize" 86 | const val TOKEN_URI = "https://github.com/login/oauth/access_token" 87 | const val END_SESSION_URI = "https://github.com/logout" 88 | const val RESPONSE_TYPE = ResponseTypeValues.CODE 89 | const val SCOPE = "user,repo" 90 | 91 | const val CLIENT_ID = "..." 92 | const val CLIENT_SECRET = "..." 93 | const val CALLBACK_URL = "ru.kts.oauth://github.com/callback" 94 | const val LOGOUT_CALLBACK_URL = "ru.kts.oauth://github.com/logout_callback" 95 | } 96 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/auth/AuthRepository.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.auth 2 | 3 | import net.openid.appauth.AuthorizationRequest 4 | import net.openid.appauth.AuthorizationService 5 | import net.openid.appauth.EndSessionRequest 6 | import net.openid.appauth.TokenRequest 7 | import timber.log.Timber 8 | 9 | class AuthRepository { 10 | 11 | fun corruptAccessToken() { 12 | TokenStorage.accessToken = "fake token" 13 | } 14 | 15 | fun logout() { 16 | TokenStorage.accessToken = null 17 | TokenStorage.refreshToken = null 18 | TokenStorage.idToken = null 19 | } 20 | 21 | fun getAuthRequest(): AuthorizationRequest { 22 | return AppAuth.getAuthRequest() 23 | } 24 | 25 | fun getEndSessionRequest(): EndSessionRequest { 26 | return AppAuth.getEndSessionRequest() 27 | } 28 | 29 | suspend fun performTokenRequest( 30 | authService: AuthorizationService, 31 | tokenRequest: TokenRequest, 32 | ) { 33 | val tokens = AppAuth.performTokenRequestSuspend(authService, tokenRequest) 34 | //обмен кода на токен произошел успешно, сохраняем токены и завершаем авторизацию 35 | TokenStorage.accessToken = tokens.accessToken 36 | TokenStorage.refreshToken = tokens.refreshToken 37 | TokenStorage.idToken = tokens.idToken 38 | Timber.tag("Oauth").d("6. Tokens accepted:\n access=${tokens.accessToken}\nrefresh=${tokens.refreshToken}\nidToken=${tokens.idToken}") 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/auth/TokenStorage.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.auth 2 | 3 | object TokenStorage { 4 | var accessToken: String? = null 5 | var refreshToken: String? = null 6 | var idToken: String? = null 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/auth/models/TokensModel.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.auth.models 2 | 3 | data class TokensModel( 4 | val accessToken: String, 5 | val refreshToken: String, 6 | val idToken: String 7 | ) 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/github/GithubApi.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.github 2 | 3 | import com.kts.github.data.github.models.RemoteGithubUser 4 | import retrofit2.http.GET 5 | 6 | interface GithubApi { 7 | @GET("user") 8 | suspend fun getCurrentUser( 9 | ): RemoteGithubUser 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/github/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.github 2 | 3 | import com.kts.github.data.github.models.RemoteGithubUser 4 | import com.kts.github.data.network.Networking 5 | 6 | class UserRepository { 7 | suspend fun getUserInformation(): RemoteGithubUser { 8 | return Networking.githubApi.getCurrentUser() 9 | } 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/github/models/RemoteGithubUser.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.github.models 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class RemoteGithubUser( 7 | val id: Long, 8 | val login: String, 9 | val name: String, 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/network/AuthorizationFailedInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.network 2 | 3 | import com.kts.github.data.auth.AppAuth 4 | import com.kts.github.data.auth.TokenStorage 5 | import kotlinx.coroutines.runBlocking 6 | import net.openid.appauth.AuthorizationService 7 | import okhttp3.Interceptor 8 | import okhttp3.Request 9 | import okhttp3.Response 10 | import timber.log.Timber 11 | import java.util.concurrent.CountDownLatch 12 | import java.util.concurrent.TimeUnit 13 | 14 | class AuthorizationFailedInterceptor( 15 | private val authorizationService: AuthorizationService, 16 | private val tokenStorage: TokenStorage 17 | ) : Interceptor { 18 | 19 | override fun intercept(chain: Interceptor.Chain): Response { 20 | val originalRequestTimestamp = System.currentTimeMillis() 21 | val originalResponse = chain.proceed(chain.request()) 22 | return originalResponse 23 | .takeIf { it.code != 401 } 24 | ?: handleUnauthorizedResponse(chain, originalResponse, originalRequestTimestamp) 25 | } 26 | 27 | private fun handleUnauthorizedResponse( 28 | chain: Interceptor.Chain, 29 | originalResponse: Response, 30 | requestTimestamp: Long 31 | ): Response { 32 | val latch = getLatch() 33 | return when { 34 | latch != null && latch.count > 0 -> handleTokenIsUpdating(chain, latch, requestTimestamp) 35 | ?: originalResponse 36 | tokenUpdateTime > requestTimestamp -> updateTokenAndProceedChain(chain) 37 | else -> handleTokenNeedRefresh(chain) ?: originalResponse 38 | } 39 | } 40 | 41 | private fun handleTokenIsUpdating( 42 | chain: Interceptor.Chain, 43 | latch: CountDownLatch, 44 | requestTimestamp: Long 45 | ): Response? { 46 | return if (latch.await(REQUEST_TIMEOUT, TimeUnit.SECONDS) 47 | && tokenUpdateTime > requestTimestamp 48 | ) { 49 | updateTokenAndProceedChain(chain) 50 | } else { 51 | null 52 | } 53 | } 54 | 55 | private fun handleTokenNeedRefresh( 56 | chain: Interceptor.Chain 57 | ): Response? { 58 | return if (refreshToken()) { 59 | updateTokenAndProceedChain(chain) 60 | } else { 61 | null 62 | } 63 | } 64 | 65 | private fun updateTokenAndProceedChain( 66 | chain: Interceptor.Chain 67 | ): Response { 68 | val newRequest = updateOriginalCallWithNewToken(chain.request()) 69 | return chain.proceed(newRequest) 70 | } 71 | 72 | private fun refreshToken(): Boolean { 73 | initLatch() 74 | 75 | val tokenRefreshed = runBlocking { 76 | runCatching { 77 | val refreshRequest = AppAuth.getRefreshTokenRequest(tokenStorage.refreshToken.orEmpty()) 78 | AppAuth.performTokenRequestSuspend(authorizationService, refreshRequest) 79 | } 80 | .getOrNull() 81 | ?.let { tokens -> 82 | TokenStorage.accessToken = tokens.accessToken 83 | TokenStorage.refreshToken = tokens.refreshToken 84 | TokenStorage.idToken = tokens.idToken 85 | true 86 | } ?: false 87 | } 88 | 89 | if (tokenRefreshed) { 90 | tokenUpdateTime = System.currentTimeMillis() 91 | } else { 92 | // не удалось обновить токен, произвести логаут 93 | // unauthorizedHandler.onUnauthorized() 94 | Timber.d("logout after token refresh failure") 95 | } 96 | getLatch()?.countDown() 97 | return tokenRefreshed 98 | } 99 | 100 | private fun updateOriginalCallWithNewToken(request: Request): Request { 101 | return tokenStorage.accessToken?.let { newAccessToken -> 102 | request 103 | .newBuilder() 104 | .header("Authorization", newAccessToken) 105 | .build() 106 | } ?: request 107 | } 108 | 109 | companion object { 110 | 111 | private const val REQUEST_TIMEOUT = 30L 112 | 113 | @Volatile 114 | private var tokenUpdateTime: Long = 0L 115 | 116 | private var countDownLatch: CountDownLatch? = null 117 | 118 | @Synchronized 119 | fun initLatch() { 120 | countDownLatch = CountDownLatch(1) 121 | } 122 | 123 | @Synchronized 124 | fun getLatch() = countDownLatch 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/network/AuthorizationInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.network 2 | 3 | import com.kts.github.data.auth.TokenStorage 4 | import okhttp3.Interceptor 5 | import okhttp3.Request 6 | import okhttp3.Response 7 | 8 | class AuthorizationInterceptor: Interceptor { 9 | override fun intercept(chain: Interceptor.Chain): Response { 10 | return chain.request() 11 | .addTokenHeader() 12 | .let { chain.proceed(it) } 13 | } 14 | 15 | private fun Request.addTokenHeader(): Request { 16 | val authHeaderName = "Authorization" 17 | return newBuilder() 18 | .apply { 19 | val token = TokenStorage.accessToken 20 | if (token != null) { 21 | header(authHeaderName, token.withBearer()) 22 | } 23 | } 24 | .build() 25 | } 26 | 27 | private fun String.withBearer() = "Bearer $this" 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/data/network/Networking.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.data.network 2 | 3 | import android.content.Context 4 | import com.kts.github.data.auth.TokenStorage 5 | import com.kts.github.data.github.GithubApi 6 | import net.openid.appauth.AuthorizationService 7 | import okhttp3.OkHttpClient 8 | import okhttp3.logging.HttpLoggingInterceptor 9 | import retrofit2.Retrofit 10 | import retrofit2.converter.moshi.MoshiConverterFactory 11 | import retrofit2.create 12 | import timber.log.Timber 13 | 14 | object Networking { 15 | 16 | private var okhttpClient: OkHttpClient? = null 17 | private var retrofit: Retrofit? = null 18 | 19 | val githubApi: GithubApi 20 | get() = retrofit?.create() ?: error("retrofit is not initialized") 21 | 22 | fun init(context: Context) { 23 | okhttpClient = OkHttpClient.Builder() 24 | .addNetworkInterceptor( 25 | HttpLoggingInterceptor { 26 | Timber.tag("Network").d(it) 27 | } 28 | .setLevel(HttpLoggingInterceptor.Level.BODY) 29 | ) 30 | .addNetworkInterceptor(AuthorizationInterceptor()) 31 | .addNetworkInterceptor(AuthorizationFailedInterceptor(AuthorizationService(context), TokenStorage)) 32 | .build() 33 | 34 | retrofit = Retrofit.Builder() 35 | .baseUrl("https://api.github.com/") 36 | .addConverterFactory(MoshiConverterFactory.create()) 37 | .client(okhttpClient!!) 38 | .build() 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.ui 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import com.kts.github.R 5 | 6 | class MainActivity : AppCompatActivity(R.layout.activity_main) -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/ui/auth/AuthFragment.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.ui.auth 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.View 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.core.view.isVisible 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.viewModels 10 | import androidx.navigation.fragment.findNavController 11 | import by.kirich1409.viewbindingdelegate.viewBinding 12 | import com.kts.github.R 13 | import com.kts.github.databinding.FragmentAuthBinding 14 | import com.kts.github.utils.launchAndCollectIn 15 | import com.kts.github.utils.toast 16 | import net.openid.appauth.AuthorizationException 17 | import net.openid.appauth.AuthorizationResponse 18 | 19 | class AuthFragment : Fragment(R.layout.fragment_auth) { 20 | 21 | private val viewModel: AuthViewModel by viewModels() 22 | private val binding by viewBinding(FragmentAuthBinding::bind) 23 | 24 | private val getAuthResponse = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { 25 | val dataIntent = it.data ?: return@registerForActivityResult 26 | handleAuthResponseIntent(dataIntent) 27 | } 28 | 29 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 30 | super.onViewCreated(view, savedInstanceState) 31 | bindViewModel() 32 | } 33 | 34 | private fun bindViewModel() { 35 | binding.loginButton.setOnClickListener { viewModel.openLoginPage() } 36 | viewModel.loadingFlow.launchAndCollectIn(viewLifecycleOwner) { 37 | updateIsLoading(it) 38 | } 39 | viewModel.openAuthPageFlow.launchAndCollectIn(viewLifecycleOwner) { 40 | openAuthPage(it) 41 | } 42 | viewModel.toastFlow.launchAndCollectIn(viewLifecycleOwner) { 43 | toast(it) 44 | } 45 | viewModel.authSuccessFlow.launchAndCollectIn(viewLifecycleOwner) { 46 | findNavController().navigate(AuthFragmentDirections.actionAuthFragmentToRepositoryListFragment()) 47 | } 48 | } 49 | 50 | private fun updateIsLoading(isLoading: Boolean) = with(binding) { 51 | loginButton.isVisible = !isLoading 52 | loginProgress.isVisible = isLoading 53 | } 54 | 55 | private fun openAuthPage(intent: Intent) { 56 | getAuthResponse.launch(intent) 57 | } 58 | 59 | private fun handleAuthResponseIntent(intent: Intent) { 60 | // пытаемся получить ошибку из ответа. null - если все ок 61 | val exception = AuthorizationException.fromIntent(intent) 62 | // пытаемся получить запрос для обмена кода на токен, null - если произошла ошибка 63 | val tokenExchangeRequest = AuthorizationResponse.fromIntent(intent) 64 | ?.createTokenExchangeRequest() 65 | when { 66 | // авторизация завершались ошибкой 67 | exception != null -> viewModel.onAuthCodeFailed(exception) 68 | // авторизация прошла успешно, меняем код на токен 69 | tokenExchangeRequest != null -> 70 | viewModel.onAuthCodeReceived(tokenExchangeRequest) 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/ui/auth/AuthViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.ui.auth 2 | 3 | import android.app.Application 4 | import android.content.Intent 5 | import androidx.browser.customtabs.CustomTabsIntent 6 | import androidx.lifecycle.AndroidViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.kts.github.R 9 | import com.kts.github.data.auth.AuthRepository 10 | import kotlinx.coroutines.channels.Channel 11 | import kotlinx.coroutines.channels.trySendBlocking 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.asStateFlow 15 | import kotlinx.coroutines.flow.receiveAsFlow 16 | import kotlinx.coroutines.launch 17 | import net.openid.appauth.AuthorizationException 18 | import net.openid.appauth.AuthorizationService 19 | import net.openid.appauth.TokenRequest 20 | import timber.log.Timber 21 | 22 | class AuthViewModel(application: Application) : AndroidViewModel(application) { 23 | 24 | private val authRepository = AuthRepository() 25 | private val authService: AuthorizationService = AuthorizationService(getApplication()) 26 | 27 | private val openAuthPageEventChannel = Channel(Channel.BUFFERED) 28 | private val toastEventChannel = Channel(Channel.BUFFERED) 29 | private val authSuccessEventChannel = Channel(Channel.BUFFERED) 30 | 31 | private val loadingMutableStateFlow = MutableStateFlow(false) 32 | 33 | val openAuthPageFlow: Flow 34 | get() = openAuthPageEventChannel.receiveAsFlow() 35 | 36 | val loadingFlow: Flow 37 | get() = loadingMutableStateFlow.asStateFlow() 38 | 39 | val toastFlow: Flow 40 | get() = toastEventChannel.receiveAsFlow() 41 | 42 | val authSuccessFlow: Flow 43 | get() = authSuccessEventChannel.receiveAsFlow() 44 | 45 | fun onAuthCodeFailed(exception: AuthorizationException) { 46 | toastEventChannel.trySendBlocking(R.string.auth_canceled) 47 | } 48 | 49 | fun onAuthCodeReceived(tokenRequest: TokenRequest) { 50 | 51 | Timber.tag("Oauth").d("3. Received code = ${tokenRequest.authorizationCode}") 52 | 53 | viewModelScope.launch { 54 | loadingMutableStateFlow.value = true 55 | runCatching { 56 | Timber.tag("Oauth").d("4. Change code to token. Url = ${tokenRequest.configuration.tokenEndpoint}, verifier = ${tokenRequest.codeVerifier}") 57 | authRepository.performTokenRequest( 58 | authService = authService, 59 | tokenRequest = tokenRequest 60 | ) 61 | }.onSuccess { 62 | loadingMutableStateFlow.value = false 63 | authSuccessEventChannel.send(Unit) 64 | }.onFailure { 65 | loadingMutableStateFlow.value = false 66 | toastEventChannel.send(R.string.auth_canceled) 67 | } 68 | } 69 | } 70 | 71 | fun openLoginPage() { 72 | val customTabsIntent = CustomTabsIntent.Builder().build() 73 | 74 | val authRequest = authRepository.getAuthRequest() 75 | 76 | Timber.tag("Oauth").d("1. Generated verifier=${authRequest.codeVerifier},challenge=${authRequest.codeVerifierChallenge}") 77 | 78 | val openAuthPageIntent = authService.getAuthorizationRequestIntent( 79 | authRequest, 80 | customTabsIntent 81 | ) 82 | 83 | openAuthPageEventChannel.trySendBlocking(openAuthPageIntent) 84 | Timber.tag("Oauth").d("2. Open auth page: ${authRequest.toUri()}") 85 | } 86 | 87 | override fun onCleared() { 88 | super.onCleared() 89 | authService.dispose() 90 | } 91 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/ui/user_info/UserInfoFragment.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.ui.user_info 2 | 3 | import android.app.Activity 4 | import android.os.Bundle 5 | import android.view.View 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.core.view.isVisible 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.viewModels 10 | import androidx.navigation.fragment.findNavController 11 | import by.kirich1409.viewbindingdelegate.viewBinding 12 | import com.kts.github.R 13 | import com.kts.github.databinding.FragmentUserInfoBinding 14 | import com.kts.github.utils.launchAndCollectIn 15 | import com.kts.github.utils.resetNavGraph 16 | import com.kts.github.utils.toast 17 | 18 | class UserInfoFragment : Fragment(R.layout.fragment_user_info) { 19 | 20 | private val viewModel: UserInfoViewModel by viewModels() 21 | private val binding by viewBinding(FragmentUserInfoBinding::bind) 22 | 23 | private val logoutResponse = registerForActivityResult( 24 | ActivityResultContracts.StartActivityForResult() 25 | ) { result -> 26 | if(result.resultCode == Activity.RESULT_OK) { 27 | viewModel.webLogoutComplete() 28 | } else { 29 | // логаут отменен 30 | // делаем complete тк github не редиректит после логаута и пользователь закрывает CCT 31 | viewModel.webLogoutComplete() 32 | } 33 | } 34 | 35 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 36 | super.onViewCreated(view, savedInstanceState) 37 | binding.corruptAccessToken.setOnClickListener { 38 | viewModel.corruptAccessToken() 39 | } 40 | binding.getUserInfo.setOnClickListener { 41 | viewModel.loadUserInfo() 42 | } 43 | binding.logout.setOnClickListener { 44 | viewModel.logout() 45 | } 46 | 47 | viewModel.loadingFlow.launchAndCollectIn(viewLifecycleOwner) { isLoading -> 48 | binding.progressBar.isVisible = isLoading 49 | binding.getUserInfo.isEnabled = !isLoading 50 | binding.userInfo.isVisible = !isLoading 51 | } 52 | 53 | viewModel.userInfoFlow.launchAndCollectIn(viewLifecycleOwner) { userInfo -> 54 | binding.userInfo.text = userInfo?.login 55 | } 56 | 57 | viewModel.toastFlow.launchAndCollectIn(viewLifecycleOwner) { 58 | toast(it) 59 | } 60 | 61 | viewModel.logoutPageFlow.launchAndCollectIn(viewLifecycleOwner) { 62 | logoutResponse.launch(it) 63 | } 64 | 65 | viewModel.logoutCompletedFlow.launchAndCollectIn(viewLifecycleOwner) { 66 | findNavController().resetNavGraph(R.navigation.nav_graph) 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/ui/user_info/UserInfoViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.ui.user_info 2 | 3 | import android.app.Application 4 | import android.content.Intent 5 | import androidx.browser.customtabs.CustomTabsIntent 6 | import androidx.lifecycle.AndroidViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.kts.github.R 9 | import com.kts.github.data.auth.AuthRepository 10 | import com.kts.github.data.github.UserRepository 11 | import com.kts.github.data.github.models.RemoteGithubUser 12 | import kotlinx.coroutines.channels.Channel 13 | import kotlinx.coroutines.channels.trySendBlocking 14 | import kotlinx.coroutines.flow.Flow 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.asStateFlow 17 | import kotlinx.coroutines.flow.receiveAsFlow 18 | import kotlinx.coroutines.launch 19 | import net.openid.appauth.AuthorizationService 20 | 21 | class UserInfoViewModel(application: Application): AndroidViewModel(application) { 22 | 23 | private val authService: AuthorizationService = AuthorizationService(getApplication()) 24 | 25 | private val authRepository = AuthRepository() 26 | private val userRepository = UserRepository() 27 | 28 | private val loadingMutableStateFlow = MutableStateFlow(false) 29 | private val userInfoMutableStateFlow = MutableStateFlow(null) 30 | private val toastEventChannel = Channel(Channel.BUFFERED) 31 | private val logoutPageEventChannel = Channel(Channel.BUFFERED) 32 | private val logoutCompletedEventChannel = Channel(Channel.BUFFERED) 33 | 34 | 35 | val loadingFlow: Flow 36 | get() = loadingMutableStateFlow.asStateFlow() 37 | 38 | val userInfoFlow: Flow 39 | get() = userInfoMutableStateFlow.asStateFlow() 40 | 41 | val toastFlow: Flow 42 | get() = toastEventChannel.receiveAsFlow() 43 | 44 | val logoutPageFlow: Flow 45 | get() = logoutPageEventChannel.receiveAsFlow() 46 | 47 | val logoutCompletedFlow: Flow 48 | get() = logoutCompletedEventChannel.receiveAsFlow() 49 | 50 | fun corruptAccessToken() { 51 | authRepository.corruptAccessToken() 52 | } 53 | 54 | fun loadUserInfo() { 55 | viewModelScope.launch { 56 | loadingMutableStateFlow.value = true 57 | runCatching { 58 | userRepository.getUserInformation() 59 | }.onSuccess { 60 | userInfoMutableStateFlow.value = it 61 | loadingMutableStateFlow.value = false 62 | }.onFailure { 63 | loadingMutableStateFlow.value = false 64 | userInfoMutableStateFlow.value = null 65 | toastEventChannel.trySendBlocking(R.string.get_user_error) 66 | } 67 | } 68 | } 69 | 70 | fun logout() { 71 | val customTabsIntent = CustomTabsIntent.Builder().build() 72 | 73 | val logoutPageIntent = authService.getEndSessionRequestIntent( 74 | authRepository.getEndSessionRequest(), 75 | customTabsIntent 76 | ) 77 | 78 | logoutPageEventChannel.trySendBlocking(logoutPageIntent) 79 | } 80 | 81 | fun webLogoutComplete() { 82 | authRepository.logout() 83 | logoutCompletedEventChannel.trySendBlocking(Unit) 84 | } 85 | 86 | override fun onCleared() { 87 | super.onCleared() 88 | authService.dispose() 89 | } 90 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/utils/FlowExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.utils 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import androidx.lifecycle.LifecycleOwner 5 | import androidx.lifecycle.lifecycleScope 6 | import androidx.lifecycle.repeatOnLifecycle 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.collect 10 | import kotlinx.coroutines.launch 11 | 12 | inline fun Flow.launchAndCollectIn( 13 | owner: LifecycleOwner, 14 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED, 15 | crossinline action: suspend CoroutineScope.(T) -> Unit 16 | ) = owner.lifecycleScope.launch { 17 | owner.repeatOnLifecycle(minActiveState) { 18 | collect { 19 | action(it) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/utils/FragmentExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.utils 2 | 3 | import android.widget.Toast 4 | import androidx.annotation.StringRes 5 | import androidx.fragment.app.Fragment 6 | 7 | fun Fragment.toast(@StringRes stringRes: Int) { 8 | Toast.makeText(requireContext(), stringRes, Toast.LENGTH_SHORT).show() 9 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kts/github/utils/NavExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.kts.github.utils 2 | 3 | import androidx.annotation.NavigationRes 4 | import androidx.navigation.NavController 5 | 6 | fun NavController.resetNavGraph(@NavigationRes navGraph: Int) { 7 | val newGraph = navInflater.inflate(navGraph) 8 | graph = newGraph 9 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_auth.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 |