refactor: move network logic into ViewModels and externalize strings

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Andrew Ridgway 2026-08-04 23:14:06 +10:00
parent 98b15c748a
commit 43d1a6013b
Signed by: armistace
GPG Key ID: C8D9EAC514B47EF1
8 changed files with 228 additions and 161 deletions

View File

@ -22,9 +22,14 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true
optimization { optimization {
enable = false enable = false
} }
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
} }
} }
compileOptions { compileOptions {
@ -36,6 +41,9 @@ android {
dependencies { dependencies {
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.fragment.ktx)
implementation(libs.material) implementation(libs.material)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.gson) implementation(libs.gson)

View File

@ -147,7 +147,7 @@ class JobFormFragment : Fragment() {
private fun scrapeUrl() { private fun scrapeUrl() {
val url = seekUrlInput.text?.toString()?.trim() ?: "" val url = seekUrlInput.text?.toString()?.trim() ?: ""
if (url.isEmpty()) { if (url.isEmpty()) {
seekUrlInput.error = "Enter a URL" seekUrlInput.error = getString(R.string.enter_url)
return return
} }
showLoading(true) showLoading(true)

View File

@ -14,27 +14,25 @@ import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.GetCredentialException
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import androidx.activity.viewModels
import com.example.resbuilder.R import com.example.resbuilder.R
import com.example.resbuilder.data.remote.ApiClient
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.tasks.Task
import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.CircularProgressIndicator import com.google.android.material.progressindicator.CircularProgressIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.security.SecureRandom import java.security.SecureRandom
import java.util.Base64 import java.util.Base64
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
private lateinit var googleSignInButton: MaterialButton private lateinit var googleSignInButton: MaterialButton
private lateinit var progress: CircularProgressIndicator private lateinit var progress: CircularProgressIndicator
private lateinit var credentialManager: CredentialManager private lateinit var credentialManager: CredentialManager
@ -45,10 +43,6 @@ class LoginActivity : AppCompatActivity() {
private val webClientId = "416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com" private val webClientId = "416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com"
private val androidClientId = "416332591622-41ioels091g0am6v5m1catf0tt69ohk4.apps.googleusercontent.com" private val androidClientId = "416332591622-41ioels091g0am6v5m1catf0tt69ohk4.apps.googleusercontent.com"
companion object {
private const val TAG = "LoginActivity"
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login) setContentView(R.layout.activity_login)
@ -62,25 +56,36 @@ class LoginActivity : AppCompatActivity() {
googleSignInLauncher = registerForActivityResult( googleSignInLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult() ActivityResultContracts.StartIntentSenderForResult()
) { result -> ) { result ->
Log.w(TAG, "Unexpected activity result: ${result.resultCode}")
showLoading(false) showLoading(false)
Toast.makeText(this, "Sign-in interrupted", Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.sign_in_interrupted, Toast.LENGTH_SHORT).show()
} }
observeAuthState()
showLoading(true) showLoading(true)
checkExistingAuth() viewModel.checkExistingAuth()
} }
private fun checkExistingAuth() { private fun observeAuthState() {
lifecycleScope.launch { lifecycleScope.launch {
try { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val user = withContext(Dispatchers.IO) { ApiClient.getMe() } viewModel.state.collect { state ->
showLoading(false) showLoading(state.loading)
if (user.authenticated) { state.error?.let {
Toast.makeText(this@LoginActivity, it, Toast.LENGTH_LONG).show()
viewModel.setLoading(false)
}
state.user?.let { user ->
if (state.authenticated) {
Toast.makeText(
this@LoginActivity,
getString(R.string.welcome_user, user.name ?: user.email),
Toast.LENGTH_SHORT
).show()
launchMain() launchMain()
} }
} catch (_: Exception) { }
showLoading(false) }
} }
} }
} }
@ -90,14 +95,8 @@ class LoginActivity : AppCompatActivity() {
googleSignInButton.isEnabled = !loading googleSignInButton.isEnabled = !loading
} }
private fun launchGoogleSignIn() { private fun launchGoogleSignIn() {
showLoading(true) showLoading(true)
lifecycleScope.launch {
Log.d(TAG, "Starting Credential Manager flow")
Log.d(TAG, "Web client ID: $webClientId")
Log.d(TAG, "Android client ID: $androidClientId")
val googleIdOption = GetGoogleIdOption.Builder() val googleIdOption = GetGoogleIdOption.Builder()
.setFilterByAuthorizedAccounts(false) .setFilterByAuthorizedAccounts(false)
.setServerClientId(webClientId) .setServerClientId(webClientId)
@ -109,23 +108,19 @@ class LoginActivity : AppCompatActivity() {
.addCredentialOption(googleIdOption) .addCredentialOption(googleIdOption)
.build() .build()
viewModel.viewModelScope.launch {
try { try {
Log.d(TAG, "Calling getCredential...")
val result = credentialManager.getCredential( val result = credentialManager.getCredential(
request = request, request = request,
context = this@LoginActivity context = this@LoginActivity
) )
Log.d(TAG, "Credential retrieved successfully")
handleCredentialResponse(result) handleCredentialResponse(result)
} catch (e: GetCredentialException) { } catch (e: GetCredentialException) {
Log.e(TAG, "Credential Manager failed: ${e.javaClass.simpleName}", e)
Log.e(TAG, "Error message: ${e.message}")
Log.e(TAG, "Error code: ${e.cause}")
showLoading(false) showLoading(false)
val errorMsg = when (e) { val errorMsg = when (e) {
is androidx.credentials.exceptions.NoCredentialException -> "No Google accounts found on device" is androidx.credentials.exceptions.NoCredentialException -> getString(R.string.no_google_accounts)
is androidx.credentials.exceptions.GetCredentialCancellationException -> "Sign-in cancelled" is androidx.credentials.exceptions.GetCredentialCancellationException -> getString(R.string.sign_in_cancelled)
else -> "Sign-in failed: ${e.message}" else -> getString(R.string.sign_in_failed, e.message)
} }
Toast.makeText(this@LoginActivity, errorMsg, Toast.LENGTH_LONG).show() Toast.makeText(this@LoginActivity, errorMsg, Toast.LENGTH_LONG).show()
} }
@ -141,56 +136,18 @@ class LoginActivity : AppCompatActivity() {
val idToken = googleIdTokenCredential.idToken val idToken = googleIdTokenCredential.idToken
if (idToken.isNullOrEmpty()) { if (idToken.isNullOrEmpty()) {
showLoading(false) showLoading(false)
Toast.makeText(this, "No ID token from Google", Toast.LENGTH_LONG).show() Toast.makeText(this, R.string.no_id_token, Toast.LENGTH_LONG).show()
return return
} }
exchangeToken(idToken) viewModel.exchangeToken(idToken)
} catch (e: GoogleIdTokenParsingException) { } catch (e: GoogleIdTokenParsingException) {
showLoading(false) showLoading(false)
Toast.makeText(this, "Invalid Google ID token: ${e.message}", Toast.LENGTH_LONG).show() Toast.makeText(this, getString(R.string.invalid_google_id_token, e.message), Toast.LENGTH_LONG).show()
} }
} }
else -> { else -> {
showLoading(false) showLoading(false)
Toast.makeText(this, "Unexpected credential type", Toast.LENGTH_LONG).show() Toast.makeText(this, R.string.unexpected_credential_type, Toast.LENGTH_LONG).show()
}
}
}
private fun handleSignInError(message: String) {
showLoading(false)
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
private fun exchangeToken(idToken: String) {
lifecycleScope.launch {
try {
val token = withContext(Dispatchers.IO) { ApiClient.exchangeGoogleToken(idToken) }
ApiClient.setToken(token)
val user = withContext(Dispatchers.IO) { ApiClient.getMe() }
showLoading(false)
if (user.authenticated) {
Toast.makeText(
this@LoginActivity,
"Welcome ${user.name ?: user.email}",
Toast.LENGTH_SHORT
).show()
launchMain()
} else {
Toast.makeText(
this@LoginActivity,
"Server rejected the token",
Toast.LENGTH_LONG
).show()
}
} catch (e: Exception) {
showLoading(false)
Log.e(TAG, "Token exchange failed", e)
Toast.makeText(
this@LoginActivity,
e.message ?: "Token exchange failed",
Toast.LENGTH_LONG
).show()
} }
} }
} }
@ -200,10 +157,6 @@ class LoginActivity : AppCompatActivity() {
finish() finish()
} }
override fun onDestroy() {
super.onDestroy()
}
private fun generateNonce(): String { private fun generateNonce(): String {
val secureRandom = SecureRandom() val secureRandom = SecureRandom()
val bytes = ByteArray(32) val bytes = ByteArray(32)

View File

@ -0,0 +1,62 @@
package com.example.resbuilder.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.resbuilder.data.model.User
import com.example.resbuilder.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class LoginUiState(
val loading: Boolean = false,
val authenticated: Boolean = false,
val user: User? = null,
val error: String? = null
)
class LoginViewModel : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
fun checkExistingAuth() {
viewModelScope.launch {
try {
val user = withContext(Dispatchers.IO) { ApiClient.getMe() }
_state.value = LoginUiState(loading = false, authenticated = user.authenticated, user = user)
} catch (_: Exception) {
_state.value = LoginUiState(loading = false, error = "check auth failed")
}
}
}
fun exchangeToken(idToken: String) {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
try {
val token = withContext(Dispatchers.IO) { ApiClient.exchangeGoogleToken(idToken) }
ApiClient.setToken(token)
val user = withContext(Dispatchers.IO) { ApiClient.getMe() }
_state.value = LoginUiState(
loading = false,
authenticated = user.authenticated,
user = user
)
} catch (e: Exception) {
_state.value = LoginUiState(loading = false, error = e.message ?: "Token exchange failed")
}
}
}
fun setLoading(loading: Boolean) {
_state.value = _state.value.copy(loading = loading)
}
fun consumeAuth() {
_state.value = _state.value.copy(authenticated = false)
}
}

View File

@ -1,8 +1,6 @@
package com.example.resbuilder.ui package com.example.resbuilder.ui
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -11,15 +9,18 @@ import android.widget.ListView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.example.resbuilder.R import com.example.resbuilder.R
import com.example.resbuilder.data.model.Job
import com.example.resbuilder.data.model.JobSummary import com.example.resbuilder.data.model.JobSummary
import com.example.resbuilder.data.remote.ApiClient
import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.progressindicator.LinearProgressIndicator
import kotlinx.coroutines.launch
class MyResumesFragment : Fragment() { class MyResumesFragment : Fragment() {
private val handler = Handler(Looper.getMainLooper()) private val viewModel: MyResumesViewModel by viewModels()
private lateinit var emptyView: TextView private lateinit var emptyView: TextView
private lateinit var progress: LinearProgressIndicator private lateinit var progress: LinearProgressIndicator
@ -37,71 +38,54 @@ class MyResumesFragment : Fragment() {
listView.setOnItemClickListener { _, _, position, _ -> listView.setOnItemClickListener { _, _, position, _ ->
val item = listView.adapter.getItem(position) as JobSummary val item = listView.adapter.getItem(position) as JobSummary
openJob(item.job_id) viewModel.openJob(item.job_id)
} }
loadJobs() observeState()
viewModel.loadJobs()
} }
private fun loadJobs() { private fun observeState() {
showLoading(true) viewLifecycleOwner.lifecycleScope.launch {
Thread { viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
try { viewModel.state.collect { state ->
val resp = ApiClient.getMyJobs() progress.visibility = if (state.loading) View.VISIBLE else View.GONE
handler.post {
showLoading(false) state.error?.let {
bindJobs(resp.jobs)
}
} catch (e: Exception) {
handler.post {
showLoading(false)
emptyView.text = getString(R.string.my_resumes_error) emptyView.text = getString(R.string.my_resumes_error)
emptyView.visibility = View.VISIBLE emptyView.visibility = View.VISIBLE
Toast.makeText(requireContext(), e.message ?: getString(R.string.my_resumes_error), Toast.LENGTH_LONG).show() Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show()
}
}
}.start()
} }
private fun bindJobs(jobs: List<JobSummary>) { if (!state.loading && state.jobs.isNotEmpty()) {
if (jobs.isEmpty()) { emptyView.visibility = View.GONE
listView.visibility = View.VISIBLE
listView.adapter = JobListAdapter(requireContext(), state.jobs)
} else if (!state.loading && state.error == null) {
emptyView.text = getString(R.string.my_resumes_empty) emptyView.text = getString(R.string.my_resumes_empty)
emptyView.visibility = View.VISIBLE emptyView.visibility = View.VISIBLE
listView.visibility = View.GONE listView.visibility = View.GONE
return
}
listView.visibility = View.VISIBLE
listView.adapter = JobListAdapter(requireContext(), jobs)
} }
private fun openJob(jobId: String) { state.openedJob?.let { job ->
showLoading(true) viewModel.consumeOpenedJob()
Thread { openJobResult(job)
try {
val job = ApiClient.getJob(jobId)
handler.post {
showLoading(false)
if (job == null) {
Toast.makeText(requireContext(), R.string.my_resumes_error, Toast.LENGTH_LONG).show()
return@post
} }
}
}
}
}
private fun openJobResult(job: com.example.resbuilder.data.model.Job) {
when (job.status.uppercase()) { when (job.status.uppercase()) {
"COMPLETED" -> { "COMPLETED" -> {
val type = job.type ?: JobFormFragment.TYPE_RESUME val type = job.type ?: JobFormFragment.TYPE_RESUME
replaceWith(ResultFragment.newInstance(job, type)) replaceWith(ResultFragment.newInstance(job, type))
} }
"FAILED" -> Toast.makeText(requireContext(), job.error ?: getString(R.string.status_failed), Toast.LENGTH_LONG).show() "FAILED" -> Toast.makeText(requireContext(), job.error ?: getString(R.string.status_failed), Toast.LENGTH_LONG).show()
else -> replaceWith(JobStatusFragment.newInstance(jobId, job.type ?: JobFormFragment.TYPE_RESUME)) else -> replaceWith(JobStatusFragment.newInstance(job.job_id ?: "", job.type ?: JobFormFragment.TYPE_RESUME))
} }
} }
} catch (e: Exception) {
handler.post {
showLoading(false)
Toast.makeText(requireContext(), e.message ?: getString(R.string.my_resumes_error), Toast.LENGTH_LONG).show()
}
}
}.start()
}
private fun replaceWith(fragment: Fragment) { private fun replaceWith(fragment: Fragment) {
requireActivity().supportFragmentManager.beginTransaction() requireActivity().supportFragmentManager.beginTransaction()
@ -110,15 +94,6 @@ class MyResumesFragment : Fragment() {
.commitAllowingStateLoss() .commitAllowingStateLoss()
} }
private fun showLoading(loading: Boolean) {
progress.visibility = if (loading) View.VISIBLE else View.GONE
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
}
private class JobListAdapter( private class JobListAdapter(
context: android.content.Context, context: android.content.Context,
private val jobs: List<JobSummary> private val jobs: List<JobSummary>

View File

@ -0,0 +1,54 @@
package com.example.resbuilder.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.resbuilder.data.model.Job
import com.example.resbuilder.data.model.JobSummary
import com.example.resbuilder.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class MyResumesUiState(
val loading: Boolean = false,
val jobs: List<JobSummary> = emptyList(),
val error: String? = null,
val openedJob: Job? = null
)
class MyResumesViewModel : ViewModel() {
private val _state = MutableStateFlow(MyResumesUiState())
val state: StateFlow<MyResumesUiState> = _state.asStateFlow()
fun loadJobs() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
try {
val jobs = withContext(Dispatchers.IO) { ApiClient.getMyJobs().jobs }
_state.value = _state.value.copy(loading = false, jobs = jobs)
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false, error = e.message ?: "load failed")
}
}
}
fun openJob(jobId: String) {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
try {
val job = withContext(Dispatchers.IO) { ApiClient.getJob(jobId) }
_state.value = _state.value.copy(loading = false, openedJob = job)
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false, error = e.message ?: "open failed")
}
}
}
fun consumeOpenedJob() {
_state.value = _state.value.copy(openedJob = null)
}
}

View File

@ -6,6 +6,14 @@
<string name="checking_auth">Checking authentication…</string> <string name="checking_auth">Checking authentication…</string>
<string name="welcome_subtitle">AI-powered resume and cover letter builder</string> <string name="welcome_subtitle">AI-powered resume and cover letter builder</string>
<string name="sign_in_terms">By signing in, you agree to our Terms of Service and Privacy Policy</string> <string name="sign_in_terms">By signing in, you agree to our Terms of Service and Privacy Policy</string>
<string name="sign_in_interrupted">Sign-in interrupted</string>
<string name="no_id_token">No ID token from Google</string>
<string name="invalid_google_id_token">Invalid Google ID token: %1$s</string>
<string name="unexpected_credential_type">Unexpected credential type</string>
<string name="no_google_accounts">No Google accounts found on device</string>
<string name="sign_in_cancelled">Sign-in cancelled</string>
<string name="sign_in_failed">Sign-in failed: %1$s</string>
<string name="welcome_user">Welcome %1$s</string>
<!-- Main --> <!-- Main -->
<string name="app_title">resBuilder</string> <string name="app_title">resBuilder</string>
@ -28,6 +36,7 @@
<string name="generate_cover_letter">Generate Cover Letter</string> <string name="generate_cover_letter">Generate Cover Letter</string>
<string name="please_log_in">Please log in to continue</string> <string name="please_log_in">Please log in to continue</string>
<string name="fields_required">Please fill in all fields</string> <string name="fields_required">Please fill in all fields</string>
<string name="enter_url">Enter a URL</string>
<!-- Status --> <!-- Status -->
<string name="status_queued">Queued</string> <string name="status_queued">Queued</string>

View File

@ -13,9 +13,15 @@ googlePlayServicesAuth = "20.7.0"
credentialManager = "1.5.0-beta01" credentialManager = "1.5.0-beta01"
googleid = "1.1.1" googleid = "1.1.1"
kotlinxCoroutines = "1.9.0" kotlinxCoroutines = "1.9.0"
lifecycleViewmodel = "2.8.7"
lifecycleRuntime = "2.8.7"
fragmentKtx = "1.8.5"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodel" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }