Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,17 @@ one call (`sendEmailVerification()` after sign-up, then gating the feed on
project are test data, and a mandatory confirmation step would put a mailbox between a
reviewer and the running app for no benefit at this scale.

**Registration writes to two systems, and only one of them can be rolled back.** An auth
account is created first, then a Firestore profile. If the profile write fails the account
is deleted again — but that deletion can fail too, and then the account exists with no
profile: the person is registered as far as Firebase is concerned, cannot sign up again
with that address, and cannot use the app, because every screen needs the profile. There
is no transaction spanning both systems on this plan, so the second line of defence is a
recovery screen: a session with no profile is routed to
[`CompleteProfileActivity`](app/src/main/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileActivity.kt),
which writes the missing document from the session's own uid and address. One such
account existed in the live project and was recovered through it.

**Deleting a note reports failure with a `Toast`.** Unlike a failed read, the user can
repeat a failed delete by tapping the button again, so the toast does not leave them
stuck — but the retry is still theirs to figure out. Offering it in the snackbar would
Expand Down Expand Up @@ -440,7 +451,7 @@ emulator on API 24+.
./gradlew testDebugUnitTest
```

126 JVM tests, no emulator and no network. They cover the layers where a bug would be
139 JVM tests, no emulator and no network. They cover the layers where a bug would be
invisible rather than loud: the scoring arithmetic, the decision logic in every
ViewModel, the list diffing that decides which rows get redrawn, and — for the feature
the app is built around — what the screen actually shows.
Expand All @@ -453,11 +464,13 @@ the app is built around — what the screen actually shows.
| `NoteDetailViewModelTest` | Metadata and file content loading as separate states, no file read for a note without one, and the rating rules (own note, missing session, deleted note). |
| `EditNoteViewModelTest` | Ownership and session handling on load and save. |
| `ProfileViewModelTest` | Total points summed from the user's own notes, and note deletion. |
| `LoginViewModelTest`, `RegisterViewModelTest` | State transitions, and which of registration's two steps — auth account or profile document — failed. |
| `LoginViewModelTest`, `RegisterViewModelTest` | State transitions, which of registration's two steps — auth account or profile document — failed, and that a failed profile write rolls the auth account back. Including the case where the rollback itself fails: the user still sees the profile error, because from where they stand the result is the same. |
| `CompleteProfileViewModelTest` | The recovery path for an account that has no profile: the write takes its uid and address from the session rather than the request, a missing session writes nothing, a double tap saves one profile, and a failed write leaves the screen usable. |
| `MainViewModelTest` | Session routing, including the three-way decision on startup: signed out, signed in with a profile, and signed in without one. Also that a profile which cannot be *read* is not treated as one that does not exist — a dropped connection must not push someone into a screen where they cannot write either. |
| `AuthErrorMessagesTest` | That each Firebase auth error code reaches its own Turkish message rather than the catch-all. The mapping used to match on Firebase's English error *sentence*; those strings are not API, they changed, and every failure quietly collapsed into "Tekrar deneyin." with no test to notice. `ERROR_INVALID_CREDENTIAL` is the sharp case — with email enumeration protection on, it is what both a wrong password and an unknown account now return. |
| `PasswordToggleTest` | That the password field starts masked and its toggle icon reports state, not action: unchecked (crossed-out eye) while hidden, checked (open eye) while visible. The icon is a custom selector wired through `endIconDrawable`, so dropping that one attribute leaves a working button with the inverted icon — invisible to every other test. |
| `LeaderboardViewModelTest` | Empty vs. content, and the department scoping: which department the ranking is asked for, and the three cases — no session, no profile, blank department — where the query must not be built at all. |
| `MainViewModelTest`, `UploaderNameTest` | Session routing, and the uploader-name masking shared by three screens. |
| `UploaderNameTest` | The uploader-name derivation, which the rule mirrors with `email.split('@')[0]`. |
| `PostAdapterTest`, `LeaderboardAdapterTest` | Which rows a `DiffUtil` pass marks as changed. The leaderboard case is the sharp one: a note's medal depends on its rank, so a note that swapped places without changing must still be rebound, or the gold medal stays on the row it left. |
| `FeedScreenTest` | The contribution gate as a user meets it — the lock and its way out, the unlocked feed and its notes. See [Screen tests](#screen-tests). |

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
<activity
android:name=".ui.auth.RegisterActivity"
android:exported="false" />
<activity
android:name=".ui.auth.CompleteProfileActivity"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,20 @@ interface AuthRepository {

suspend fun register(email: String, password: String): AuthenticatedUser

/**
* Az önce oluşturulmuş hesabı geri alır.
*
* Kayıt iki ayrı sisteme yazıyor — önce Auth hesabı, sonra Firestore profili —
* ve ikincisi düşerse geriye kimsenin kullanamadığı bir hesap kalıyor: kişi
* Firebase'de kayıtlı görünüyor, aynı adresle yeniden kayıt olamıyor, ama
* profili olmadığı için uygulamanın çoğu kapalı. Bu çağrı o durumu telafi
* ediyor.
*
* Telafi garantili değil — silme çağrısının kendisi de düşebilir — o yüzden
* ikinci bir savunma var: profili olmayan bir oturum açılışta profil tamamlama
* ekranına gidiyor.
*/
suspend fun deleteCurrentUser()

fun signOut()
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class FirebaseAuthRepository(
?: throw IllegalStateException("Registered user is missing")
}

override suspend fun deleteCurrentUser() {
auth.currentUser?.delete()?.await()
}

override fun signOut() {
auth.signOut()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package duygu.yilmaz.campusnote.ui.auth

import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import duygu.yilmaz.campusnote.R
import duygu.yilmaz.campusnote.databinding.ActivityCompleteProfileBinding
import duygu.yilmaz.campusnote.ui.main.MainActivity

/**
* Auth hesabı olup Firestore profili olmayan oturumun çıkış yolu.
*
* Buraya kayıt akışından gelinmiyor; [duygu.yilmaz.campusnote.ui.main.MainActivity]
* oturumu çözerken profilin olmadığını görürse yönlendiriyor. Sebebi için bkz.
* [CompleteProfileViewModel].
*
* Ekran yalnızca bölüm soruyor: uid ve e-posta zaten oturumda ve güvenlik kuralı
* e-postayı token'la karşılaştırdığı için kullanıcıya sorulacak bir şey değil.
*/
class CompleteProfileActivity : AppCompatActivity() {

private val viewModel: CompleteProfileViewModel by viewModels()
private lateinit var binding: ActivityCompleteProfileBinding

/** 0. sıra seçim uyarısıdır; [selectedDepartment] bu indeksi geçersiz sayar. */
private val departments: List<String> by lazy {
listOf(getString(R.string.department_prompt)) +
resources.getStringArray(R.array.departments)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCompleteProfileBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.tvEmail.text = viewModel.currentEmail()
setupSpinner()
observeState()

binding.btnSave.setOnClickListener {
val department = selectedDepartment()
if (department == null) {
Toast.makeText(this, R.string.error_department_not_selected, Toast.LENGTH_SHORT)
.show()
return@setOnClickListener
}
viewModel.completeProfile(department)
}

binding.btnSignOut.setOnClickListener {
viewModel.signOut()
goToLogin()
}
}

private fun setupSpinner() {
val adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
departments
)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spDepartment.adapter = adapter
}

private fun selectedDepartment(): String? =
binding.spDepartment.selectedItemPosition
.takeIf { it != DEPARTMENT_PROMPT_INDEX }
?.let { departments[it] }

private fun observeState() {
viewModel.uiState.observe(this) { state ->
when (state) {
CompleteProfileUiState.Idle -> showLoading(false)
CompleteProfileUiState.Loading -> showLoading(true)

CompleteProfileUiState.Success -> {
showLoading(false)
Toast.makeText(this, R.string.complete_profile_saved, Toast.LENGTH_SHORT)
.show()
startActivity(
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
)
}

// Oturum bu ekran açıkken kaybolduysa yazacak bir uid yok; girişe dön.
CompleteProfileUiState.MissingSession -> {
showLoading(false)
Toast.makeText(this, R.string.error_missing_session, Toast.LENGTH_SHORT).show()
goToLogin()
}

is CompleteProfileUiState.Error -> {
showLoading(false)
Toast.makeText(
this,
getString(
R.string.register_error_profile_save,
state.exception.message.orEmpty()
),
Toast.LENGTH_LONG
).show()
}
}
}
}

private fun showLoading(show: Boolean) {
binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE
binding.btnSave.isEnabled = !show
binding.btnSignOut.isEnabled = !show
}

private fun goToLogin() {
startActivity(
Intent(this, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
)
}

private companion object {
const val DEPARTMENT_PROMPT_INDEX = 0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package duygu.yilmaz.campusnote.ui.auth

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import duygu.yilmaz.campusnote.data.model.UserProfile
import duygu.yilmaz.campusnote.data.repository.AuthRepository
import duygu.yilmaz.campusnote.data.repository.FirebaseAuthRepository
import duygu.yilmaz.campusnote.data.repository.FirebaseUserRepository
import duygu.yilmaz.campusnote.data.repository.UserRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch

/**
* Auth hesabı olup Firestore profili olmayan oturumu toparlar.
*
* Kayıt iki ayrı sisteme yazıyor. [RegisterViewModel] profil yazılamadığında Auth
* hesabını geri alıyor, ama o telafi de düşebilir — ve düştüğünde geriye kimsenin
* çıkamadığı bir hesap kalıyor: kişi Firebase'de var, aynı adresle yeniden kayıt
* olamıyor, profili olmadığı için feed kilitli ve yükleme çalışmıyor.
*
* Buradaki ekran o hesabın tek çıkışı. Yeni bir hesap açmıyor; var olan oturumun
* uid'si ve e-postasıyla eksik profili yazıyor. E-posta oturumdan alınıyor çünkü
* güvenlik kuralı zaten `request.auth.token.email` ile karşılaştırıyor — kullanıcıya
* sorulacak tek şey bölüm.
*/
class CompleteProfileViewModel(
private val authRepository: AuthRepository = FirebaseAuthRepository(),
private val userRepository: UserRepository = FirebaseUserRepository()
) : ViewModel() {

private val _uiState = MutableLiveData<CompleteProfileUiState>(CompleteProfileUiState.Idle)
val uiState: LiveData<CompleteProfileUiState> = _uiState

/** Oturumun e-postası; ekranda "hangi hesabı tamamlıyorsun" olarak gösteriliyor. */
fun currentEmail(): String = authRepository.currentUser()?.email.orEmpty()

fun completeProfile(department: String) {
if (_uiState.value == CompleteProfileUiState.Loading) return

val user = authRepository.currentUser()
if (user == null) {
_uiState.value = CompleteProfileUiState.MissingSession
return
}

_uiState.value = CompleteProfileUiState.Loading
viewModelScope.launch {
_uiState.value = try {
userRepository.saveUser(
UserProfile(
id = user.uid,
email = user.email,
department = department
)
)
CompleteProfileUiState.Success
} catch (cancellation: CancellationException) {
throw cancellation
} catch (exception: Exception) {
CompleteProfileUiState.Error(exception)
}
}
}

/**
* Tamamlamadan vazgeçen kullanıcıyı oturumdan çıkarır.
*
* Profilsiz bir oturumla uygulamada dolaşmanın anlamı yok — feed kilitli, yükleme
* bölüm istiyor, profil ekranı boş. Çıkış, giriş ekranına dönüp başka bir hesapla
* devam etmenin yolu.
*/
fun signOut() {
authRepository.signOut()
}
}

sealed interface CompleteProfileUiState {
data object Idle : CompleteProfileUiState
data object Loading : CompleteProfileUiState
data object Success : CompleteProfileUiState

/** Oturum bu ekran açıkken kaybolduysa: yazacak bir uid yok. */
data object MissingSession : CompleteProfileUiState

data class Error(val exception: Exception) : CompleteProfileUiState
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class RegisterViewModel(
} catch (cancellation: CancellationException) {
throw cancellation
} catch (exception: Exception) {
rollBackAccount()
AuthUiState.Error(
exception = exception,
stage = AuthFailureStage.USER_PROFILE
Expand All @@ -57,6 +58,29 @@ class RegisterViewModel(
}
}

/**
* Profil yazılamadığında az önce açılan Auth hesabını geri alır.
*
* Kayıt iki sisteme yazıyor ve arada yarıda kalabiliyor. Geri almazsak kişi
* Firebase'de var olur, aynı adresle yeniden kayıt olamaz ve profili olmadığı
* için uygulamayı da kullanamaz — kimsenin çıkamadığı bir durum.
*
* Silmenin kendisi de düşebilir; o zaman kullanıcıya gösterilecek mesaj
* değişmiyor, çünkü kullanıcı açısından sonuç aynı: kayıt olmadı. Fark, hesabın
* ortada kalmasında ve onu [duygu.yilmaz.campusnote.ui.profile.CompleteProfileActivity]
* topluyor. Bu yüzden burada hata yutuluyor — telafi başarısız olduğu için
* asıl hatanın üstünü örtmenin anlamı yok.
*/
private suspend fun rollBackAccount() {
try {
authRepository.deleteCurrentUser()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
// Bilinçli olarak yutuluyor; yukarıdaki açıklamaya bakın.
}
}

fun resetState() {
_uiState.value = AuthUiState.Idle
}
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/duygu/yilmaz/campusnote/ui/main/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import duygu.yilmaz.campusnote.R
import duygu.yilmaz.campusnote.databinding.ActivityMainBinding
import duygu.yilmaz.campusnote.ui.auth.CompleteProfileActivity
import duygu.yilmaz.campusnote.ui.auth.LoginActivity
import duygu.yilmaz.campusnote.ui.feed.FeedFragment
import duygu.yilmaz.campusnote.ui.leaderboard.LeaderboardFragment
Expand All @@ -26,6 +27,15 @@ class MainActivity : AppCompatActivity() {
return
}

// Oturum var ama profili olmayabilir; bkz. MainViewModel.resolveRoute.
viewModel.route.observe(this) { route ->
if (route == MainRoute.NeedsProfile) {
startActivity(Intent(this, CompleteProfileActivity::class.java))
finish()
}
}
viewModel.resolveRoute()

binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

Expand Down
Loading
Loading