diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bed2ed5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,58 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Kotlin files +[*.{kt,kts}] +indent_style = space +indent_size = 4 +max_line_length = 120 +continuation_indent_size = 4 +ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ +ij_kotlin_name_count_to_use_star_import = 5 +ij_kotlin_name_count_to_use_star_import_for_members = 3 +ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.** + +# Java files +[*.java] +indent_style = space +indent_size = 4 +max_line_length = 120 + +# XML files (layouts, manifests, resources) +[*.xml] +indent_style = space +indent_size = 4 + +# Gradle files +[*.gradle,*.gradle.kts] +indent_style = space +indent_size = 4 + +# JSON files +[*.json] +indent_style = space +indent_size = 2 + +# Markdown files +[*.md] +trim_trailing_whitespace = false +max_line_length = off + +# YAML files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# Properties files +[*.properties] +indent_style = space +indent_size = 4 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..3d67833 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,1910 @@ +# Contributing to ShadowCheckMobile + +Welcome to ShadowCheckMobile! This guide will help you understand our codebase architecture, development patterns, and contribution workflow. + +## Important Context: Dual Architecture + +**ShadowCheckMobile contains two architectural patterns:** + +1. **Legacy Architecture** - Original code from APK recovery, now cleaned up and functional + - Direct ViewModel-to-Database access + - Manual singleton database instances + - Monolithic ViewModels + - Located primarily in older packages + +2. **Modern Clean Architecture** - New development standard (USE THIS) + - Hilt dependency injection + - Repository pattern with domain/data/presentation layers + - Single-responsibility use cases + - Feature-specific ViewModels + - All new code follows this pattern + +**For all new development, use the Modern Clean Architecture.** If you need to modify legacy code, consider refactoring it to the new pattern if feasible. + +--- + +## Code Standards + +### Dependency Injection (Non-Negotiable) + +**All dependencies must be injected via Hilt.** Never use manual singletons or service locators. + +❌ **DON'T:** +```kotlin +val database = ShadowCheckDatabase.getInstance(context) +val dao = database.wifiNetworkDao() +``` + +✅ **DO:** +```kotlin +@HiltViewModel +class WifiViewModel @Inject constructor( + private val getAllWifiNetworks: GetAllWifiNetworksUseCase +) : ViewModel() +``` + +### Clean Architecture Layers + +Our architecture is organized into three distinct layers: + +**1. Domain Layer** (Business Logic - No Android Dependencies) + - **Repository Interfaces**: Contracts defining data operations + - **Use Cases**: Single-responsibility business operations + - **Domain Models**: Pure Kotlin data classes representing business entities + - Most stable layer - changes rarely + +**2. Data Layer** (Data Access & External APIs) + - **Repository Implementations**: Concrete data access logic + - **DAOs**: Room database access + - **API Services**: Retrofit interfaces + - **DTOs**: Data transfer objects for network responses + - **Database Entities**: Room @Entity classes + - Contains all Android-specific data access (Room, Retrofit) + +**3. Presentation Layer** (UI & State Management) + - **ViewModels**: UI state management with StateFlow + - **Composable Screens**: UI components (read-only, minimal logic) + - **UI State Classes**: Sealed classes for screen states + - Depends only on domain layer (uses use cases) + +### Data Flow Rules + +**Repositories:** +- Return `Flow` for read operations (reactive, continuous updates) +- Use `suspend` functions for write operations (one-time coroutine operations) +- Always run on `Dispatchers.IO` using `.flowOn()` or `withContext()` + +```kotlin +class WifiNetworkRepositoryImpl @Inject constructor( + private val dao: WifiNetworkDao, + @IoDispatcher private val dispatcher: CoroutineDispatcher +) : WifiNetworkRepository { + + // Read: Return Flow for reactive updates + override fun getAllNetworks(): Flow> { + return dao.getAllNetworks() + .map { entities -> entities.map { it.toDomainModel() } } + .flowOn(dispatcher) + } + + // Write: Suspend function for one-time operation + override suspend fun insertNetwork(network: WifiNetwork): Long { + return withContext(dispatcher) { + dao.insert(network.toEntity()) + } + } +} +``` + +**Use Cases:** +- Contain business logic (filtering, sorting, validation, transformation) +- Orchestrate repository calls +- Keep ViewModels thin and focused on UI state + +```kotlin +@Singleton +class GetAllWifiNetworksUseCase @Inject constructor( + private val repository: WifiNetworkRepository +) { + operator fun invoke(): Flow> { + return repository.getAllNetworks() + .map { networks -> networks.filter { it.ssid.isNotBlank() } } + .map { networks -> networks.sortedByDescending { it.timestamp } } + } +} +``` + +**ViewModels:** +- Orchestrate use cases +- Manage UI state with `StateFlow` or `MutableStateFlow` +- Never access DAOs directly +- Use `viewModelScope` for coroutines + +```kotlin +@HiltViewModel +class WifiViewModel @Inject constructor( + private val getAllWifiNetworks: GetAllWifiNetworksUseCase +) : ViewModel() { + + private val _networks = MutableStateFlow>(emptyList()) + val networks: StateFlow> = _networks.asStateFlow() + + init { + getAllWifiNetworks() + .onEach { _networks.value = it } + .launchIn(viewModelScope) + } +} +``` + +**All Async Operations:** +- Use Kotlin coroutines with `viewModelScope` or `lifecycleScope` +- Never use `.get()` on LiveData or block threads +- Prefer Flow over callbacks + +### Code Style + +**Formatting:** +- Max line length: 120 characters +- Indentation: 4 spaces (Kotlin, Java, XML) +- Follow `.editorconfig` settings + +**Naming:** +- Use meaningful variable names +- Avoid single letters except loop counters (`i`, `j`, `k`) +- No decompilation artifacts (`var1`, `var2`) - rename immediately + +**Immutability:** +- Prefer `val` over `var` +- Use `data class` for immutable data structures +- Use `copy()` for modifications + +```kotlin +// ✅ Good +val network = WifiNetwork(ssid = "Home", bssid = "AA:BB:CC:DD:EE:FF") +val updated = network.copy(rssi = -50) + +// ❌ Bad +var network = WifiNetwork(ssid = "Home", bssid = "AA:BB:CC:DD:EE:FF") +network.rssi = -50 // Mutation +``` + +### API Key Management + +**Never hardcode API keys.** Store in `EncryptedSharedPreferences`, retrieve at call site. + +❌ **DON'T:** +```kotlin +@Provides +fun provideApiKey(): String = "sk_live_abc123" // NEVER! +``` + +✅ **DO:** +```kotlin +// Store securely +val encryptedPrefs = EncryptedSharedPreferences.create(...) +encryptedPrefs.edit().putString("wigle_api_key", apiKey).apply() + +// Retrieve at call site +val apiKey = encryptedPrefs.getString("wigle_api_key", null) ?: return +repository.syncWithWiGLE(apiKey) +``` + +--- + +## Before Submitting a Pull Request + +### 1. Code Quality + +- [ ] **Run detekt:** `./gradlew detekt` + - All issues resolved + - No warnings suppressed without justification + - Max complexity thresholds respected + +- [ ] **Run lint:** Android Studio → Analyze → Inspect Code + - No Android lint errors + - No unused imports + - No deprecated API usage + +- [ ] **Code compiles:** `./gradlew clean build` + - No compilation errors + - No Hilt code generation failures + - All KAPT processors succeed + +### 2. Testing + +- [ ] **Unit tests written** + - New use cases have tests (100% coverage goal) + - New repository methods have tests + - ViewModels have state transition tests + +- [ ] **All tests pass:** `./gradlew test` + - No flaky tests + - No ignored tests without justification + - Test coverage >80% for new logic + +- [ ] **Mocks properly configured** + - No real database access in unit tests + - No real API calls in unit tests + - Use MockK for all external dependencies + +### 3. Device Testing + +- [ ] **Tested on real device** (emulator insufficient for hardware features) + - WiFi scanning requires physical device + - Bluetooth/BLE requires physical device + - Location tracking tested with GPS + +- [ ] **No crashes on API 26** (minSdk) + - Test on oldest supported Android version + - No API compatibility issues + +- [ ] **Permissions work correctly** + - All required permissions requested + - Graceful handling of denied permissions + - No crashes due to missing permissions + +- [ ] **Foreground service works** (if applicable) + - Service starts correctly + - Notification appears + - Service survives app backgrounding + +### 4. Documentation + +- [ ] **KDoc comments added** + - Public use cases documented + - Public repository methods documented + - Complex logic explained + +- [ ] **README updated** (if user-facing changes) + - New features documented + - Screenshots added for UI changes + +- [ ] **CLAUDE.md updated** (if architectural changes) + - New patterns documented + - Examples updated + +### 5. Git Hygiene + +- [ ] **Clear commit messages** + - Format: `type(scope): description` + - Examples: `feat(wifi): add WPA3 detection`, `fix(bluetooth): resolve scan crash` + +- [ ] **No debug artifacts** + - No `Log.d()` statements in production code + - No `TODO` or `FIXME` comments + - No commented-out code blocks + +- [ ] **No secrets committed** + - No API keys in code + - No `.env` files + - `local.properties` not committed + +--- + +## File Structure & Architecture Patterns + +### Domain Layer (Business Logic) + +**Location:** `app/src/main/kotlin/com/shadowcheck/mobile/domain/` + +#### Repository Interfaces + +Define contracts that the data layer implements: + +```kotlin +// domain/repository/WifiNetworkRepository.kt +package com.shadowcheck.mobile.domain.repository + +import com.shadowcheck.mobile.domain.model.WifiNetwork +import kotlinx.coroutines.flow.Flow + +interface WifiNetworkRepository { + fun getAllNetworks(): Flow> + fun getNetworkById(ssid: String): Flow + fun getNetworksByBssid(bssid: String): Flow> + suspend fun insertNetwork(network: WifiNetwork): Long + suspend fun updateNetwork(network: WifiNetwork) + suspend fun deleteNetwork(ssid: String) + fun searchNetworks(query: String): Flow> + suspend fun syncWithWiGLE(apiKey: String): Int +} +``` + +#### Use Cases + +Encapsulate single business operations: + +```kotlin +// domain/usecase/GetAllWifiNetworksUseCase.kt +package com.shadowcheck.mobile.domain.usecase + +import com.shadowcheck.mobile.domain.model.WifiNetwork +import com.shadowcheck.mobile.domain.repository.WifiNetworkRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Use case to get all Wi-Fi networks, filtering out those with empty SSIDs + * and sorting them by the last time they were seen. + * + * Business rules: + * - Hidden networks (blank SSID) are filtered out + * - Networks sorted by timestamp descending (most recent first) + */ +@Singleton +class GetAllWifiNetworksUseCase @Inject constructor( + private val repository: WifiNetworkRepository +) { + operator fun invoke(): Flow> { + return repository.getAllNetworks() + .map { networks -> networks.filter { it.ssid.isNotBlank() } } + .map { networks -> networks.sortedByDescending { it.timestamp } } + } +} +``` + +#### Domain Models + +Pure Kotlin data classes: + +```kotlin +// domain/model/WifiNetwork.kt +package com.shadowcheck.mobile.domain.model + +/** + * Domain model representing a Wi-Fi network. + * Contains no Android-specific types - pure business data. + */ +data class WifiNetwork( + val ssid: String, + val bssid: String, + val capabilities: String, + val frequency: Int, + val level: Int, + val timestamp: Long +) +``` + +### Data Layer (Storage & API) + +**Location:** `app/src/main/kotlin/com/shadowcheck/mobile/data/` + +#### Repository Implementations + +Concrete data access logic: + +```kotlin +// data/repository/WifiNetworkRepositoryImpl.kt +package com.shadowcheck.mobile.data.repository + +import android.util.Log +import com.shadowcheck.mobile.data.database.dao.WifiNetworkDao +import com.shadowcheck.mobile.data.database.model.toDomainModel +import com.shadowcheck.mobile.data.database.model.toEntity +import com.shadowcheck.mobile.data.remote.WiGLEApiService +import com.shadowcheck.mobile.data.remote.dto.toEntity +import com.shadowcheck.mobile.di.IoDispatcher +import com.shadowcheck.mobile.domain.model.WifiNetwork +import com.shadowcheck.mobile.domain.repository.WifiNetworkRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WifiNetworkRepositoryImpl @Inject constructor( + private val wifiDao: WifiNetworkDao, + private val wigleService: WiGLEApiService, + @IoDispatcher private val dispatcher: CoroutineDispatcher +) : WifiNetworkRepository { + + override fun getAllNetworks(): Flow> { + return wifiDao.getAllNetworks() + .map { entities -> entities.map { it.toDomainModel() } } + .distinctUntilChanged() + .flowOn(dispatcher) + } + + override fun getNetworkById(ssid: String): Flow { + return wifiDao.getNetworkBySsid(ssid) + .map { it?.toDomainModel() } + .flowOn(dispatcher) + } + + override fun getNetworksByBssid(bssid: String): Flow> { + return wifiDao.getNetworksByBssid(bssid) + .map { entities -> entities.map { it.toDomainModel() } } + .flowOn(dispatcher) + } + + override suspend fun insertNetwork(network: WifiNetwork): Long { + return withContext(dispatcher) { + wifiDao.insertNetwork(network.toEntity()) + } + } + + override suspend fun updateNetwork(network: WifiNetwork) { + withContext(dispatcher) { + wifiDao.updateNetwork(network.toEntity()) + } + } + + override suspend fun deleteNetwork(ssid: String) { + withContext(dispatcher) { + wifiDao.deleteNetwork(ssid) + } + } + + override fun searchNetworks(query: String): Flow> { + val formattedQuery = "%${query.replace(' ', '%')}%" + return wifiDao.searchNetworks(formattedQuery) + .map { entities -> entities.map { it.toDomainModel() } } + .flowOn(dispatcher) + } + + override suspend fun syncWithWiGLE(apiKey: String): Int { + return withContext(dispatcher) { + try { + val response = wigleService.searchNetworks(apiKey = "Basic $apiKey") + if (response.isSuccessful) { + response.body()?.results?.let { dtos -> + val entities = dtos.map { it.toEntity() } + wifiDao.insertAll(entities) + return@withContext entities.size + } + } else { + Log.e("WifiRepo", "WiGLE API Error: ${response.code()}") + } + } catch (e: Exception) { + Log.e("WifiRepo", "WiGLE sync failed", e) + throw e + } + return@withContext 0 + } + } +} +``` + +#### DAOs (Room Database Access) + +```kotlin +// data/database/dao/WifiNetworkDao.kt +package com.shadowcheck.mobile.data.database.dao + +import androidx.room.* +import com.shadowcheck.mobile.data.database.model.WifiNetworkEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface WifiNetworkDao { + + @Query("SELECT * FROM wifi_networks ORDER BY timestamp DESC") + fun getAllNetworks(): Flow> + + @Query("SELECT * FROM wifi_networks WHERE ssid = :ssid LIMIT 1") + fun getNetworkBySsid(ssid: String): Flow + + @Query("SELECT * FROM wifi_networks WHERE bssid = :bssid") + fun getNetworksByBssid(bssid: String): Flow> + + @Query("SELECT * FROM wifi_networks WHERE ssid LIKE :query OR bssid LIKE :query") + fun searchNetworks(query: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertNetwork(network: WifiNetworkEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(networks: List) + + @Update + suspend fun updateNetwork(network: WifiNetworkEntity) + + @Query("DELETE FROM wifi_networks WHERE ssid = :ssid") + suspend fun deleteNetwork(ssid: String) +} +``` + +#### Database Entities + +```kotlin +// data/database/model/WifiNetworkEntity.kt +package com.shadowcheck.mobile.data.database.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.shadowcheck.mobile.domain.model.WifiNetwork + +@Entity(tableName = "wifi_networks") +data class WifiNetworkEntity( + @PrimaryKey val ssid: String, + val bssid: String, + val capabilities: String, + val frequency: Int, + val level: Int, + val timestamp: Long +) + +// Mapper functions +fun WifiNetworkEntity.toDomainModel(): WifiNetwork { + return WifiNetwork( + ssid = ssid, + bssid = bssid, + capabilities = capabilities, + frequency = frequency, + level = level, + timestamp = timestamp + ) +} + +fun WifiNetwork.toEntity(): WifiNetworkEntity { + return WifiNetworkEntity( + ssid = ssid, + bssid = bssid, + capabilities = capabilities, + frequency = frequency, + level = level, + timestamp = timestamp + ) +} +``` + +### Presentation Layer (UI) + +**Location:** `app/src/main/kotlin/com/shadowcheck/mobile/presentation/` + +#### ViewModels + +UI state management: + +```kotlin +// presentation/viewmodel/WifiViewModel.kt +package com.shadowcheck.mobile.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.shadowcheck.mobile.domain.model.WifiNetwork +import com.shadowcheck.mobile.domain.usecase.GetAllWifiNetworksUseCase +import com.shadowcheck.mobile.domain.usecase.SearchWifiNetworksUseCase +import com.shadowcheck.mobile.domain.usecase.SyncWiGLEUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WifiViewModel @Inject constructor( + private val getAllWifiNetworks: GetAllWifiNetworksUseCase, + private val searchWifiNetworks: SearchWifiNetworksUseCase, + private val syncWiGLE: SyncWiGLEUseCase +) : ViewModel() { + + private val _networks = MutableStateFlow>(emptyList()) + val networks: StateFlow> = _networks.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + init { + loadNetworks() + } + + fun loadNetworks() { + getAllWifiNetworks() + .onEach { _networks.value = it } + .launchIn(viewModelScope) + } + + fun search(query: String) { + searchWifiNetworks(query) + .onEach { _networks.value = it } + .launchIn(viewModelScope) + } + + fun syncData(apiKey: String) { + viewModelScope.launch { + _isLoading.value = true + try { + syncWiGLE(apiKey) + loadNetworks() + } catch (e: Exception) { + _error.value = e.message + } finally { + _isLoading.value = false + } + } + } + + fun clearError() { + _error.value = null + } +} +``` + +#### Compose Screens + +UI components (read-only, minimal logic): + +```kotlin +// presentation/ui/screens/WifiListScreen.kt +package com.shadowcheck.mobile.presentation.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel + +@Composable +fun WifiListScreen( + viewModel: WifiViewModel = hiltViewModel() +) { + val networks by viewModel.networks.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val error by viewModel.error.collectAsState() + + Column(modifier = Modifier.fillMaxSize()) { + if (isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + error?.let { errorMessage -> + Card( + modifier = Modifier.fillMaxWidth().padding(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) + ) { + Text( + text = errorMessage, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(networks, key = { it.ssid }) { network -> + WifiNetworkCard(network = network) + } + } + } +} + +@Composable +fun WifiNetworkCard(network: WifiNetwork) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = network.ssid, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = network.bssid, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Signal: ${network.level} dBm", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = network.capabilities, + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} +``` + +### Dependency Injection Modules + +**Location:** `app/src/main/kotlin/com/shadowcheck/mobile/di/` + +#### Database Module + +```kotlin +// di/DatabaseModule.kt +package com.shadowcheck.mobile.di + +import android.content.Context +import androidx.room.Room +import com.shadowcheck.mobile.data.database.AppDatabase +import com.shadowcheck.mobile.data.database.dao.WifiNetworkDao +import com.shadowcheck.mobile.data.database.dao.BluetoothDeviceDao +import com.shadowcheck.mobile.data.database.dao.CellularTowerDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideAppDatabase( + @ApplicationContext context: Context + ): AppDatabase { + return Room.databaseBuilder( + context, + AppDatabase::class.java, + "shadowcheck.db" + ).build() + } + + @Provides + fun provideWifiNetworkDao(database: AppDatabase): WifiNetworkDao { + return database.wifiNetworkDao() + } + + @Provides + fun provideBluetoothDeviceDao(database: AppDatabase): BluetoothDeviceDao { + return database.bluetoothDeviceDao() + } + + @Provides + fun provideCellularTowerDao(database: AppDatabase): CellularTowerDao { + return database.cellularTowerDao() + } +} +``` + +#### Repository Module + +```kotlin +// di/RepositoryModule.kt +package com.shadowcheck.mobile.di + +import com.shadowcheck.mobile.data.repository.WifiNetworkRepositoryImpl +import com.shadowcheck.mobile.data.repository.BluetoothDeviceRepositoryImpl +import com.shadowcheck.mobile.data.repository.CellularTowerRepositoryImpl +import com.shadowcheck.mobile.domain.repository.WifiNetworkRepository +import com.shadowcheck.mobile.domain.repository.BluetoothDeviceRepository +import com.shadowcheck.mobile.domain.repository.CellularTowerRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindWifiNetworkRepository( + impl: WifiNetworkRepositoryImpl + ): WifiNetworkRepository + + @Binds + @Singleton + abstract fun bindBluetoothDeviceRepository( + impl: BluetoothDeviceRepositoryImpl + ): BluetoothDeviceRepository + + @Binds + @Singleton + abstract fun bindCellularTowerRepository( + impl: CellularTowerRepositoryImpl + ): CellularTowerRepository +} +``` + +#### Network Module + +```kotlin +// di/NetworkModule.kt +package com.shadowcheck.mobile.di + +import com.shadowcheck.mobile.data.remote.WiGLEApiService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl("https://api.wigle.net/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideWiGLEApiService(retrofit: Retrofit): WiGLEApiService { + return retrofit.create(WiGLEApiService::class.java) + } +} +``` + +#### Dispatchers Module + +```kotlin +// di/DispatchersModule.kt +package com.shadowcheck.mobile.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class MainDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + +@Module +@InstallIn(SingletonComponent::class) +object DispatchersModule { + + @Provides + @IoDispatcher + fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @MainDispatcher + fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + @Provides + @DefaultDispatcher + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default +} +``` + +--- + +## Testing Strategy + +### Unit Test Repositories + +Test data access logic without hitting real database: + +```kotlin +// test/data/repository/WifiNetworkRepositoryImplTest.kt +package com.shadowcheck.mobile.data.repository + +import com.shadowcheck.mobile.data.database.dao.WifiNetworkDao +import com.shadowcheck.mobile.data.database.model.WifiNetworkEntity +import com.shadowcheck.mobile.data.remote.WiGLEApiService +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class WifiNetworkRepositoryImplTest { + + private lateinit var wifiDao: WifiNetworkDao + private lateinit var wigleService: WiGLEApiService + private lateinit var repository: WifiNetworkRepositoryImpl + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + wifiDao = mockk() + wigleService = mockk() + repository = WifiNetworkRepositoryImpl(wifiDao, wigleService, testDispatcher) + } + + @Test + fun getAllNetworks_returnsNetworksFromDao() = runTest(testDispatcher) { + // Given + val entities = listOf( + WifiNetworkEntity("TestSSID", "AA:BB:CC:DD:EE:FF", "WPA2", 2412, -70, 1000L) + ) + every { wifiDao.getAllNetworks() } returns flowOf(entities) + + // When + val result = repository.getAllNetworks().first() + + // Then + assertEquals(1, result.size) + assertEquals("TestSSID", result[0].ssid) + verify { wifiDao.getAllNetworks() } + } + + @Test + fun insertNetwork_insertsIntoDao() = runTest(testDispatcher) { + // Given + val network = WifiNetwork("TestSSID", "AA:BB:CC:DD:EE:FF", "WPA2", 2412, -70, 1000L) + coEvery { wifiDao.insertNetwork(any()) } returns 1L + + // When + val result = repository.insertNetwork(network) + + // Then + assertEquals(1L, result) + coVerify { wifiDao.insertNetwork(any()) } + } + + @Test + fun searchNetworks_filtersCorrectly() = runTest(testDispatcher) { + // Given + val entities = listOf( + WifiNetworkEntity("CoffeeShop", "AA:BB:CC:DD:EE:01", "WPA2", 2412, -70, 1000L), + WifiNetworkEntity("Home", "AA:BB:CC:DD:EE:02", "WPA3", 2417, -50, 2000L) + ) + every { wifiDao.searchNetworks("%Coffee%") } returns flowOf( + entities.filter { it.ssid.contains("Coffee") } + ) + + // When + val result = repository.searchNetworks("Coffee").first() + + // Then + assertEquals(1, result.size) + assertEquals("CoffeeShop", result[0].ssid) + } +} +``` + +### Unit Test Use Cases + +Test business logic independently: + +```kotlin +// test/domain/usecase/GetAllWifiNetworksUseCaseTest.kt +package com.shadowcheck.mobile.domain.usecase + +import com.shadowcheck.mobile.domain.model.WifiNetwork +import com.shadowcheck.mobile.domain.repository.WifiNetworkRepository +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class GetAllWifiNetworksUseCaseTest { + + private lateinit var repository: WifiNetworkRepository + private lateinit var useCase: GetAllWifiNetworksUseCase + + @Before + fun setup() { + repository = mockk() + useCase = GetAllWifiNetworksUseCase(repository) + } + + @Test + fun invoke_filtersBlankSsids() = runTest { + // Given + val networks = listOf( + WifiNetwork("ValidNetwork", "AA:BB:CC:DD:EE:01", "WPA2", 2412, -70, 1000L), + WifiNetwork("", "AA:BB:CC:DD:EE:02", "WPA3", 2417, -50, 2000L), + WifiNetwork("AnotherValid", "AA:BB:CC:DD:EE:03", "WPA2", 2422, -60, 3000L) + ) + every { repository.getAllNetworks() } returns flowOf(networks) + + // When + val result = useCase().first() + + // Then + assertEquals(2, result.size) + assertTrue(result.all { it.ssid.isNotBlank() }) + } + + @Test + fun invoke_sortsByTimestampDescending() = runTest { + // Given + val networks = listOf( + WifiNetwork("Network1", "AA:BB:CC:DD:EE:01", "WPA2", 2412, -70, 1000L), + WifiNetwork("Network2", "AA:BB:CC:DD:EE:02", "WPA3", 2417, -50, 3000L), + WifiNetwork("Network3", "AA:BB:CC:DD:EE:03", "WPA2", 2422, -60, 2000L) + ) + every { repository.getAllNetworks() } returns flowOf(networks) + + // When + val result = useCase().first() + + // Then + assertEquals(3000L, result[0].timestamp) + assertEquals(2000L, result[1].timestamp) + assertEquals(1000L, result[2].timestamp) + } + + @Test + fun invoke_returnsEmptyListWhenNoNetworks() = runTest { + // Given + every { repository.getAllNetworks() } returns flowOf(emptyList()) + + // When + val result = useCase().first() + + // Then + assertTrue(result.isEmpty()) + } +} +``` + +### Unit Test ViewModels + +Test state management with coroutines: + +```kotlin +// test/presentation/viewmodel/WifiViewModelTest.kt +package com.shadowcheck.mobile.presentation.viewmodel + +import com.shadowcheck.mobile.domain.model.WifiNetwork +import com.shadowcheck.mobile.domain.usecase.GetAllWifiNetworksUseCase +import com.shadowcheck.mobile.domain.usecase.SearchWifiNetworksUseCase +import com.shadowcheck.mobile.domain.usecase.SyncWiGLEUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class WifiViewModelTest { + + private lateinit var getAllWifiNetworks: GetAllWifiNetworksUseCase + private lateinit var searchWifiNetworks: SearchWifiNetworksUseCase + private lateinit var syncWiGLE: SyncWiGLEUseCase + private lateinit var viewModel: WifiViewModel + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + getAllWifiNetworks = mockk() + searchWifiNetworks = mockk() + syncWiGLE = mockk() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun init_loadsNetworks() = runTest(testDispatcher) { + // Given + val networks = listOf( + WifiNetwork("Network1", "AA:BB:CC:DD:EE:01", "WPA2", 2412, -70, 1000L) + ) + every { getAllWifiNetworks() } returns flowOf(networks) + + // When + viewModel = WifiViewModel(getAllWifiNetworks, searchWifiNetworks, syncWiGLE) + advanceUntilIdle() + + // Then + assertEquals(1, viewModel.networks.value.size) + assertEquals("Network1", viewModel.networks.value[0].ssid) + } + + @Test + fun search_updatesNetworks() = runTest(testDispatcher) { + // Given + val initialNetworks = listOf( + WifiNetwork("Network1", "AA:BB:CC:DD:EE:01", "WPA2", 2412, -70, 1000L) + ) + val searchResults = listOf( + WifiNetwork("SearchResult", "AA:BB:CC:DD:EE:02", "WPA3", 2417, -50, 2000L) + ) + every { getAllWifiNetworks() } returns flowOf(initialNetworks) + every { searchWifiNetworks("test") } returns flowOf(searchResults) + + viewModel = WifiViewModel(getAllWifiNetworks, searchWifiNetworks, syncWiGLE) + advanceUntilIdle() + + // When + viewModel.search("test") + advanceUntilIdle() + + // Then + assertEquals(1, viewModel.networks.value.size) + assertEquals("SearchResult", viewModel.networks.value[0].ssid) + } + + @Test + fun syncData_setsLoadingAndCallsUseCase() = runTest(testDispatcher) { + // Given + every { getAllWifiNetworks() } returns flowOf(emptyList()) + coEvery { syncWiGLE("test-api-key") } returns Unit + + viewModel = WifiViewModel(getAllWifiNetworks, searchWifiNetworks, syncWiGLE) + advanceUntilIdle() + + // When + viewModel.syncData("test-api-key") + + // During sync + assertTrue(viewModel.isLoading.value) + + advanceUntilIdle() + + // Then + assertFalse(viewModel.isLoading.value) + coVerify { syncWiGLE("test-api-key") } + } + + @Test + fun syncData_handlesErrors() = runTest(testDispatcher) { + // Given + every { getAllWifiNetworks() } returns flowOf(emptyList()) + coEvery { syncWiGLE("test-api-key") } throws Exception("Network error") + + viewModel = WifiViewModel(getAllWifiNetworks, searchWifiNetworks, syncWiGLE) + advanceUntilIdle() + + // When + viewModel.syncData("test-api-key") + advanceUntilIdle() + + // Then + assertFalse(viewModel.isLoading.value) + assertEquals("Network error", viewModel.error.value) + } + + @Test + fun clearError_resetsErrorState() = runTest(testDispatcher) { + // Given + every { getAllWifiNetworks() } returns flowOf(emptyList()) + coEvery { syncWiGLE("test-api-key") } throws Exception("Error") + + viewModel = WifiViewModel(getAllWifiNetworks, searchWifiNetworks, syncWiGLE) + viewModel.syncData("test-api-key") + advanceUntilIdle() + + // When + viewModel.clearError() + + // Then + assertEquals(null, viewModel.error.value) + } +} +``` + +### Testing Guidelines + +**Core Principles:** + +1. **No Real Database** - Use MockK for DAOs. Never connect to actual SQLite in unit tests. +2. **No Real API** - Mock Retrofit services. Never make actual HTTP calls. +3. **Use `runTest {}`** - Properly handles coroutines and Flow collection. +4. **One Behavior Per Test** - Each test verifies one specific behavior. +5. **Mock External Dependencies** - Retrofit, Firebase, location services—all mocked. +6. **No UI Tests in Unit Tests** - Compose UI tests belong in `androidTest/`, not `test/`. + +**Flow Testing Pattern:** + +```kotlin +// Collect first emission +val result = repository.getData().first() + +// Collect all emissions +val results = repository.getData().take(3).toList() + +// Test with delays +repository.getData() + .onEach { delay(100) } + .test { + assertEquals(expected1, awaitItem()) + assertEquals(expected2, awaitItem()) + awaitComplete() + } +``` + +**Coroutine Testing Pattern:** + +```kotlin +@Before +fun setup() { + Dispatchers.setMain(testDispatcher) +} + +@After +fun tearDown() { + Dispatchers.resetMain() +} + +@Test +fun testSuspendFunction() = runTest(testDispatcher) { + // Trigger coroutine + viewModel.performAction() + + // Advance virtual time + advanceUntilIdle() + + // Assert results + assertEquals(expected, viewModel.state.value) +} +``` + +--- + +## Common Development Tasks + +### Task 1: Adding a New Entity (e.g., CellularTower) + +#### Step 1: Create Database Entity + +```kotlin +// data/database/model/CellularTowerEntity.kt +package com.shadowcheck.mobile.data.database.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.shadowcheck.mobile.domain.model.CellularTower + +@Entity(tableName = "cellular_towers") +data class CellularTowerEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val cellId: Int, + val mcc: Int, + val mnc: Int, + val lac: Int, + val latitude: Double, + val longitude: Double, + val signalStrength: Int, + val timestamp: Long +) + +// Mapper functions +fun CellularTowerEntity.toDomainModel(): CellularTower { + return CellularTower( + id = id, + cellId = cellId, + mcc = mcc, + mnc = mnc, + lac = lac, + latitude = latitude, + longitude = longitude, + signalStrength = signalStrength, + timestamp = timestamp + ) +} + +fun CellularTower.toEntity(): CellularTowerEntity { + return CellularTowerEntity( + id = id, + cellId = cellId, + mcc = mcc, + mnc = mnc, + lac = lac, + latitude = latitude, + longitude = longitude, + signalStrength = signalStrength, + timestamp = timestamp + ) +} +``` + +#### Step 2: Create DAO + +```kotlin +// data/database/dao/CellularTowerDao.kt +package com.shadowcheck.mobile.data.database.dao + +import androidx.room.* +import com.shadowcheck.mobile.data.database.model.CellularTowerEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CellularTowerDao { + + @Query("SELECT * FROM cellular_towers ORDER BY timestamp DESC") + fun getAllTowers(): Flow> + + @Query("SELECT * FROM cellular_towers WHERE cellId = :cellId LIMIT 1") + fun getTowerById(cellId: Int): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTower(tower: CellularTowerEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(towers: List) + + @Update + suspend fun updateTower(tower: CellularTowerEntity) + + @Delete + suspend fun deleteTower(tower: CellularTowerEntity) +} +``` + +#### Step 3: Update Database Class + +```kotlin +// data/database/AppDatabase.kt +package com.shadowcheck.mobile.data.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.shadowcheck.mobile.data.database.dao.CellularTowerDao +import com.shadowcheck.mobile.data.database.dao.WifiNetworkDao +import com.shadowcheck.mobile.data.database.dao.BluetoothDeviceDao +import com.shadowcheck.mobile.data.database.model.CellularTowerEntity +import com.shadowcheck.mobile.data.database.model.WifiNetworkEntity +import com.shadowcheck.mobile.data.database.model.BluetoothDeviceEntity + +@Database( + entities = [ + WifiNetworkEntity::class, + BluetoothDeviceEntity::class, + CellularTowerEntity::class // Add new entity + ], + version = 2, // Increment version + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + abstract fun wifiNetworkDao(): WifiNetworkDao + abstract fun bluetoothDeviceDao(): BluetoothDeviceDao + abstract fun cellularTowerDao(): CellularTowerDao // Add DAO +} +``` + +#### Step 4: Create Domain Model + +```kotlin +// domain/model/CellularTower.kt +package com.shadowcheck.mobile.domain.model + +data class CellularTower( + val id: Long = 0, + val cellId: Int, + val mcc: Int, + val mnc: Int, + val lac: Int, + val latitude: Double, + val longitude: Double, + val signalStrength: Int, + val timestamp: Long +) +``` + +#### Step 5: Create Repository Interface + +```kotlin +// domain/repository/CellularTowerRepository.kt +package com.shadowcheck.mobile.domain.repository + +import com.shadowcheck.mobile.domain.model.CellularTower +import kotlinx.coroutines.flow.Flow + +interface CellularTowerRepository { + fun getAllTowers(): Flow> + fun getTowerById(cellId: Int): Flow + suspend fun insertTower(tower: CellularTower): Long + suspend fun updateTower(tower: CellularTower) + suspend fun deleteTower(tower: CellularTower) +} +``` + +#### Step 6: Implement Repository + +```kotlin +// data/repository/CellularTowerRepositoryImpl.kt +package com.shadowcheck.mobile.data.repository + +import com.shadowcheck.mobile.data.database.dao.CellularTowerDao +import com.shadowcheck.mobile.data.database.model.toDomainModel +import com.shadowcheck.mobile.data.database.model.toEntity +import com.shadowcheck.mobile.di.IoDispatcher +import com.shadowcheck.mobile.domain.model.CellularTower +import com.shadowcheck.mobile.domain.repository.CellularTowerRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CellularTowerRepositoryImpl @Inject constructor( + private val cellularTowerDao: CellularTowerDao, + @IoDispatcher private val dispatcher: CoroutineDispatcher +) : CellularTowerRepository { + + override fun getAllTowers(): Flow> { + return cellularTowerDao.getAllTowers() + .map { entities -> entities.map { it.toDomainModel() } } + .flowOn(dispatcher) + } + + override fun getTowerById(cellId: Int): Flow { + return cellularTowerDao.getTowerById(cellId) + .map { it?.toDomainModel() } + .flowOn(dispatcher) + } + + override suspend fun insertTower(tower: CellularTower): Long { + return withContext(dispatcher) { + cellularTowerDao.insertTower(tower.toEntity()) + } + } + + override suspend fun updateTower(tower: CellularTower) { + withContext(dispatcher) { + cellularTowerDao.updateTower(tower.toEntity()) + } + } + + override suspend fun deleteTower(tower: CellularTower) { + withContext(dispatcher) { + cellularTowerDao.deleteTower(tower.toEntity()) + } + } +} +``` + +#### Step 7: Update RepositoryModule + +```kotlin +// di/RepositoryModule.kt +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + // ... existing bindings ... + + @Binds + @Singleton + abstract fun bindCellularTowerRepository( + impl: CellularTowerRepositoryImpl + ): CellularTowerRepository +} +``` + +#### Step 8: Create Use Case + +```kotlin +// domain/usecase/GetAllCellularTowersUseCase.kt +package com.shadowcheck.mobile.domain.usecase + +import com.shadowcheck.mobile.domain.model.CellularTower +import com.shadowcheck.mobile.domain.repository.CellularTowerRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GetAllCellularTowersUseCase @Inject constructor( + private val repository: CellularTowerRepository +) { + operator fun invoke(): Flow> { + return repository.getAllTowers() + .map { towers -> towers.sortedByDescending { it.timestamp } } + } +} +``` + +#### Step 9: Create ViewModel + +```kotlin +// presentation/viewmodel/CellularViewModel.kt +package com.shadowcheck.mobile.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.shadowcheck.mobile.domain.model.CellularTower +import com.shadowcheck.mobile.domain.usecase.GetAllCellularTowersUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@HiltViewModel +class CellularViewModel @Inject constructor( + private val getAllCellularTowers: GetAllCellularTowersUseCase +) : ViewModel() { + + private val _towers = MutableStateFlow>(emptyList()) + val towers: StateFlow> = _towers.asStateFlow() + + init { + loadTowers() + } + + fun loadTowers() { + getAllCellularTowers() + .onEach { _towers.value = it } + .launchIn(viewModelScope) + } +} +``` + +#### Step 10: Update DatabaseModule + +```kotlin +// di/DatabaseModule.kt +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + // ... existing providers ... + + @Provides + fun provideCellularTowerDao(database: AppDatabase): CellularTowerDao { + return database.cellularTowerDao() + } +} +``` + +--- + +### Task 2: Adding an API Call (e.g., Fetch WiGLE Data) + +#### Step 1: Define API Service Interface + +```kotlin +// data/remote/WiGLEApiService.kt +package com.shadowcheck.mobile.data.remote + +import com.shadowcheck.mobile.data.remote.dto.WigleWifiSearchResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query + +interface WiGLEApiService { + + @GET("/api/v3/network/search") + suspend fun searchNetworks( + @Query("onlymine") onlyMine: Boolean = false, + @Query("freenet") freeNet: Boolean = false, + @Query("paynet") payNet: Boolean = false, + @Header("Authorization") apiKey: String + ): Response +} +``` + +#### Step 2: Create DTOs + +```kotlin +// data/remote/dto/WigleWifiSearchResponse.kt +package com.shadowcheck.mobile.data.remote.dto + +import com.google.gson.annotations.SerializedName + +data class WigleWifiSearchResponse( + @SerializedName("success") val success: Boolean, + @SerializedName("totalResults") val totalResults: Int, + @SerializedName("results") val results: List +) + +data class WigleNetworkDto( + @SerializedName("ssid") val ssid: String, + @SerializedName("netid") val bssid: String, + @SerializedName("channel") val channel: Int, + @SerializedName("encryption") val encryption: String, + @SerializedName("lastupdt") val lastUpdate: String, + @SerializedName("trilat") val latitude: Double, + @SerializedName("trilong") val longitude: Double +) +``` + +#### Step 3: Create Mapper Extension + +```kotlin +// data/remote/dto/WigleMappers.kt +package com.shadowcheck.mobile.data.remote.dto + +import com.shadowcheck.mobile.data.database.model.WifiNetworkEntity + +fun WigleNetworkDto.toEntity(): WifiNetworkEntity { + return WifiNetworkEntity( + ssid = ssid, + bssid = bssid, + capabilities = encryption, + frequency = channelToFrequency(channel), + level = -70, // WiGLE doesn't provide signal strength + timestamp = System.currentTimeMillis() + ) +} + +private fun channelToFrequency(channel: Int): Int { + return when (channel) { + in 1..13 -> 2407 + (channel * 5) + 14 -> 2484 + else -> 5000 + (channel * 5) + } +} +``` + +#### Step 4: Add Method to Repository + +```kotlin +// data/repository/WifiNetworkRepositoryImpl.kt +@Singleton +class WifiNetworkRepositoryImpl @Inject constructor( + private val wifiDao: WifiNetworkDao, + private val wigleService: WiGLEApiService, + @IoDispatcher private val dispatcher: CoroutineDispatcher +) : WifiNetworkRepository { + + // ... existing methods ... + + override suspend fun syncWithWiGLE(apiKey: String): Int { + return withContext(dispatcher) { + try { + val response = wigleService.searchNetworks(apiKey = "Basic $apiKey") + if (response.isSuccessful) { + response.body()?.results?.let { dtos -> + val entities = dtos.map { it.toEntity() } + wifiDao.insertAll(entities) + return@withContext entities.size + } + } else { + Log.e("WifiRepo", "WiGLE API Error: ${response.code()}") + throw Exception("WiGLE API returned error: ${response.code()}") + } + } catch (e: Exception) { + Log.e("WifiRepo", "WiGLE sync failed", e) + throw e + } + return@withContext 0 + } + } +} +``` + +#### Step 5: Create Use Case + +```kotlin +// domain/usecase/SyncWiGLEUseCase.kt +package com.shadowcheck.mobile.domain.usecase + +import com.shadowcheck.mobile.domain.repository.WifiNetworkRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SyncWiGLEUseCase @Inject constructor( + private val repository: WifiNetworkRepository +) { + suspend operator fun invoke(apiKey: String): Int { + return repository.syncWithWiGLE(apiKey) + } +} +``` + +#### Step 6: Call from ViewModel + +```kotlin +// presentation/viewmodel/WifiViewModel.kt +@HiltViewModel +class WifiViewModel @Inject constructor( + private val getAllWifiNetworks: GetAllWifiNetworksUseCase, + private val searchWifiNetworks: SearchWifiNetworksUseCase, + private val syncWiGLE: SyncWiGLEUseCase +) : ViewModel() { + + // ... existing properties ... + + fun syncData(apiKey: String) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + try { + val count = syncWiGLE(apiKey) + // Optionally show success message + Log.d("WifiViewModel", "Synced $count networks from WiGLE") + loadNetworks() // Refresh local data + } catch (e: Exception) { + _error.value = e.message ?: "Unknown error during sync" + Log.e("WifiViewModel", "Sync failed", e) + } finally { + _isLoading.value = false + } + } + } +} +``` + +--- + +### Task 3: Debugging Tips + +#### Logging Best Practices + +```kotlin +// Use consistent tags +private const val TAG = "WifiRepo" + +// Log at appropriate levels +Log.d(TAG, "Synced ${networks.size} networks") // Debug info +Log.w(TAG, "API key missing, skipping sync") // Warnings +Log.e(TAG, "Failed to sync", exception) // Errors + +// Use Timber (if available) for automatic tagging +Timber.d("Synced ${networks.size} networks") +Timber.e(exception, "Failed to sync") +``` + +#### Inspect Flows + +```kotlin +getAllWifiNetworksUseCase() + .onEach { networks -> + Log.d("Debug", "Flow emitted ${networks.size} networks") + Log.d("Debug", "First network: ${networks.firstOrNull()}") + } + .launchIn(viewModelScope) +``` + +#### Database Inspection + +1. Open Android Studio +2. View → Tool Windows → Database Inspector +3. Connect to running device/emulator +4. Browse tables and run SQL queries + +#### Network Inspection + +```kotlin +// Add logging interceptor in NetworkModule +val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY +} + +val okHttpClient = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() +``` + +Then filter logcat for `okhttp` to see HTTP requests/responses. + +#### Coroutine Debugging + +```kotlin +// Print current dispatcher +Log.d("Debug", "Running on: ${Thread.currentThread().name}") + +// Check if on main thread +if (Looper.myLooper() == Looper.getMainLooper()) { + Log.w("Debug", "Running on main thread!") +} +``` + +--- + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| **"Hilt cannot find a binding for X"** | Dependency not provided in DI modules | Check `DatabaseModule`, `NetworkModule`, `RepositoryModule` have `@Provides` or `@Binds` for X. Run `./gradlew clean build` to regenerate Hilt code. | +| **"Compilation error: Cannot resolve symbol"** | KSP/KAPT processor not run | Run `./gradlew clean build` to regenerate Hilt/Room code. Check that KAPT is enabled in `build.gradle.kts`. | +| **"Test hangs or never completes"** | Flow not collected or coroutine not launched | Wrap test with `runTest {}` and call `.first()` or `.collect()` on Flow. Use `advanceUntilIdle()` to advance virtual time. | +| **"Mock not working, real database accessed"** | Using `every` instead of `coEvery` for suspend function | Check function signature. Use `every` for non-suspend, `coEvery` for suspend functions. | +| **"Build passes but app crashes at runtime"** | Missing `@Singleton` annotation on repository | Ensure all repository implementations have `@Singleton`. Check Hilt modules are properly configured. | +| **"API key appears in logs"** | Hardcoded or logged during debug | Fetch from `EncryptedSharedPreferences`. Never log sensitive values. Remove all `Log.d()` that print API keys. | +| **"Room database version conflict"** | Database schema changed but version not incremented | Increment `version` in `@Database` annotation. Consider adding migration or using `.fallbackToDestructiveMigration()` for dev builds. | +| **"NetworkOnMainThreadException"** | Blocking network call on main thread | Ensure repository uses `withContext(Dispatchers.IO)` or `.flowOn(Dispatchers.IO)`. Never use `.get()` on Flow. | +| **"Compose recomposition loop"** | State change triggers itself | Check that ViewModel state updates don't re-trigger the same operation. Use `derivedStateOf` for computed values. | +| **"WiFi scanning not working in tests"** | Real hardware required for WiFi scanning | Mock scanner service in tests. Use real device for integration testing, not emulator. | +| **"Hilt ViewModels not injecting"** | Missing `@HiltViewModel` or `@AndroidEntryPoint` | Ensure ViewModel has `@HiltViewModel` and Activity/Fragment has `@AndroidEntryPoint`. Use `hiltViewModel()` in Compose. | +| **"Detekt failures blocking build"** | Code doesn't meet style guidelines | Run `./gradlew detekt` to see issues. Fix or suppress with `@Suppress("RuleName")` with justification. | + +--- + +## Additional Resources + +### Project Documentation + +- **CLAUDE.md** - Architectural overview and build configuration +- **README.md** - User-facing features and setup instructions +- **docs/DEVELOPMENT.md** - Development workflow and environment setup +- **docs/BUILD_FIX_GUIDE.md** - Build troubleshooting + +### External Resources + +- [Kotlin Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html) +- [Hilt Dependency Injection](https://developer.android.com/training/dependency-injection/hilt-android) +- [Room Database](https://developer.android.com/training/data-storage/room) +- [Jetpack Compose](https://developer.android.com/jetpack/compose) +- [Retrofit](https://square.github.io/retrofit/) + +--- + +## Questions & Support + +- **Architecture questions**: Check CLAUDE.md or ask in GitHub Discussions +- **Build issues**: See docs/BUILD_FIX_GUIDE.md +- **Bug reports**: Open a GitHub Issue using the bug report template +- **Feature requests**: Open a GitHub Issue using the feature request template + +--- + +**Thank you for contributing to ShadowCheckMobile!** + +Your contributions help make network security and surveillance detection accessible to everyone. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..163c0ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,43 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Bug Description + + +## Steps to Reproduce +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +## Expected Behavior + + +## Actual Behavior + + +## Screenshots + + +## Environment +- **Device**: [e.g., Pixel 7, Samsung S23] +- **Android Version**: [e.g., Android 13] +- **App Version**: [e.g., 1.0.0] +- **Build Type**: [debug/release] + +## Logcat Output + +``` +Paste logcat here +``` + +## Additional Context + + +## Possible Solution + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..7215e6c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,42 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Feature Description + + +## Problem It Solves + + +## Proposed Solution + + +## Alternative Solutions + + +## Implementation Details + +- [ ] Affects: WiFi scanning / Bluetooth scanning / Cellular / UI / Other +- [ ] Requires new permissions: Yes / No +- [ ] Breaking change: Yes / No + +## User Stories + +As a [type of user], I want [goal] so that [benefit]. + +## Mockups/Examples + + +## Additional Context + + +## Priority + +- [ ] Critical - App is unusable without it +- [ ] High - Significantly impacts functionality +- [ ] Medium - Nice to have +- [ ] Low - Minor improvement diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..483334c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,53 @@ +## Description + + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Refactoring (no functional changes, code improvements) +- [ ] Documentation update +- [ ] Test additions/updates + +## Related Issues + +Closes # + +## Changes Made + +- +- +- + +## Testing Performed + +- [ ] Unit tests added/updated +- [ ] All unit tests pass locally +- [ ] Manual testing on device/emulator +- [ ] Tested on multiple Android versions (if applicable) +- [ ] Tested edge cases + +## Screenshots (if applicable) + + +## Checklist + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published + +## Code Quality +- [ ] Code passes detekt checks +- [ ] No hardcoded API keys or secrets +- [ ] Follows Clean Architecture patterns +- [ ] Uses Hilt dependency injection +- [ ] Proper error handling implemented + +## Additional Notes + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66b5168 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: CI + +on: + push: + branches: [ main, develop, feature/* ] + pull_request: + branches: [ main, develop ] + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Run detekt + run: ./gradlew detekt + continue-on-error: true + + - name: Run unit tests + run: ./gradlew test --stacktrace + + - name: Generate test report + if: always() + run: ./gradlew testDebugUnitTest + continue-on-error: true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-results + path: app/build/reports/tests/ + + - name: Build debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v3 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk + + lint: + name: Lint Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run detekt + run: ./gradlew detekt + + - name: Upload detekt report + if: always() + uses: actions/upload-artifact@v3 + with: + name: detekt-report + path: app/build/reports/detekt/ + + dependency-check: + name: Dependency Updates + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Check for dependency updates + run: ./gradlew dependencyUpdates + continue-on-error: true diff --git a/.gitignore b/.gitignore index f6d035a..64c6dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,4 @@ screenshots/ *.bak *.swp *~.nib +.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d3cc7ab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This is a multi-module Android project. Key paths: +- `app/` for the Android application module (Compose UI, Android entry points). +- `core/`, `domain/`, `data/` for shared, clean-architecture layers. +- `app/src/main/` for production code, resources, and assets. +- `app/src/test/` for unit tests. +- `docs/` for development and build guides. + +Package by feature (not by layer), e.g. `com.shadowcheck.mobile.wifi/` with `data/`, `domain/`, `presentation/` subpackages. + +## Build, Test, and Development Commands +- `./gradlew clean build` — full build with checks. +- `./gradlew assembleDebug` — build debug APK. +- `./gradlew installDebug` — install on a connected device. +- `./gradlew test` — run unit tests. +- `./gradlew detekt` — run static analysis (must be clean before PR). + +## Coding Style & Naming Conventions +- Kotlin conventions, 4-space indentation, max line length 120. +- Use meaningful names; avoid decompilation artifacts like `var1`. +- Naming patterns: + - ViewModels: `WifiViewModel.kt` + - Use cases: `GetAllWifiNetworksUseCase.kt` + - Repositories: `WifiNetworkRepository.kt` / `WifiNetworkRepositoryImpl.kt` +- DI via Hilt with KAPT (do not migrate to KSP without explicit approval). + +## Testing Guidelines +- Frameworks: JUnit4, MockK, coroutines test. +- Coverage targets (new code): Use cases 100%, ViewModels 90%+, Repos 80%+. +- Naming: `*Test.kt` in `app/src/test/` (e.g., `GetAllWifiNetworksUseCaseTest`). + +## Commit & Pull Request Guidelines +- Conventional commits: `type(scope): subject` (e.g., `feat(wifi): Add WPA3 detection`). +- PRs must include: clear description, tests run, screenshots for UI changes, and linked issues. +- Run `./gradlew detekt` and `./gradlew test` before opening a PR. +- Address review feedback in new commits (do not force-push). + +## Security & Configuration Tips +- Copy `local.properties.example` to `local.properties` and add API keys locally. +- Never commit secrets; use `SecureApiKeyManager` for API key handling. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be2be28 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,392 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ShadowCheckMobile is a network security and surveillance detection Android app for wardriving, threat monitoring, and network analysis. It scans WiFi networks, Bluetooth/BLE devices, and cellular towers with advanced threat detection capabilities. + +**Critical Context**: This project was recovered from an APK using jadx decompiler. While fully functional, the codebase contains decompilation artifacts and is undergoing architectural refactoring from legacy code to modern Clean Architecture with Hilt. + +## Build Commands + +```bash +# Clean build (recommended after dependency changes) +./gradlew clean build + +# Debug build +./gradlew assembleDebug + +# Install on device +./gradlew installDebug + +# Run unit tests +./gradlew test + +# Run specific test class +./gradlew test --tests com.shadowcheck.mobile.domain.usecase.GetAllWifiNetworksUseCaseTest + +# Run tests with logging +./gradlew test --info + +# Refresh dependencies (if build issues occur) +./gradlew --refresh-dependencies clean build +``` + +### Build Requirements +- Android Studio Hedgehog (2023.1.1) or later +- JDK 17 (strictly enforced - JDK 11 will fail) +- Android SDK 34 +- Gradle 8.2+ +- Kotlin 2.0.0 (migrated from 1.9.22) + +## Architecture + +### Dual Architecture (In Transition) + +The codebase currently has two architectural patterns coexisting: + +**Legacy Architecture** (`com.shadowcheck.mobile`) +- Direct ViewModel-to-Database access +- Single `MainViewModel` handling all state +- Monolithic view models without dependency injection +- Original decompiled code structure + +**New Clean Architecture** (`com.shadowcheck.mobile` with DI) +- Repository pattern with interfaces in `domain/repository/` +- Repository implementations in `data/repository/` +- Use cases in `domain/usecase/` for business logic +- Hilt dependency injection throughout +- Separate ViewModels per feature: `WifiViewModel`, `BluetoothViewModel`, `CellularViewModel` + +**When adding new features**: Use the new Clean Architecture pattern with Hilt. The legacy code is being gradually refactored. + +### Clean Architecture Layers + +**Data Layer** (`data/`) +- `data/database/`: Room database with entities and DAOs + - `AppDatabase`: Room database class + - `dao/`: DAO interfaces with Flow-based queries + - `model/`: Database entities (suffixed with `Entity`) +- `data/repository/`: Repository implementations + - Implement interfaces from `domain/repository/` + - Handle data mapping between entities and domain models + +**Domain Layer** (`domain/`) +- `domain/model/`: Domain models (pure business objects) + - `WifiNetwork`, `BluetoothDevice`, `CellularTower` + - `ThreatDetection`, `SurveillanceDetector`, `UnifiedSighting` +- `domain/repository/`: Repository interfaces (contracts) +- `domain/usecase/`: Single-responsibility use cases + - Each use case is a distinct business operation + - Injected into ViewModels via Hilt + +**Presentation Layer** (`presentation/`, `ui/`) +- `presentation/viewmodel/`: Feature-specific ViewModels with Hilt + - Annotated with `@HiltViewModel` + - Constructor injection of use cases + - Expose `StateFlow` for UI state +- `ui/screens/`: Composable screens organized by feature + - `details/`: Detail screens for individual networks/devices + - `lists/`: List screens for WiFi, Bluetooth, Cellular + - `maps/`: Map visualizations + - `security/`: Threat detection and security screens + - `settings/`: App configuration +- `ui/components/`: Reusable UI components + +**Dependency Injection** (`di/`) +- `DatabaseModule`: Provides Room database and DAOs +- `RepositoryModule`: Binds repository interfaces to implementations +- `NetworkModule`: Provides Retrofit and API services +- `DispatchersModule`: Provides coroutine dispatchers + +### Key Architectural Components + +**Room Database Flow** +```kotlin +// DAOs expose Flow for reactive updates +interface WifiNetworkDao { + @Query("SELECT * FROM wifi_networks") + fun getAllNetworks(): Flow> +} + +// Repositories transform entities to domain models +class WifiNetworkRepositoryImpl @Inject constructor( + private val dao: WifiNetworkDao +) : WifiNetworkRepository { + override fun getAllNetworks(): Flow> = + dao.getAllNetworks().map { entities -> + entities.map { it.toDomainModel() } + } +} + +// Use cases contain business logic +class GetAllWifiNetworksUseCase @Inject constructor( + private val repository: WifiNetworkRepository +) { + operator fun invoke(): Flow> = + repository.getAllNetworks() + .map { networks -> + networks.filter { it.ssid.isNotBlank() } + .sortedByDescending { it.timestamp } + } +} + +// ViewModels consume use cases +@HiltViewModel +class WifiViewModel @Inject constructor( + private val getAllWifiNetworks: GetAllWifiNetworksUseCase +) : ViewModel() { + private val _networks = MutableStateFlow>(emptyList()) + val networks: StateFlow> = _networks.asStateFlow() + + init { + getAllWifiNetworks() + .onEach { _networks.value = it } + .launchIn(viewModelScope) + } +} +``` + +**Scanner Service** (`rebuilt/service/CompleteScannerService`) +- Foreground service for continuous background scanning +- Scans WiFi, Bluetooth, BLE, and cellular networks +- Writes scan results directly to Room database +- Configured in AndroidManifest with `foregroundServiceType="location"` + +**Application Entry Points** +- `ShadowCheckApp.kt`: Application class annotated with `@HiltAndroidApp` +- `rebuilt/presentation/MainActivity.kt`: Main activity entry point +- Navigation handled via Jetpack Navigation Compose + +## Dependency Injection with Hilt + +### Adding a New Hilt Component + +**1. Create Domain Model** (`domain/model/`) +```kotlin +data class MyFeature( + val id: String, + val name: String, + val timestamp: Long +) +``` + +**2. Create Repository Interface** (`domain/repository/`) +```kotlin +interface MyFeatureRepository { + fun getAllFeatures(): Flow> +} +``` + +**3. Create Repository Implementation** (`data/repository/`) +```kotlin +class MyFeatureRepositoryImpl @Inject constructor( + private val dao: MyFeatureDao +) : MyFeatureRepository { + override fun getAllFeatures(): Flow> = + dao.getAll().map { entities -> entities.map { it.toDomainModel() } } +} +``` + +**4. Bind in RepositoryModule** (`di/RepositoryModule.kt`) +```kotlin +@Binds +abstract fun bindMyFeatureRepository( + impl: MyFeatureRepositoryImpl +): MyFeatureRepository +``` + +**5. Create Use Case** (`domain/usecase/`) +```kotlin +@Singleton +class GetAllFeaturesUseCase @Inject constructor( + private val repository: MyFeatureRepository +) { + operator fun invoke(): Flow> = + repository.getAllFeatures() +} +``` + +**6. Create ViewModel** (`presentation/viewmodel/`) +```kotlin +@HiltViewModel +class MyFeatureViewModel @Inject constructor( + private val getAllFeatures: GetAllFeaturesUseCase +) : ViewModel() { + private val _features = MutableStateFlow>(emptyList()) + val features: StateFlow> = _features.asStateFlow() + + init { + getAllFeatures() + .onEach { _features.value = it } + .launchIn(viewModelScope) + } +} +``` + +**7. Use in Composable** +```kotlin +@Composable +fun MyFeatureScreen(viewModel: MyFeatureViewModel = hiltViewModel()) { + val features by viewModel.features.collectAsState() + // UI code +} +``` + +## Database Schema + +**Primary Database**: `shadowcheck.db` (Room) + +**Current Entities** (Clean Architecture): +- `WifiNetworkEntity`: WiFi network scans +- `BluetoothDeviceEntity`: Bluetooth device scans +- `CellularTowerEntity`: Cellular tower scans + +**Legacy Entities** (being migrated): +- `WifiNetwork`, `BluetoothDevice`, `BleDevice`, `CellularTower` +- `SensorReading`, `HardwareMetadata`, `RadioManufacturer` +- `Geofence`, `NetworkNote`, `DeviceTag` +- `ApiToken`, `ApiUsage`, `MediaAttachment` + +**Note**: When working with the database, check whether you're modifying legacy entities in `data/Entities.kt` or new entities in `data/database/model/`. Prefer the new structure. + +## Key Dependencies + +**Core** +- Kotlin 2.0.0 with Compose Compiler 1.5.11 +- Jetpack Compose BOM 2024.01.00 (Material 3) +- Hilt 2.48 (using KAPT, not KSP) + +**Database & Persistence** +- Room 2.6.1 with KAPT annotation processing +- AndroidX Security Crypto 1.1.0-alpha06 + +**Networking** +- Retrofit 2.9.0 + OkHttp 4.12.0 +- Kotlin Serialization 1.6.2 + +**Maps** +- Mapbox SDK 11.0.0 (requires `MAPBOX_ACCESS_TOKEN` in AndroidManifest) +- Google Maps SDK 18.2.0 with Compose support + +**Testing** +- JUnit 4.13.2 +- MockK 1.13.8 +- Coroutines Test 1.7.3 + +## Build Configuration Critical Notes + +### Annotation Processing: KAPT vs KSP + +**IMPORTANT**: This project uses KAPT for all annotation processing, NOT KSP. + +```kotlin +// app/build.gradle.kts uses KAPT plugin +plugins { + id("kotlin-kapt") // NOT ksp +} + +dependencies { + kapt("androidx.room:room-compiler:$roomVersion") + kapt("com.google.dagger:hilt-compiler:2.48") +} + +// KAPT configuration in android block +android { + kapt { + correctErrorTypes = true + } +} +``` + +Room and Hilt currently use KAPT. Do not attempt to migrate to KSP without extensive testing. + +### Compiler Flags + +```kotlin +kotlinOptions { + jvmTarget = "17" + freeCompilerArgs += listOf( + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api" + ) +} +``` + +Do not add `-Xskip-prerelease-check` - it's unnecessary with Kotlin 2.0.0. + +### Kotlin Version Mismatch + +- Root `build.gradle.kts` declares Kotlin 2.0.0 +- Serialization plugin uses Kotlin 1.9.22 +- This mismatch is known and works correctly +- Do not "fix" version mismatches without testing + +## Testing Strategy + +### Unit Tests +- Test use cases with MockK for repository mocking +- Test ViewModels with coroutine test dispatcher +- Test repository implementations with fake DAOs + +### Device Testing Required +- WiFi scanning requires physical device WiFi hardware +- Bluetooth/BLE scanning requires Bluetooth adapter +- Cellular scanning requires phone modem +- Location tracking requires GPS +- AR features require camera + +Emulator testing is limited to UI and database operations only. + +## Common Pitfalls & Solutions + +### Decompilation Artifacts +- Generic variable names (`var1`, `var2`) - rename when editing +- Unusual lambda formatting - reformat as needed +- Missing comments - add documentation for complex logic +- Duplicate classes - check both legacy and new packages + +### Build Issues +- **Hilt not generating code**: Run `./gradlew clean build` +- **Room DAO not found**: Ensure KAPT is configured, not KSP +- **Compose compiler errors**: Verify Kotlin 2.0.0 with Compose Compiler 1.5.11 +- **Java version errors**: Strictly use JDK 17, no other version + +### Dual Package Structure +When adding features: +- **NEW code**: Use `com.shadowcheck.mobile` with Hilt DI +- **AVOID**: Creating new files in `com.shadowcheck.mobile.rebuilt` unless explicitly refactoring +- MainActivity is in `rebuilt/presentation/` but new screens go in `ui/screens/` + +### API Keys Configuration +Required in `AndroidManifest.xml`: +```xml + + +``` + +WiGLE API keys are stored encrypted at runtime via `SecureApiKeyManager`. + +## Performance Considerations + +- Database queries use Flow for reactive updates - avoid blocking calls +- Scanner service runs in foreground to prevent Android from killing it +- Large network lists (36k+ entries) use Compose `LazyColumn` for virtualization +- Map markers are culled/clustered for performance with large datasets +- Export operations (CSV, JSON, KML) run in coroutines to avoid blocking UI +- Heavy operations use `Dispatchers.IO`, not `Dispatchers.Main` + +## Security & Privacy + +- Location data stored locally in encrypted SQLite database +- API keys encrypted with AndroidX Security Crypto +- No analytics or tracking (privacy-focused) +- Explicit permission grants required for location, WiFi, Bluetooth +- Foreground service notification required when scanning (Android requirement) + +## Documentation + +Additional docs in `docs/`: +- `DEVELOPMENT.md` - Development workflow +- `FEATURES.md` - Complete feature list +- `archive/` - Historical reconstruction notes diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e69de29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0827c61 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,311 @@ +# Contributing to ShadowCheckMobile + +Thank you for considering contributing to ShadowCheckMobile! This document provides guidelines and instructions for contributing. + +## Getting Started + +### Prerequisites +- Android Studio Hedgehog (2023.1.1) or later +- JDK 17 +- Android SDK 34 +- Git + +### Setting Up Development Environment + +1. **Fork and Clone** + ```bash + git clone https://github.com/yourusername/ShadowCheckMobile.git + cd ShadowCheckMobile + ``` + +2. **Configure Local Properties** + ```bash + cp local.properties.example local.properties + # Edit local.properties and add your API keys + ``` + +3. **Build the Project** + ```bash + ./gradlew clean build + ``` + +4. **Run Tests** + ```bash + ./gradlew test + ``` + +## Development Workflow + +### Branch Naming Convention +- `feature/` - New features (e.g., `feature/bluetooth-scanning`) +- `fix/` - Bug fixes (e.g., `fix/wifi-crash`) +- `refactor/` - Code refactoring (e.g., `refactor/viewmodel-cleanup`) +- `test/` - Adding or updating tests (e.g., `test/wifi-repository`) +- `docs/` - Documentation updates (e.g., `docs/api-guide`) + +### Commit Message Guidelines + +Follow the conventional commits format: + +``` +(): + + + +