From e67a4769ecc5de706163d534f2e9bc69928a7061 Mon Sep 17 00:00:00 2001
From: yansans <66671259+yansans@users.noreply.github.com>
Date: Sat, 30 Mar 2024 20:16:49 +0700
Subject: [PATCH] refactor: apiClient and feat: simple jwt service

---
 app/src/main/AndroidManifest.xml              |   9 ++
 .../java/com/example/bondoyap/ApiClient.kt    |  41 -------
 .../java/com/example/bondoyap/MainActivity.kt |   7 +-
 .../bondoyap/service/SessionManager.kt        |  52 ++++++++
 .../example/bondoyap/service/api/ApiClient.kt |  40 ++++++
 .../bondoyap/service/api/ApiService.kt        |  24 ++++
 .../bondoyap/service/api/AuthInterceptor.kt   |  21 ++++
 .../example/bondoyap/service/api/Constants.kt |   6 +
 .../bondoyap/service/api/data/LoginRequest.kt |   6 +
 .../service/api/data/LoginResponse.kt         |   5 +
 .../service/api/data/TokenResponse.kt         |   7 ++
 .../bondoyap/service/jwt/JwtService.kt        | 115 ++++++++++++++++++
 .../bondoyap/ui/login/LoginActivity.kt        |   2 +-
 .../ui/login/LoginViewModelFactory.kt         |   2 +-
 .../bondoyap/ui/login/data/LoginDataSource.kt |  31 ++---
 .../bondoyap/ui/login/data/LoginRepository.kt |   3 +-
 .../ui/settings/SettingsViewModelFactory.kt   |   2 +-
 17 files changed, 308 insertions(+), 65 deletions(-)
 delete mode 100644 app/src/main/java/com/example/bondoyap/ApiClient.kt
 create mode 100644 app/src/main/java/com/example/bondoyap/service/SessionManager.kt
 create mode 100644 app/src/main/java/com/example/bondoyap/service/api/ApiClient.kt
 create mode 100644 app/src/main/java/com/example/bondoyap/service/api/ApiService.kt
 create mode 100644 app/src/main/java/com/example/bondoyap/service/api/AuthInterceptor.kt
 create mode 100644 app/src/main/java/com/example/bondoyap/service/api/Constants.kt
 create mode 100644 app/src/main/java/com/example/bondoyap/service/api/data/LoginRequest.kt
 create mode 100644 app/src/main/java/com/example/bondoyap/service/api/data/LoginResponse.kt
 create mode 100644 app/src/main/java/com/example/bondoyap/service/api/data/TokenResponse.kt
 create mode 100644 app/src/main/java/com/example/bondoyap/service/jwt/JwtService.kt

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d03d4fa..5835309 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,11 +14,20 @@
         android:supportsRtl="true"
         android:theme="@style/Theme.BondoYap"
         tools:targetApi="31">
+
+        <service
+            android:name=".service.jwt.JwtService"
+            android:enabled="true"
+            android:exported="true"
+            android:permission="android.permission.INTERNET">
+        </service>
+
         <activity
             android:name=".ui.login.LoginActivity"
             android:exported="true">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
+
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
diff --git a/app/src/main/java/com/example/bondoyap/ApiClient.kt b/app/src/main/java/com/example/bondoyap/ApiClient.kt
deleted file mode 100644
index d1858bc..0000000
--- a/app/src/main/java/com/example/bondoyap/ApiClient.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.example.bondoyap
-
-import retrofit2.Call
-import retrofit2.http.Body
-import retrofit2.http.POST
-
-
-object Constants {
-    const val BASE_URL: String = "https://pbd-backend-2024.vercel.app/api/"
-    const val SHARED_PREFS_NAME = "Prefs"
-}
-
-interface ApiClient{
-    @POST("auth/login")
-    fun login(
-        @Body loginRequest: LoginRequest
-    ): Call<LoginResponse>
-
-    @POST("auth/token")
-    fun verifyToken(
-    ): Call<TokenResponse>
-
-    @POST("bill/upload")
-    fun uploadPicture(
-    )
-}
-
-data class LoginRequest(
-    val email: String,
-    val password: String
-)
-
-data class LoginResponse(
-    val token: String
-)
-
-data class TokenResponse(
-    val nim: String,
-    val iat: Number,
-    val exp: Number
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/bondoyap/MainActivity.kt b/app/src/main/java/com/example/bondoyap/MainActivity.kt
index 7002b98..bb0a8bf 100644
--- a/app/src/main/java/com/example/bondoyap/MainActivity.kt
+++ b/app/src/main/java/com/example/bondoyap/MainActivity.kt
@@ -10,8 +10,9 @@ import androidx.navigation.findNavController
 import androidx.navigation.ui.AppBarConfiguration
 import androidx.navigation.ui.setupActionBarWithNavController
 import androidx.navigation.ui.setupWithNavController
-import com.example.bondoyap.Constants.SHARED_PREFS_NAME
+import com.example.bondoyap.service.api.Constants.SHARED_PREFS_NAME
 import com.example.bondoyap.databinding.ActivityMainBinding
+import com.example.bondoyap.service.jwt.JwtService
 import com.example.bondoyap.ui.login.LoginActivity
 
 class MainActivity : AppCompatActivity() {
@@ -29,6 +30,10 @@ class MainActivity : AppCompatActivity() {
             startActivity(intent)
             finish()
         }
+
+        val serviceIntent = Intent(this, JwtService::class.java)
+        this.startService(serviceIntent)
+
         binding = ActivityMainBinding.inflate(layoutInflater)
         setContentView(binding.root)
 
diff --git a/app/src/main/java/com/example/bondoyap/service/SessionManager.kt b/app/src/main/java/com/example/bondoyap/service/SessionManager.kt
new file mode 100644
index 0000000..2a58fbe
--- /dev/null
+++ b/app/src/main/java/com/example/bondoyap/service/SessionManager.kt
@@ -0,0 +1,52 @@
+package com.example.bondoyap.service
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.example.bondoyap.service.api.Constants.SHARED_PREFS_NAME
+import com.example.bondoyap.ui.login.data.model.LoggedInUser
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+
+class SessionManager(context: Context) {
+
+    private var prefs: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_NAME,
+        Context.MODE_PRIVATE)
+    private val editor: SharedPreferences.Editor = prefs.edit()
+
+
+    private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
+    private val userAdapter: JsonAdapter<LoggedInUser> = moshi.adapter(LoggedInUser::class.java)
+
+    private fun getUser(): LoggedInUser? {
+        val userJson = prefs.getString("loggedInUser", null)
+        return userJson?.let {
+            userAdapter.fromJson(it)
+        }
+    }
+
+    fun saveExp(exp: Long){
+        editor.putLong("exp", exp)
+        editor.apply()
+    }
+
+    fun hasExp(): Boolean{
+        return getExp().toInt() != -1
+    }
+
+    fun getExp(): Long {
+        return prefs.getLong("exp", -1)
+    }
+
+    fun getToken(): String? {
+        val user = getUser()
+        return user?.let {
+            user.token
+        }
+    }
+
+    fun logout(){
+        editor.clear()
+        editor.apply()
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/bondoyap/service/api/ApiClient.kt b/app/src/main/java/com/example/bondoyap/service/api/ApiClient.kt
new file mode 100644
index 0000000..02b03ee
--- /dev/null
+++ b/app/src/main/java/com/example/bondoyap/service/api/ApiClient.kt
@@ -0,0 +1,40 @@
+package com.example.bondoyap.service.api
+
+import android.content.Context
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+
+class ApiClient {
+    private lateinit var apiService: ApiService
+    fun getApiService(context: Context): ApiService {
+
+        if (!::apiService.isInitialized) {
+
+            val moshi = Moshi.Builder()
+                .add(KotlinJsonAdapterFactory())
+                .build()
+
+            val retrofit = Retrofit.Builder()
+                .baseUrl(Constants.BASE_URL)
+                .client(okhttpClient(context))
+                .addConverterFactory(MoshiConverterFactory.create(moshi))
+                .build()
+
+            apiService = retrofit.create(ApiService::class.java)
+        }
+        return apiService
+    }
+
+    private fun okhttpClient(context: Context): OkHttpClient {
+        return OkHttpClient.Builder()
+            .addInterceptor(AuthInterceptor(context))
+            .build()
+    }
+
+}
+
+
+
diff --git a/app/src/main/java/com/example/bondoyap/service/api/ApiService.kt b/app/src/main/java/com/example/bondoyap/service/api/ApiService.kt
new file mode 100644
index 0000000..a7bf474
--- /dev/null
+++ b/app/src/main/java/com/example/bondoyap/service/api/ApiService.kt
@@ -0,0 +1,24 @@
+package com.example.bondoyap.service.api
+
+import com.example.bondoyap.service.api.data.LoginRequest
+import com.example.bondoyap.service.api.data.LoginResponse
+import com.example.bondoyap.service.api.data.TokenResponse
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface ApiService {
+    @POST("auth/login")
+    fun login(
+        @Body loginRequest: LoginRequest
+    ): Call<LoginResponse>
+
+    @POST("auth/token")
+    fun verifyToken(
+        @Body token: String
+    ): Call<TokenResponse>
+
+    @POST("bill/upload")
+    fun uploadPicture(
+    )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/bondoyap/service/api/AuthInterceptor.kt b/app/src/main/java/com/example/bondoyap/service/api/AuthInterceptor.kt
new file mode 100644
index 0000000..62f933c
--- /dev/null
+++ b/app/src/main/java/com/example/bondoyap/service/api/AuthInterceptor.kt
@@ -0,0 +1,21 @@
+package com.example.bondoyap.service.api
+
+import android.content.Context
+import com.example.bondoyap.service.SessionManager
+import okhttp3.Interceptor
+import okhttp3.Response
+
+class AuthInterceptor(context: Context) : Interceptor {
+
+    private val sessionManager = SessionManager(context)
+
+    override fun intercept(chain: Interceptor.Chain): Response {
+        val requestBuilder = chain.request().newBuilder()
+
+        sessionManager.getToken()?.let {
+            requestBuilder.addHeader("Authorization", "Bearer $it")
+        }
+
+        return chain.proceed(requestBuilder.build())
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/bondoyap/service/api/Constants.kt b/app/src/main/java/com/example/bondoyap/service/api/Constants.kt
new file mode 100644
index 0000000..76fb345
--- /dev/null
+++ b/app/src/main/java/com/example/bondoyap/service/api/Constants.kt
@@ -0,0 +1,6 @@
+package com.example.bondoyap.service.api
+
+object Constants {
+    const val BASE_URL: String = "https://pbd-backend-2024.vercel.app/api/"
+    const val SHARED_PREFS_NAME = "BondoYap"
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/bondoyap/service/api/data/LoginRequest.kt b/app/src/main/java/com/example/bondoyap/service/api/data/LoginRequest.kt
new file mode 100644
index 0000000..583c050
--- /dev/null
+++ b/app/src/main/java/com/example/bondoyap/service/api/data/LoginRequest.kt
@@ -0,0 +1,6 @@
+package com.example.bondoyap.service.api.data
+
+data class LoginRequest(
+    val email: String,
+    val password: String
+)
diff --git a/app/src/main/java/com/example/bondoyap/service/api/data/LoginResponse.kt b/app/src/main/java/com/example/bondoyap/service/api/data/LoginResponse.kt
new file mode 100644
index 0000000..ca1bb88
--- /dev/null
+++ b/app/src/main/java/com/example/bondoyap/service/api/data/LoginResponse.kt
@@ -0,0 +1,5 @@
+package com.example.bondoyap.service.api.data
+
+data class LoginResponse(
+    val token: String
+)
diff --git a/app/src/main/java/com/example/bondoyap/service/api/data/TokenResponse.kt b/app/src/main/java/com/example/bondoyap/service/api/data/TokenResponse.kt
new file mode 100644
index 0000000..7f8a2be
--- /dev/null
+++ b/app/src/main/java/com/example/bondoyap/service/api/data/TokenResponse.kt
@@ -0,0 +1,7 @@
+package com.example.bondoyap.service.api.data
+
+data class TokenResponse(
+    val nim: String,
+    val iat: Long,
+    val exp: Long
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/bondoyap/service/jwt/JwtService.kt b/app/src/main/java/com/example/bondoyap/service/jwt/JwtService.kt
new file mode 100644
index 0000000..946a435
--- /dev/null
+++ b/app/src/main/java/com/example/bondoyap/service/jwt/JwtService.kt
@@ -0,0 +1,115 @@
+package com.example.bondoyap.service.jwt
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Handler
+import android.os.IBinder
+import android.util.Log
+import android.widget.Toast
+import com.example.bondoyap.service.SessionManager
+import com.example.bondoyap.service.api.ApiClient
+import com.example.bondoyap.service.api.data.TokenResponse
+import com.example.bondoyap.ui.login.LoginActivity
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+
+class JwtService : Service() {
+
+    private lateinit var handler : Handler
+
+    private lateinit var session: SessionManager
+
+    private val task = object : Runnable {
+        override fun run() {
+            Log.d("JwtService", "Current Time: ${System.currentTimeMillis()}")
+
+            session =  SessionManager(applicationContext)
+            val token = session.getToken()
+
+            if (token != null) {
+                verifyJwt(applicationContext, token)
+            }
+            // 5 second
+            handler.postDelayed(this, 5000)
+        }
+    }
+
+    private lateinit var apiClient: ApiClient
+
+    override fun onCreate() {
+        super.onCreate()
+        handler = Handler(mainLooper)
+    }
+
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        handler.post(task)
+        return START_STICKY
+    }
+
+    override fun onBind(intent: Intent): IBinder? {
+        return null
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        handler.removeCallbacks(task)
+    }
+
+    fun verifyJwt(context: Context, token: String){
+        if (session.hasExp()) {
+            checkJwt(session.getExp())
+            return
+        }
+        apiClient = ApiClient()
+        apiClient.getApiService(context).verifyToken(token).enqueue(object: Callback<TokenResponse>{
+            override fun onFailure(call: Call<TokenResponse>, t: Throwable) {
+                Log.d("JwtService", "Error Failure")
+            }
+
+            override fun onResponse(call: Call<TokenResponse>, response: Response<TokenResponse>) {
+                Log.d("JwtService", "Getting Response")
+
+                val tokenResponse = response.body()
+
+                if (tokenResponse != null){
+                    Log.d("JwtService", "Updating Exp")
+
+                    session.saveExp(tokenResponse.exp)
+                    checkJwt(tokenResponse.exp)
+                } else {
+                    Log.d("JwtService", "Error response")
+                    Log.d("JwtService", "Error expired?")
+                    handleExpired()
+                }
+
+            }
+        })
+    }
+
+    fun checkJwt(exp: Long) {
+        val currentTimestamp = System.currentTimeMillis() / 1000
+
+        Log.d("JwtService", "exp : ${exp}")
+        Log.d("JwtService", "currentTime : ${currentTimestamp}")
+
+        if (currentTimestamp >= exp) {
+            Log.d("JwtService", "JWT is expired")
+            handleExpired()
+        } else {
+            Log.d("JwtService", "JWT is still valid")
+        }
+    }
+
+    fun handleExpired(){
+        session.logout()
+        Toast.makeText(applicationContext, "Session Expired! \n Logging out ...", Toast.LENGTH_LONG).show()
+
+        val intent = Intent(applicationContext, LoginActivity::class.java)
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        startActivity(intent)
+        stopSelf()
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/bondoyap/ui/login/LoginActivity.kt b/app/src/main/java/com/example/bondoyap/ui/login/LoginActivity.kt
index dc379ed..e64b43e 100644
--- a/app/src/main/java/com/example/bondoyap/ui/login/LoginActivity.kt
+++ b/app/src/main/java/com/example/bondoyap/ui/login/LoginActivity.kt
@@ -14,7 +14,7 @@ import android.view.inputmethod.EditorInfo
 import android.widget.EditText
 import android.widget.Toast
 import androidx.appcompat.app.AppCompatActivity
-import com.example.bondoyap.Constants.SHARED_PREFS_NAME
+import com.example.bondoyap.service.api.Constants.SHARED_PREFS_NAME
 import com.example.bondoyap.MainActivity
 import com.example.bondoyap.R
 import com.example.bondoyap.databinding.ActivityLoginBinding
diff --git a/app/src/main/java/com/example/bondoyap/ui/login/LoginViewModelFactory.kt b/app/src/main/java/com/example/bondoyap/ui/login/LoginViewModelFactory.kt
index b0e9039..0b7abfa 100644
--- a/app/src/main/java/com/example/bondoyap/ui/login/LoginViewModelFactory.kt
+++ b/app/src/main/java/com/example/bondoyap/ui/login/LoginViewModelFactory.kt
@@ -13,7 +13,7 @@ class LoginViewModelFactory(private val context: Context) : ViewModelProvider.Fa
         if (modelClass.isAssignableFrom(LoginViewModel::class.java)) {
             return LoginViewModel(
                 loginRepository = LoginRepository(
-                    dataSource = LoginDataSource(),
+                    dataSource = LoginDataSource(context),
                     context = context
 
                 )
diff --git a/app/src/main/java/com/example/bondoyap/ui/login/data/LoginDataSource.kt b/app/src/main/java/com/example/bondoyap/ui/login/data/LoginDataSource.kt
index cf54c30..00f4b56 100644
--- a/app/src/main/java/com/example/bondoyap/ui/login/data/LoginDataSource.kt
+++ b/app/src/main/java/com/example/bondoyap/ui/login/data/LoginDataSource.kt
@@ -1,21 +1,20 @@
 package com.example.bondoyap.ui.login.data
 
-import com.example.bondoyap.ApiClient
-import com.example.bondoyap.Constants
-import com.example.bondoyap.LoginRequest
+import android.content.Context
+import android.util.Log
+import com.example.bondoyap.service.api.ApiClient
+import com.example.bondoyap.service.api.data.LoginRequest
 import com.example.bondoyap.ui.login.data.model.LoggedInUser
-import com.squareup.moshi.Moshi
-import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
-import retrofit2.Retrofit
-import retrofit2.converter.moshi.MoshiConverterFactory
 import java.io.IOException
 
 /**
  * Class that handles authentication w/ login credentials and retrieves user information.
  */
-class LoginDataSource {
+class LoginDataSource(private val context: Context) {
+
+    private lateinit var apiClient: ApiClient
 
     suspend fun login(email: String, password: String): Result<LoggedInUser> {
         return try {
@@ -35,29 +34,23 @@ class LoginDataSource {
 
     private suspend fun postLogin(email: String, password: String):String {
 
-        val moshi = Moshi.Builder()
-            .add(KotlinJsonAdapterFactory())
-            .build()
-
-        val api = Retrofit.Builder()
-            .baseUrl(Constants.BASE_URL)
-            .addConverterFactory(MoshiConverterFactory.create(moshi))
-            .build()
-
-        val apiClient = api.create(ApiClient::class.java)
+        apiClient = ApiClient()
+        val service = apiClient.getApiService(context)
 
         return withContext(Dispatchers.IO) {
             try {
                 val loginRequest = LoginRequest(email, password)
-                val response = apiClient.login(loginRequest).execute()
+                val response = service.login(loginRequest).execute()
 
                 if (response.isSuccessful) {
                     val loginResponse = response.body()
                     loginResponse?.token ?: throw IOException("Token not received")
                 } else {
+                    Log.d("LoginDataSource", "${response.code()} ${response.message()}")
                     throw IOException("${response.code()} ${response.message()}")
                 }
             } catch (e: Exception) {
+                Log.d("LoginDataSource", "Login failed: ${e.message}")
                 throw IOException("Login failed: ${e.message}")
             }
         }
diff --git a/app/src/main/java/com/example/bondoyap/ui/login/data/LoginRepository.kt b/app/src/main/java/com/example/bondoyap/ui/login/data/LoginRepository.kt
index bcc2777..1d72937 100644
--- a/app/src/main/java/com/example/bondoyap/ui/login/data/LoginRepository.kt
+++ b/app/src/main/java/com/example/bondoyap/ui/login/data/LoginRepository.kt
@@ -3,6 +3,7 @@ package com.example.bondoyap.ui.login.data
 import com.example.bondoyap.ui.login.data.model.LoggedInUser
 import android.content.Context
 import android.content.SharedPreferences
+import com.example.bondoyap.service.api.Constants.SHARED_PREFS_NAME
 import com.squareup.moshi.JsonAdapter
 import com.squareup.moshi.Moshi
 import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
@@ -14,7 +15,7 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
 
 class LoginRepository(val dataSource: LoginDataSource, context: Context) {
 
-    private val sharedPreferences: SharedPreferences = context.getSharedPreferences("Prefs", Context.MODE_PRIVATE)
+    private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
     private val editor: SharedPreferences.Editor = sharedPreferences.edit()
 
     private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
diff --git a/app/src/main/java/com/example/bondoyap/ui/settings/SettingsViewModelFactory.kt b/app/src/main/java/com/example/bondoyap/ui/settings/SettingsViewModelFactory.kt
index b3b7f97..ae36728 100644
--- a/app/src/main/java/com/example/bondoyap/ui/settings/SettingsViewModelFactory.kt
+++ b/app/src/main/java/com/example/bondoyap/ui/settings/SettingsViewModelFactory.kt
@@ -13,7 +13,7 @@ class SettingsViewModelFactory(private val context: Context) : ViewModelProvider
         if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) {
             return SettingsViewModel(
                 loginRepository = LoginRepository(
-                    dataSource = LoginDataSource(),
+                    dataSource = LoginDataSource(context),
                     context = context
                 )
             ) as T
-- 
GitLab