chore(cleanup): remove verified dead files (Tier 1 dead-file sweep)

Removes files confirmed unreferenced by symbol-level git-grep and proven safe by a
clean :app:compileDebugKotlin + compileDebugUnitTestKotlin (no main/test references).

Dead code (every top-level type had 0 external references):
- core/notifications/NotificationHelper.kt — dead duplicate of the live
  NotificationChannelSetup (which is what CloserApp/AppMessagingService actually call)
- core/notifications/NotificationPermissionHelper.kt — unused permission helper
- notifications/PartnerNotificationScheduler.kt — superseded by PartnerNotificationManager
- data/questions/QuestionJsonParser.kt — superseded by the Room/asset-DB path
- data/repository/FakeQuestionRepository.kt — orphaned fake, no test/DI consumer
- ui/questions/QuestionDetailViewModel.kt — superseded VM, no composable binds it
- domain/model/{Entitlement,InviteStatus,QuestionSessionStatus}.kt — unused models/enums
  (entitlement state is read as Firestore booleans, not this model)

Stray artifacts:
- gitleaks-current.json / gitleaks-history.json — sanitized scan output (history = []),
  referenced by no CI/config; regenerable
- 19 stale .gitkeep placeholders in directories that now hold real files

Deliberately KEPT (flagged but not dead): WheelHistoryScreen/ViewModel.kt (named
"WheelHistory" but declare the live, nav-wired GameHistoryScreen/GameHistoryViewModel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-09 00:10:40 -05:00
parent a8819650d2
commit 21392ec2f3
30 changed files with 0 additions and 617 deletions

View File

@ -1,70 +0,0 @@
package app.closer.core.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import app.closer.MainActivity
import app.closer.R
object NotificationHelper {
const val CHANNEL_REMINDERS = "reminders"
const val CHANNEL_PARTNER = "partner_activity"
const val CHANNEL_GAME_ACTIVITY = "game_activity"
fun createChannels(context: Context) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_REMINDERS,
"Daily reminders",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Gentle nudges for today's question and shared moments"
}
)
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_PARTNER,
"Partner activity",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "When your partner answers, reveals, or opens a conversation"
}
)
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_GAME_ACTIVITY,
"Games",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "When your partner starts or completes a game round"
}
)
}
fun show(context: Context, id: Int, channelId: String, title: String, body: String) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pending = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification_closer)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pending)
.build()
if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
NotificationManagerCompat.from(context).notify(id, notification)
}
}
}

View File

@ -1,69 +0,0 @@
package app.closer.core.notifications
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import javax.inject.Inject
import javax.inject.Singleton
/**
* Handles Android 13+ (API 33) POST_NOTIFICATIONS permission requests.
*
* Callers should:
* 1. Check [isGranted] before asking.
* 2. If [shouldShowRationale] is true, show an in-app rationale first.
* 3. Invoke [requestPermission] from an Activity context.
*/
@Singleton
class NotificationPermissionHelper @Inject constructor() {
val permission: String = Manifest.permission.POST_NOTIFICATIONS
fun isGranted(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
} else {
// Pre-Android 13 notifications are granted at install time.
true
}
}
fun shouldShowRationale(activity: Activity): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
} else {
false
}
}
fun requestPermission(activity: Activity, requestCode: Int = REQUEST_CODE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions(
activity,
arrayOf(permission),
requestCode
)
}
}
fun onRequestResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
): Boolean {
if (requestCode != REQUEST_CODE) return false
val index = permissions.indexOf(permission)
if (index < 0 || index >= grantResults.size) return false
return grantResults[index] == PackageManager.PERMISSION_GRANTED
}
companion object {
const val REQUEST_CODE = 9001
}
}

View File

@ -1,199 +0,0 @@
package app.closer.data.questions
import android.util.Log
import app.closer.BuildConfig
import app.closer.domain.model.*
import org.json.JSONArray
import org.json.JSONObject
/**
* Parses a JSON file containing question data in the v2 schema format.
*
* Expected JSON structure:
* {
* "category": { ... },
* "questions": [
* {
* "id": "...",
* "category_id": "...",
* "type": "written|single_choice|multi_choice|scale|this_or_that",
* "text": "...",
* "depth": 1-5,
* "access": "free|premium",
* "tags": [...],
* "answer_config": { ... }
* }
* ]
* }
*/
object QuestionJsonParser {
private const val TAG = "QuestionJsonParser"
data class ParsedQuestionBundle(
val category: QuestionCategory,
val questions: List<Question>
)
fun parseFromFile(jsonFilePath: String): ParsedQuestionBundle? {
val jsonContent = try {
java.io.File(jsonFilePath).readText()
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Failed to read JSON file: ${e.message}")
}
return null
}
return parseFromJsonString(jsonContent)
}
fun parseFromJsonString(jsonString: String): ParsedQuestionBundle? {
return try {
val root = JSONObject(jsonString)
val categoryObj = root.getJSONObject("category")
val questionsArray = root.getJSONArray("questions")
val category = parseCategory(categoryObj)
val questions = mutableListOf<Question>()
for (i in 0 until questionsArray.length()) {
val questionObj = questionsArray.getJSONObject(i)
questionObj?.let { q ->
questions.add(parseQuestion(q, category.id))
}
}
ParsedQuestionBundle(category, questions)
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Failed to parse JSON: ${e.message}")
}
null
}
}
private fun parseCategory(obj: JSONObject): QuestionCategory {
return QuestionCategory(
id = obj.optString("id", ""),
displayName = obj.optString("display_name", ""),
description = obj.optString("description", ""),
access = obj.optString("access", "free"),
iconName = obj.optString("icon_name", "message")
)
}
private fun parseQuestion(obj: JSONObject, categoryId: String): Question {
val type = obj.optString("type", "written")
val tags = obj.optJSONArray("tags")?.let { arr ->
mutableListOf<String>().apply {
for (i in 0 until arr.length()) {
add(arr.optString(i))
}
}.toList()
} ?: emptyList()
val answerConfig = parseAnswerConfig(obj, type)
return Question(
id = obj.optString("id", ""),
text = obj.optString("text", ""),
category = categoryId,
depthLevel = obj.optInt("depth", 1),
isPremium = obj.optString("access", "free") == "premium",
type = type,
tags = tags,
answerConfig = answerConfig
)
}
private fun parseAnswerConfig(obj: JSONObject, type: String): AnswerConfig? {
val answerConfigObj = obj.optJSONObject("answer_config")
if (answerConfigObj == null) return null
return when (type) {
"written" -> {
WrittenAnswerConfigImpl(
config = WrittenAnswerConfig(
minLength = answerConfigObj.optInt("min_length", 1),
maxLength = answerConfigObj.optInt("max_length", 1000),
placeholder = answerConfigObj.optString("placeholder", "Write your answer...")
)
)
}
"single_choice" -> {
val options = mutableListOf<ChoiceOption>()
val optionsArray = obj.optJSONArray("options")
if (optionsArray != null) {
for (i in 0 until optionsArray.length()) {
val opt = optionsArray.optJSONObject(i)
opt?.let { o ->
options.add(ChoiceOption(
id = o.optString("id", ""),
text = o.optString("text", "")
))
}
}
}
ChoiceAnswerConfigImpl(
config = ChoiceAnswerConfig(options = options.toList())
)
}
"scale" -> {
ScaleAnswerConfigImpl(
config = ScaleAnswerConfig(
minScale = answerConfigObj.optInt("min_scale", 1),
maxScale = answerConfigObj.optInt("max_scale", 5),
minLabel = answerConfigObj.optString("min_label", ""),
maxLabel = answerConfigObj.optString("max_label", ""),
scaleStep = answerConfigObj.optInt("scale_step", 1)
)
)
}
"this_or_that" -> {
val optionsArray = obj.optJSONArray("options")
if (optionsArray?.length() == 2) {
val optA = optionsArray.optJSONObject(0)
val optB = optionsArray.optJSONObject(1)
ThisOrThatAnswerConfigImpl(
config = ThisOrThatAnswerConfig(
optionA = ChoiceOption(
id = optA?.optString("id", "") ?: "",
text = optA?.optString("text", "") ?: ""
),
optionB = ChoiceOption(
id = optB?.optString("id", "") ?: "",
text = optB?.optString("text", "") ?: ""
)
)
)
} else {
null
}
}
"multi_choice" -> {
val options = mutableListOf<ChoiceOption>()
val optionsArray = obj.optJSONArray("options")
if (optionsArray != null) {
for (i in 0 until optionsArray.length()) {
val opt = optionsArray.optJSONObject(i)
opt?.let { o ->
options.add(ChoiceOption(
id = o.optString("id", ""),
text = o.optString("text", "")
))
}
}
}
ChoiceAnswerConfigImpl(
type = "multi_choice",
config = ChoiceAnswerConfig(
options = options.toList(),
maxSelections = answerConfigObj.optInt("max_selections", 0)
)
)
}
else -> null
}
}
}

View File

@ -1,26 +0,0 @@
package app.closer.data.repository
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
import app.closer.domain.repository.QuestionRepository
class FakeQuestionRepository : QuestionRepository {
override suspend fun getDailyQuestion(): Question? = null
override suspend fun getDailyQuestionForMode(modeTag: String, isPremium: Boolean): Question? = null
override suspend fun getQuestionById(id: String): Question? = null
override suspend fun getQuestionsByCategory(categoryId: String): List<Question> = emptyList()
override suspend fun getCategories(): List<QuestionCategory> = emptyList()
override suspend fun getCategoryById(id: String): QuestionCategory? = null
override suspend fun getQuestionCountByCategory(categoryId: String): Int = 0
override suspend fun getQuestionsByType(type: String): List<Question> = emptyList()
override suspend fun getQuestionsForPrediction(): List<Question> = emptyList()
override suspend fun getDesireSyncQuestions(sex: String): List<Question> = emptyList()
}

View File

@ -1,11 +0,0 @@
package app.closer.domain.model
data class Entitlement(
val id: String = "",
val userId: String = "",
val source: String = "",
val productId: String = "",
val isActive: Boolean = false,
val expiresAt: Long? = null,
val updatedAt: Long = System.currentTimeMillis()
)

View File

@ -1,8 +0,0 @@
package app.closer.domain.model
enum class InviteStatus {
PENDING,
ACCEPTED,
EXPIRED,
CANCELLED
}

View File

@ -1,6 +0,0 @@
package app.closer.domain.model
enum class QuestionSessionStatus {
ACTIVE,
COMPLETED
}

View File

@ -1,81 +0,0 @@
package app.closer.notifications
import javax.inject.Inject
/**
* Schedules partner-trigger notifications in response to partner actions.
*
* This class is intentionally synchronous: it validates quiet hours, opt-outs,
* and rate limits at the moment the event is received. Background deferral is
* handled by the backend FCM delivery schedule and [PartnerNotificationManager].
*/
class PartnerNotificationScheduler @Inject constructor(
private val notificationManager: PartnerNotificationManager
) {
/**
* The partner answered today's question; prompt the user to answer too.
*/
suspend fun onPartnerAnswered(coupleId: String, questionId: String? = null) {
notificationManager.show(
PartnerNotificationType.PARTNER_ANSWERED,
coupleId,
PartnerNotificationPayload(questionId = questionId)
)
}
/**
* Both partners have answered and the reveal is ready.
*/
suspend fun onRevealReady(coupleId: String, questionId: String) {
notificationManager.show(
PartnerNotificationType.REVEAL_READY,
coupleId,
PartnerNotificationPayload(questionId = questionId)
)
}
/**
* The partner started a game for the couple.
*/
suspend fun onPartnerStartedGame(coupleId: String, gameSessionId: String? = null) {
notificationManager.show(
PartnerNotificationType.PARTNER_STARTED_GAME,
coupleId,
PartnerNotificationPayload(gameSessionId = gameSessionId)
)
}
/**
* The partner completed their side of a two-player game.
*/
suspend fun onPartnerCompletedPart(coupleId: String, gameSessionId: String? = null) {
notificationManager.show(
PartnerNotificationType.PARTNER_COMPLETED_PART,
coupleId,
PartnerNotificationPayload(gameSessionId = gameSessionId)
)
}
/**
* A daily connection challenge is waiting for the couple.
*/
suspend fun onChallengeWaiting(coupleId: String, challengeId: String? = null) {
notificationManager.show(
PartnerNotificationType.CHALLENGE_WAITING,
coupleId,
PartnerNotificationPayload(challengeId = challengeId)
)
}
/**
* A previously sealed memory capsule reached its unlock date.
*/
suspend fun onCapsuleUnlocked(coupleId: String, capsuleId: String? = null) {
notificationManager.show(
PartnerNotificationType.CAPSULE_UNLOCKED,
coupleId,
PartnerNotificationPayload(capsuleId = capsuleId)
)
}
}

View File

@ -1,104 +0,0 @@
package app.closer.ui.questions
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository
import app.closer.ui.components.TextInputLimits
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class QuestionDetailViewModel @Inject constructor(
private val repository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val questionId: String = savedStateHandle["questionId"] ?: ""
private val _uiState = MutableStateFlow(LocalQuestionUiState())
val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow()
init {
loadQuestion()
}
fun loadQuestion() {
viewModelScope.launch {
_uiState.value = LocalQuestionUiState(isLoading = true)
try {
val question = repository.getQuestionById(questionId)
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
_uiState.value = LocalQuestionUiState(
isLoading = false,
question = question,
pendingScaleValue = defaultScaleValue(question)
).withLocalAnswer(answer)
} catch (e: Exception) {
_uiState.value = LocalQuestionUiState(
isLoading = false,
error = e.message ?: "Could not load this question."
)
}
}
}
fun updateWrittenText(text: String) {
_uiState.update { it.copy(pendingWrittenText = text.take(MAX_ANSWER_LENGTH), submitted = false) }
}
fun toggleOption(optionId: String) {
_uiState.update { state ->
val question = state.question ?: return@update state
val updated = if (question.type == "multi_choice") {
val maxSel = (question.answerConfig as? ChoiceAnswerConfigImpl)?.config?.maxSelections ?: 0
when {
optionId in state.pendingSelectedOptionIds -> state.pendingSelectedOptionIds - optionId
maxSel == 0 || state.pendingSelectedOptionIds.size < maxSel -> state.pendingSelectedOptionIds + optionId
else -> state.pendingSelectedOptionIds
}
} else {
listOf(optionId)
}
state.copy(pendingSelectedOptionIds = updated, submitted = false)
}
}
fun updateScale(value: Int) {
_uiState.update { it.copy(pendingScaleValue = value, submitted = false) }
}
fun submitAnswer() {
val state = _uiState.value
val question = state.question ?: return
if (!canSubmit(state)) return
viewModelScope.launch {
localAnswerRepository.saveAnswer(state.toLocalAnswer(question))
_uiState.update { it.copy(submitted = true) }
}
}
fun canSubmit(): Boolean = canSubmit(_uiState.value)
companion object {
const val MAX_ANSWER_LENGTH = TextInputLimits.WRITTEN_ANSWER
}
private fun canSubmit(state: LocalQuestionUiState): Boolean {
val question = state.question ?: return false
return when (question.type) {
"written" -> state.pendingWrittenText.isNotBlank()
"single_choice", "multi_choice", "this_or_that" -> state.pendingSelectedOptionIds.isNotEmpty()
"scale" -> true
else -> false
}
}
}

View File

@ -1,42 +0,0 @@
[
{
"Description": "Generic API Key",
"StartLine": 31,
"EndLine": 31,
"StartColumn": 21,
"EndColumn": 67,
"Match": "key\": \"REDACTED\"",
"Secret": "REDACTED",
"File": "app/google-services.json",
"SymlinkFile": "",
"Commit": "",
"Entropy": 4.7851515,
"Author": "",
"Email": "",
"Date": "",
"Message": "",
"Tags": [],
"RuleID": "generic-api-key",
"Fingerprint": "app/google-services.json:generic-api-key:31"
},
{
"Description": "Generic API Key",
"StartLine": 68,
"EndLine": 68,
"StartColumn": 21,
"EndColumn": 67,
"Match": "key\": \"REDACTED\"",
"Secret": "REDACTED",
"File": "app/google-services.json",
"SymlinkFile": "",
"Commit": "",
"Entropy": 4.7851515,
"Author": "",
"Email": "",
"Date": "",
"Message": "",
"Tags": [],
"RuleID": "generic-api-key",
"Fingerprint": "app/google-services.json:generic-api-key:68"
}
]

View File

@ -1 +0,0 @@
[]