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 {
release {
isMinifyEnabled = true
optimization {
enable = false
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
@ -36,6 +41,9 @@ android {
dependencies {
implementation(libs.androidx.appcompat)
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.okhttp)
implementation(libs.gson)

View File

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

View File

@ -14,27 +14,25 @@ import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialException
import androidx.lifecycle.Lifecycle
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.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.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.CircularProgressIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.security.SecureRandom
import java.util.Base64
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
private lateinit var googleSignInButton: MaterialButton
private lateinit var progress: CircularProgressIndicator
private lateinit var credentialManager: CredentialManager
@ -45,10 +43,6 @@ class LoginActivity : AppCompatActivity() {
private val webClientId = "416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com"
private val androidClientId = "416332591622-41ioels091g0am6v5m1catf0tt69ohk4.apps.googleusercontent.com"
companion object {
private const val TAG = "LoginActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
@ -62,25 +56,36 @@ class LoginActivity : AppCompatActivity() {
googleSignInLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
Log.w(TAG, "Unexpected activity result: ${result.resultCode}")
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)
checkExistingAuth()
viewModel.checkExistingAuth()
}
private fun checkExistingAuth() {
private fun observeAuthState() {
lifecycleScope.launch {
try {
val user = withContext(Dispatchers.IO) { ApiClient.getMe() }
showLoading(false)
if (user.authenticated) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
showLoading(state.loading)
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()
}
} catch (_: Exception) {
showLoading(false)
}
}
}
}
}
@ -90,14 +95,8 @@ class LoginActivity : AppCompatActivity() {
googleSignInButton.isEnabled = !loading
}
private fun launchGoogleSignIn() {
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()
.setFilterByAuthorizedAccounts(false)
.setServerClientId(webClientId)
@ -109,23 +108,19 @@ class LoginActivity : AppCompatActivity() {
.addCredentialOption(googleIdOption)
.build()
viewModel.viewModelScope.launch {
try {
Log.d(TAG, "Calling getCredential...")
val result = credentialManager.getCredential(
request = request,
context = this@LoginActivity
)
Log.d(TAG, "Credential retrieved successfully")
handleCredentialResponse(result)
} 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)
val errorMsg = when (e) {
is androidx.credentials.exceptions.NoCredentialException -> "No Google accounts found on device"
is androidx.credentials.exceptions.GetCredentialCancellationException -> "Sign-in cancelled"
else -> "Sign-in failed: ${e.message}"
is androidx.credentials.exceptions.NoCredentialException -> getString(R.string.no_google_accounts)
is androidx.credentials.exceptions.GetCredentialCancellationException -> getString(R.string.sign_in_cancelled)
else -> getString(R.string.sign_in_failed, e.message)
}
Toast.makeText(this@LoginActivity, errorMsg, Toast.LENGTH_LONG).show()
}
@ -141,56 +136,18 @@ class LoginActivity : AppCompatActivity() {
val idToken = googleIdTokenCredential.idToken
if (idToken.isNullOrEmpty()) {
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
}
exchangeToken(idToken)
viewModel.exchangeToken(idToken)
} catch (e: GoogleIdTokenParsingException) {
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 -> {
showLoading(false)
Toast.makeText(this, "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()
Toast.makeText(this, R.string.unexpected_credential_type, Toast.LENGTH_LONG).show()
}
}
}
@ -200,10 +157,6 @@ class LoginActivity : AppCompatActivity() {
finish()
}
override fun onDestroy() {
super.onDestroy()
}
private fun generateNonce(): String {
val secureRandom = SecureRandom()
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
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -11,15 +9,18 @@ import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
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.data.model.Job
import com.example.resbuilder.data.model.JobSummary
import com.example.resbuilder.data.remote.ApiClient
import com.google.android.material.progressindicator.LinearProgressIndicator
import kotlinx.coroutines.launch
class MyResumesFragment : Fragment() {
private val handler = Handler(Looper.getMainLooper())
private val viewModel: MyResumesViewModel by viewModels()
private lateinit var emptyView: TextView
private lateinit var progress: LinearProgressIndicator
@ -37,71 +38,54 @@ class MyResumesFragment : Fragment() {
listView.setOnItemClickListener { _, _, position, _ ->
val item = listView.adapter.getItem(position) as JobSummary
openJob(item.job_id)
viewModel.openJob(item.job_id)
}
loadJobs()
observeState()
viewModel.loadJobs()
}
private fun loadJobs() {
showLoading(true)
Thread {
try {
val resp = ApiClient.getMyJobs()
handler.post {
showLoading(false)
bindJobs(resp.jobs)
}
} catch (e: Exception) {
handler.post {
showLoading(false)
private fun observeState() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
progress.visibility = if (state.loading) View.VISIBLE else View.GONE
state.error?.let {
emptyView.text = getString(R.string.my_resumes_error)
emptyView.visibility = View.VISIBLE
Toast.makeText(requireContext(), e.message ?: getString(R.string.my_resumes_error), Toast.LENGTH_LONG).show()
}
}
}.start()
Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show()
}
private fun bindJobs(jobs: List<JobSummary>) {
if (jobs.isEmpty()) {
if (!state.loading && state.jobs.isNotEmpty()) {
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.visibility = View.VISIBLE
listView.visibility = View.GONE
return
}
listView.visibility = View.VISIBLE
listView.adapter = JobListAdapter(requireContext(), jobs)
}
private fun openJob(jobId: String) {
showLoading(true)
Thread {
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
state.openedJob?.let { job ->
viewModel.consumeOpenedJob()
openJobResult(job)
}
}
}
}
}
private fun openJobResult(job: com.example.resbuilder.data.model.Job) {
when (job.status.uppercase()) {
"COMPLETED" -> {
val type = job.type ?: JobFormFragment.TYPE_RESUME
replaceWith(ResultFragment.newInstance(job, type))
}
"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) {
requireActivity().supportFragmentManager.beginTransaction()
@ -110,15 +94,6 @@ class MyResumesFragment : Fragment() {
.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(
context: android.content.Context,
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="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_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 -->
<string name="app_title">resBuilder</string>
@ -28,6 +36,7 @@
<string name="generate_cover_letter">Generate Cover Letter</string>
<string name="please_log_in">Please log in to continue</string>
<string name="fields_required">Please fill in all fields</string>
<string name="enter_url">Enter a URL</string>
<!-- Status -->
<string name="status_queued">Queued</string>

View File

@ -13,9 +13,15 @@ googlePlayServicesAuth = "20.7.0"
credentialManager = "1.5.0-beta01"
googleid = "1.1.1"
kotlinxCoroutines = "1.9.0"
lifecycleViewmodel = "2.8.7"
lifecycleRuntime = "2.8.7"
fragmentKtx = "1.8.5"
[libraries]
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" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }