Skip to content

Latest commit

 

History

History
2282 lines (1919 loc) · 77 KB

File metadata and controls

2282 lines (1919 loc) · 77 KB

PHASE 1 IMPLEMENTATION GUIDE

Critical API & Repository Fixes - Step-by-Step Instructions


OVERVIEW

Duration: 3 weeks (15 working days) Goal: Fix all critical API implementations, disabled components, and core functionality gaps Success Criteria: All 4 stub-mode connectors functional, all TODO items resolved, all core features working


WEEK 1: CORE CONNECTOR IMPLEMENTATION

DAY 1-2: PLEXCONNECT REPOSITORY IMPLEMENTATION

1.1 Enable PlexRepositoryImpl (Day 1)

File: Connectors/PlexConnect/PlexConnector/src/main/kotlin/com/shareconnect/plexconnect/data/repository/PlexRepositoryImpl.kt

Current State: Completely disabled with TODO comment Target: Fully functional repository with real API calls

Step 1: Remove Disabled Code

// Remove the entire TODO block and comment
// TODO: Fix PlexRepositoryImpl - temporarily disabled for compilation

// Replace stub implementation with proper code:
@Singleton
class PlexRepositoryImpl @Inject constructor(
    private val apiClient: PlexApiClient,
    private val dao: PlexDao,
    private val authManager: PlexAuthManager,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : PlexRepository {

Step 2: Implement connectToServer()

override suspend fun connectToServer(connectionRequest: PlexConnectionRequest): Result<PlexServer> = withContext(ioDispatcher) {
    return@withContext try {
        // Test connection first
        val serverInfo = apiClient.getServerInfo(
            connectionRequest.serverUrl,
            connectionRequest.authToken
        )
        
        if (serverInfo != null) {
            // Save server to database
            val server = PlexServer(
                id = UUID.randomUUID().toString(),
                name = serverInfo.name,
                url = connectionRequest.serverUrl,
                authToken = connectionRequest.authToken,
                version = serverInfo.version,
                machineIdentifier = serverInfo.machineIdentifier,
                createdAt = System.currentTimeMillis(),
                lastConnected = System.currentTimeMillis(),
                isActive = true
            )
            
            dao.insertServer(server)
            Result.success(server)
        } else {
            Result.failure(Exception("Failed to connect to Plex server"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error connecting to Plex server", e)
        Result.failure(e)
    }
}

Step 3: Implement refreshLibrary()

override suspend fun refreshLibrary(serverId: String): Result<List<PlexMediaItem>> = withContext(ioDispatcher) {
    return@withContext try {
        val server = dao.getServerById(serverId)
            ?: throw IllegalArgumentException("Server not found: $serverId")
        
        // Get library sections
        val sections = apiClient.getLibrarySections(server.url, server.authToken)
        if (sections.isEmpty()) {
            Result.success(emptyList())
            return@withContext
        }
        
        // Fetch items from all sections
        val allItems = mutableListOf<PlexMediaItem>()
        for (section in sections) {
            val items = apiClient.getLibraryItems(
                server.url, 
                server.authToken, 
                section.key,
                section.type
            )
            allItems.addAll(items)
        }
        
        // Update local database
        dao.deleteMediaItemsByServer(serverId)
        dao.insertMediaItems(allItems)
        
        Result.success(allItems)
    } catch (e: Exception) {
        Log.e(TAG, "Error refreshing library", e)
        Result.failure(e)
    }
}

Step 4: Implement searchMedia()

override suspend fun searchMedia(serverId: String, query: String): Result<List<PlexMediaItem>> = withContext(ioDispatcher) {
    return@withContext try {
        val server = dao.getServerById(serverId)
            ?: throw IllegalArgumentException("Server not found: $serverId")
        
        val searchResults = apiClient.search(
            server.url,
            server.authToken,
            query
        )
        
        Result.success(searchResults)
    } catch (e: Exception) {
        Log.e(TAG, "Error searching media", e)
        Result.failure(e)
    }
}

Step 5: Implement Additional Methods

override suspend fun getMediaItems(serverId: String, sectionKey: String): Result<List<PlexMediaItem>> = withContext(ioDispatcher) {
    return@withContext try {
        val server = dao.getServerById(serverId)
            ?: throw IllegalArgumentException("Server not found: $serverId")
        
        val items = apiClient.getLibraryItems(
            server.url,
            server.authToken,
            sectionKey,
            "movie" // Default to movies, can be parameterized
        )
        
        Result.success(items)
    } catch (e: Exception) {
        Log.e(TAG, "Error getting media items", e)
        Result.failure(e)
    }
}

override suspend fun playMedia(serverId: String, mediaId: String): Result<String> = withContext(ioDispatcher) {
    return@withContext try {
        val server = dao.getServerById(serverId)
            ?: throw IllegalArgumentException("Server not found: $serverId")
        
        val playUrl = apiClient.createPlayUrl(
            server.url,
            server.authToken,
            mediaId
        )
        
        Result.success(playUrl)
    } catch (e: Exception) {
        Log.e(TAG, "Error playing media", e)
        Result.failure(e)
    }
}

Step 6: Update Dependency Injection

// File: Connectors/PlexConnect/PlexConnector/src/main/kotlin/com/shareconnect/plexconnect/di/RepositoryModule.kt

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    
    @Provides
    @Singleton
    fun providePlexRepository(
        apiClient: PlexApiClient,
        dao: PlexDao,
        authManager: PlexAuthManager,
        @IoDispatcher ioDispatcher: CoroutineDispatcher
    ): PlexRepository {
        return PlexRepositoryImpl(apiClient, dao, authManager, ioDispatcher)
    }
}

1.2 Test PlexRepositoryImpl (Day 2)

Step 1: Create Unit Tests

// File: Connectors/PlexConnect/PlexConnector/src/test/kotlin/com/shareconnect/plexconnect/data/repository/PlexRepositoryImplTest.kt

@ExperimentalCoroutinesApi
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class PlexRepositoryImplTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @MockK
    private lateinit var mockApi: PlexApiClient

    @MockK
    private lateinit var mockDao: PlexDao

    @MockK
    private lateinit var mockAuthManager: PlexAuthManager

    private lateinit var repository: PlexRepositoryImpl

    @Before
    fun setup() {
        MockKAnnotations.init(this)
        repository = PlexRepositoryImpl(mockApi, mockDao, mockAuthManager, StandardTestDispatcher)
    }

    @Test
    fun `connectToServer should save server and return success`() = runTest {
        // Given
        val connectionRequest = PlexConnectionRequest(
            serverUrl = "http://plex-server:32400",
            authToken = "test-token"
        )
        val serverInfo = PlexServerInfo(
            name = "Test Server",
            version = "1.0.0",
            machineIdentifier = "test-machine-id"
        )
        
        coEvery { mockApi.getServerInfo(connectionRequest.serverUrl, connectionRequest.authToken) } returns serverInfo
        coEvery { mockDao.insertServer(any()) } just Runs

        // When
        val result = repository.connectToServer(connectionRequest)

        // Then
        assertTrue(result.isSuccess)
        val server = result.getOrNull()
        assertNotNull(server)
        assertEquals("Test Server", server?.name)
        assertEquals(connectionRequest.serverUrl, server?.url)
        assertEquals(connectionRequest.authToken, server?.authToken)
        
        coVerify { mockApi.getServerInfo(connectionRequest.serverUrl, connectionRequest.authToken) }
        coVerify { mockDao.insertServer(any()) }
    }

    @Test
    fun `refreshLibrary should fetch and save media items`() = runTest {
        // Given
        val serverId = "test-server-id"
        val server = PlexServer(
            id = serverId,
            name = "Test Server",
            url = "http://plex-server:32400",
            authToken = "test-token"
        )
        val sections = listOf(
            PlexLibrarySection(key = "1", title = "Movies", type = "movie"),
            PlexLibrarySection(key = "2", title = "TV Shows", type = "show")
        )
        val mediaItems = listOf(
            PlexMediaItem(id = "1", title = "Test Movie", type = "movie"),
            PlexMediaItem(id = "2", title = "Test Show", type = "show")
        )
        
        coEvery { mockDao.getServerById(serverId) } returns server
        coEvery { mockApi.getLibrarySections(server.url, server.authToken) } returns sections
        coEvery { mockApi.getLibraryItems(server.url, server.authToken, "1", "movie") } returns listOf(mediaItems[0])
        coEvery { mockApi.getLibraryItems(server.url, server.authToken, "2", "show") } returns listOf(mediaItems[1])
        coEvery { mockDao.deleteMediaItemsByServer(serverId) } just Runs
        coEvery { mockDao.insertMediaItems(mediaItems) } just Runs

        // When
        val result = repository.refreshLibrary(serverId)

        // Then
        assertTrue(result.isSuccess)
        val items = result.getOrNull()
        assertEquals(2, items?.size)
        
        coVerify { mockDao.deleteMediaItemsByServer(serverId) }
        coVerify { mockDao.insertMediaItems(mediaItems) }
    }
}

Step 2: Run Tests

cd Connectors/PlexConnect
./gradlew test --tests "PlexRepositoryImplTest"

Step 3: Integration Test

# Build and install on device/emulator
./gradlew assembleDebug

# Run on device to verify API connectivity
adb install -r PlexConnector/build/outputs/apk/debug/PlexConnector-debug.apk

DAY 3-4: NEXTCLOUDCONNECT WEBDAV IMPLEMENTATION

3.1 Implement OAuth2 Authentication (Day 3)

File: Connectors/NextcloudConnect/NextcloudConnector/src/main/kotlin/com/shareconnect/nextcloudconnect/data/api/NextcloudApiClient.kt

Step 1: Remove Stub Mode

// Remove stub mode declaration
// Currently: "STUB MODE - using test data"

// Replace with real API client:
@Singleton
class NextcloudApiClient @Inject constructor(
    private val httpClient: OkHttpClient,
    private val gson: Gson,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) {
    companion object {
        private const val TAG = "NextcloudApiClient"
        private const val OAUTH_ENDPOINT = "/index.php/apps/oauth2/api/v1"
    }
}

Step 2: Implement OAuth2 Flow

suspend fun initiateOAuth(serverUrl: String, clientId: String): Result<String> = withContext(ioDispatcher) {
    return@withContext try {
        val authUrl = "$serverUrl$OAUTH_ENDPOINT/authorize?" +
                "client_id=$clientId&" +
                "response_type=code&" +
                "redirect_uri=shareconnect://oauth&" +
                "scope=files read write"
        
        Result.success(authUrl)
    } catch (e: Exception) {
        Log.e(TAG, "Error initiating OAuth", e)
        Result.failure(e)
    }
}

suspend fun exchangeCodeForToken(
    serverUrl: String, 
    clientId: String, 
    clientSecret: String, 
    code: String
): Result<NextcloudAuthToken> = withContext(ioDispatcher) {
    return@withContext try {
        val tokenUrl = "$serverUrl$OAUTH_ENDPOINT/token"
        
        val formBody = FormBody.Builder()
            .add("client_id", clientId)
            .add("client_secret", clientSecret)
            .add("grant_type", "authorization_code")
            .add("redirect_uri", "shareconnect://oauth")
            .add("code", code)
            .build()
        
        val request = Request.Builder()
            .url(tokenUrl)
            .post(formBody)
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val tokenResponse = gson.fromJson(response.body?.charStream(), NextcloudAuthToken::class.java)
            Result.success(tokenResponse)
        } else {
            Result.failure(Exception("Token exchange failed: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error exchanging code for token", e)
        Result.failure(e)
    }
}

Step 3: Implement WebDAV Operations

suspend fun listFiles(path: String = "/"): Result<List<NextcloudFile>> = withContext(ioDispatcher) {
    return@withContext try {
        val request = Request.Builder()
            .url("${buildBaseUrl()}/remote.php/dav/files/${currentUser?.id}$path")
            .header("Authorization", "Bearer ${authToken?.accessToken}")
            .header("Depth", "1")
            .method("PROPFIND", "".toRequestBody())
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val xmlResponse = response.body?.string() ?: ""
            val files = parseWebDavResponse(xmlResponse)
            Result.success(files)
        } else {
            Result.failure(Exception("Failed to list files: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error listing files", e)
        Result.failure(e)
    }
}

suspend fun uploadFile(localPath: String, remotePath: String): Result<String> = withContext(ioDispatcher) {
    return@withContext try {
        val file = File(localPath)
        if (!file.exists()) {
            return@withContext Result.failure(Exception("File not found: $localPath"))
        }
        
        val requestBody = file.asRequestBody("application/octet-stream".toMediaType())
        val request = Request.Builder()
            .url("${buildBaseUrl()}/remote.php/dav/files/${currentUser?.id}$remotePath")
            .header("Authorization", "Bearer ${authToken?.accessToken}")
            .put(requestBody)
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            Result.success("Upload successful")
        } else {
            Result.failure(Exception("Upload failed: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error uploading file", e)
        Result.failure(e)
    }
}

suspend fun downloadFile(remotePath: String, localPath: String): Result<String> = withContext(ioDispatcher) {
    return@withContext try {
        val request = Request.Builder()
            .url("${buildBaseUrl()}/remote.php/dav/files/${currentUser?.id}$remotePath")
            .header("Authorization", "Bearer ${authToken?.accessToken}")
            .get()
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val outputFile = File(localPath)
            outputFile.parentFile?.mkdirs()
            
            response.body?.byteStream()?.use { input ->
                outputFile.outputStream().use { output ->
                    input.copyTo(output)
                }
            }
            Result.success(localPath)
        } else {
            Result.failure(Exception("Download failed: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error downloading file", e)
        Result.failure(e)
    }
}

3.2 Complete Repository Implementation (Day 4)

File: Connectors/NextcloudConnect/NextcloudConnector/src/main/kotlin/com/shareconnect/nextcloudconnect/data/repository/NextcloudRepositoryImpl.kt

Step 1: Implement Repository Methods

@Singleton
class NextcloudRepositoryImpl @Inject constructor(
    private val apiClient: NextcloudApiClient,
    private val dao: NextcloudDao,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : NextcloudRepository {

    override suspend fun authenticate(serverUrl: String, credentials: NextcloudCredentials): Result<NextcloudAuthResult> = withContext(ioDispatcher) {
        return@withContext try {
            // Start OAuth flow
            val authUrl = apiClient.initiateOAuth(serverUrl, credentials.clientId)
            
            // For now, return auth URL for user to complete
            // In production, this would be handled by OAuth callback
            Result.success(NextcloudAuthResult(
                authUrl = authUrl.getOrThrow(),
                requiresUserAction = true
            ))
        } catch (e: Exception) {
            Log.e(TAG, "Authentication failed", e)
            Result.failure(e)
        }
    }

    override suspend fun listFiles(path: String): Result<List<NextcloudFile>> = withContext(ioDispatcher) {
        return@withContext try {
            val files = apiClient.listFiles(path)
            
            // Cache results in local database
            files.onSuccess { fileList ->
                dao.insertFiles(fileList)
            }
            
            files
        } catch (e: Exception) {
            Log.e(TAG, "Error listing files", e)
            Result.failure(e)
        }
    }

    override suspend fun uploadFile(localPath: String, remotePath: String): Result<String> = withContext(ioDispatcher) {
        return@withContext try {
            val result = apiClient.uploadFile(localPath, remotePath)
            
            // Update local cache on success
            result.onSuccess {
                // Refresh file listing for the directory
                val directoryPath = remotePath.substringBeforeLast("/")
                listFiles(directoryPath)
            }
            
            result
        } catch (e: Exception) {
            Log.e(TAG, "Error uploading file", e)
            Result.failure(e)
        }
    }

    override suspend fun downloadFile(remotePath: String): Result<String> = withContext(ioDispatcher) {
        return@withContext try {
            // Generate local path for download
            val fileName = remotePath.substringAfterLast("/")
            val localPath = "${Environment.getExternalStorageDirectory()}/ShareConnect/Nextcloud/$fileName"
            
            val result = apiClient.downloadFile(remotePath, localPath)
            
            // Update download history
            result.onSuccess {
                dao.insertDownloadRecord(
                    NextcloudDownloadRecord(
                        id = UUID.randomUUID().toString(),
                        remotePath = remotePath,
                        localPath = localPath,
                        downloadTime = System.currentTimeMillis(),
                        status = "completed"
                    )
                )
            }
            
            result
        } catch (e: Exception) {
            Log.e(TAG, "Error downloading file", e)
            Result.failure(e)
        }
    }
}

DAY 5: MATRIXCONNECT SYNC SERVICE

File: Connectors/MatrixConnect/MatrixConnector/src/main/kotlin/com/shareconnect/matrixconnect/sync/MatrixSyncService.kt

Step 1: Implement Continuous Sync Loop

// Current TODO: "Implement continuous sync loop"

// Replace with:
@Singleton
class MatrixSyncService @Inject constructor(
    private val matrixClient: MatrixClient,
    private val syncManager: MatrixSyncManager,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
    @ApplicationScope private val applicationScope: CoroutineScope
) : Service() {

    companion object {
        private const val TAG = "MatrixSyncService"
        private const val SYNC_INTERVAL_MS = 5000L // 5 seconds
        private const val NOTIFICATION_ID = 1001
    }

    private var syncJob: Job? = null
    private val binder = LocalBinder()

    inner class LocalBinder : Binder() {
        fun getService(): MatrixSyncService = this@MatrixSyncService
    }

    override fun onBind(intent: Intent): IBinder = binder

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        startSync()
        return START_STICKY
    }

    private fun startSync() {
        if (syncJob?.isActive == true) {
            Log.d(TAG, "Sync already running")
            return
        }

        syncJob = applicationScope.launch(ioDispatcher) {
            Log.d(TAG, "Starting continuous Matrix sync")
            
            while (isActive) {
                try {
                    performSync()
                    delay(SYNC_INTERVAL_MS)
                } catch (e: Exception) {
                    Log.e(TAG, "Error in sync loop", e)
                    // Exponential backoff on error
                    delay(SYNC_INTERVAL_MS * 2)
                }
            }
        }

        startForeground(NOTIFICATION_ID, createNotification())
    }

    private suspend fun performSync() {
        try {
            // Get sync token from last sync
            val lastSyncToken = syncManager.getLastSyncToken()
            
            // Perform incremental sync
            val syncResponse = matrixClient.sync(
                since = lastSyncToken,
                timeout = 30000L
            )
            
            if (syncResponse.isSuccessful) {
                val syncData = syncResponse.body!!
                
                // Process room events
                processRoomEvents(syncData.rooms?.join ?: emptyMap())
                processRoomEvents(syncData.rooms?.leave ?: emptyMap())
                
                // Save next sync token
                syncData.nextBatch?.let { token ->
                    syncManager.saveSyncToken(token)
                }
                
                Log.d(TAG, "Sync completed successfully")
            } else {
                Log.e(TAG, "Sync failed: ${syncResponse.code}")
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error performing sync", e)
            throw e
        }
    }

    private suspend fun processRoomEvents(rooms: Map<String, MatrixRoomData>) {
        rooms.forEach { (roomId, roomData) ->
            try {
                // Process timeline events
                roomData.timeline?.events?.forEach { event ->
                    when (event.type) {
                        "m.room.message" -> {
                            processMessageEvent(roomId, event)
                        }
                        "m.room.name" -> {
                            processRoomNameEvent(roomId, event)
                        }
                        "m.room.topic" -> {
                            processRoomTopicEvent(roomId, event)
                        }
                    }
                }
                
                // Update room information
                syncManager.updateRoomInfo(roomId, roomData)
            } catch (e: Exception) {
                Log.e(TAG, "Error processing room $roomId", e)
            }
        }
    }

    private suspend fun processMessageEvent(roomId: String, event: MatrixEvent) {
        try {
            val message = MatrixMessage(
                id = event.eventId,
                roomId = roomId,
                senderId = event.sender ?: "",
                content = event.content,
                timestamp = event.originServerTs ?: 0,
                type = event.content?.get("msgtype")?.asString ?: "m.text"
            )
            
            syncManager.saveMessage(message)
            Log.d(TAG, "Saved message: ${message.id}")
        } catch (e: Exception) {
            Log.e(TAG, "Error processing message event", e)
        }
    }

    private suspend fun processRoomNameEvent(roomId: String, event: MatrixEvent) {
        try {
            val roomName = event.content?.get("name")?.asString
            roomName?.let {
                syncManager.updateRoomName(roomId, it)
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error processing room name event", e)
        }
    }

    private suspend fun processRoomTopicEvent(roomId: String, event: MatrixEvent) {
        try {
            val roomTopic = event.content?.get("topic")?.asString
            roomTopic?.let {
                syncManager.updateRoomTopic(roomId, it)
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error processing room topic event", e)
        }
    }

    private fun createNotification(): Notification {
        val channelId = "matrix_sync_channel"
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                channelId,
                "Matrix Sync",
                NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = "Matrix synchronization service"
                setShowBadge(false)
            }
            
            val notificationManager = getSystemService(NotificationManager::class.java)
            notificationManager.createNotificationChannel(channel)
        }

        return NotificationCompat.Builder(this, channelId)
            .setContentTitle("Matrix Sync Active")
            .setContentText("Synchronizing Matrix messages")
            .setSmallIcon(R.drawable.ic_sync)
            .setOngoing(true)
            .build()
    }

    override fun onDestroy() {
        super.onDestroy()
        syncJob?.cancel()
        Log.d(TAG, "MatrixSyncService destroyed")
    }
}

Step 2: Update AndroidManifest.xml

<!-- File: Connectors/MatrixConnect/MatrixConnector/src/main/AndroidManifest.xml -->
<service
    android:name=".sync.MatrixSyncService"
    android:enabled="true"
    android:exported="false"
    android:foregroundServiceType="dataSync" />

Step 3: Test Sync Service

// File: Connectors/MatrixConnect/MatrixConnector/src/test/kotlin/com/shareconnect/matrixconnect/sync/MatrixSyncServiceTest.kt

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class MatrixSyncServiceTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @MockK
    private lateinit var mockMatrixClient: MatrixClient

    @MockK
    private lateinit var mockSyncManager: MatrixSyncManager

    private lateinit var context: Context
    private lateinit var service: MatrixSyncService

    @Before
    fun setup() {
        MockKAnnotations.init(this)
        context = ApplicationProvider.getApplicationContext()
        
        service = MatrixSyncService()
        service.onCreate()
    }

    @Test
    fun `sync service should process room events correctly`() = runTest {
        // Given
        val syncResponse = MatrixSyncResponse(
            nextBatch = "s1234",
            rooms = MatrixRoomsData(
                join = mapOf(
                    "!room1:matrix.org" to MatrixRoomData(
                        timeline = MatrixTimelineData(
                            events = listOf(
                                MatrixEvent(
                                    eventId = "$event1",
                                    type = "m.room.message",
                                    sender = "@user:matrix.org",
                                    content = mapOf(
                                        "msgtype" to "m.text",
                                        "body" to "Hello World"
                                    )
                                )
                            )
                        )
                    )
                )
            )
        )
        
        coEvery { mockMatrixClient.sync(any(), any()) } returns Response.success(syncResponse)
        coEvery { mockSyncManager.getLastSyncToken() } returns null
        coEvery { mockSyncManager.saveSyncToken(any()) } just Runs
        coEvery { mockSyncManager.saveMessage(any()) } just Runs
        coEvery { mockSyncManager.updateRoomInfo(any(), any()) } just Runs

        // When
        service.startSync()

        // Wait for sync to complete
        delay(1000)

        // Then
        coVerify { mockMatrixClient.sync(any(), any()) }
        coVerify { mockSyncManager.saveSyncToken("s1234") }
        coVerify { mockSyncManager.saveMessage(any()) }
    }
}

WEEK 2: ADDITIONAL CONNECTOR IMPLEMENTATIONS

DAY 6-7: MOTRIXCONNECT & GITEACONNECT APIs

6.1 MotrixConnect Aria2 Implementation (Day 6)

File: Connectors/MotrixConnect/MotrixConnector/src/main/kotlin/com/shareconnect/motrixconnect/data/api/MotrixApiClient.kt

Step 1: Remove Stub Mode

// Remove "STUB MODE - using test data"

// Replace with real Aria2 JSON-RPC client:
@Singleton
class MotrixApiClient @Inject constructor(
    private val httpClient: OkHttpClient,
    private val gson: Gson,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) {
    companion object {
        private const val TAG = "MotrixApiClient"
        private const val JSON_RPC_VERSION = "2.0"
    }
}

Step 2: Implement JSON-RPC Client

data class JsonRpcRequest(
    val jsonrpc: String = JSON_RPC_VERSION,
    val method: String,
    val params: List<Any> = emptyList(),
    val id: String = UUID.randomUUID().toString()
)

data class JsonRpcResponse<T>(
    val id: String,
    val jsonrpc: String,
    val result: T? = null,
    val error: JsonRpcError? = null
)

data class JsonRpcError(
    val code: Int,
    val message: String,
    val data: Any? = null
)

private suspend fun <T> executeJsonRpc(request: JsonRpcRequest, responseType: Class<T>): Result<T> = withContext(ioDispatcher) {
    return@withContext try {
        val requestBody = gson.toJson(request).toRequestBody("application/json".toMediaType())
        
        val httpRequest = Request.Builder()
            .url("${baseUrl}/jsonrpc")
            .post(requestBody)
            .header("Authorization", "Bearer $authToken")
            .build()
        
        val response = httpClient.newCall(httpRequest).execute()
        if (response.isSuccessful) {
            val responseBody = response.body?.string() ?: ""
            val rpcResponse = gson.fromJson(responseBody, JsonRpcResponse::class.java)
            
            if (rpcResponse.error != null) {
                Result.failure(Exception("JSON-RPC Error: ${rpcResponse.error.message}"))
            } else {
                @Suppress("UNCHECKED_CAST")
                val result = rpcResponse.result as? T
                if (result != null) {
                    Result.success(result)
                } else {
                    Result.failure(Exception("Invalid response format"))
                }
            }
        } else {
            Result.failure(Exception("HTTP Error: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error executing JSON-RPC request", e)
        Result.failure(e)
    }
}

Step 3: Implement Aria2 Methods

suspend fun getGlobalStat(): Result<Aria2GlobalStat> = withContext(ioDispatcher) {
    val request = JsonRpcRequest(method = "aria2.getGlobalStat")
    return@withContext executeJsonRpc(request, Aria2GlobalStat::class.java)
}

suspend fun tellActive(): Result<List<Aria2Download>> = withContext(ioDispatcher) {
    val request = JsonRpcRequest(method = "aria2.tellActive")
    return@withContext executeJsonRpc(request, Aria2DownloadList::class.java).map { it.downloads }
}

suspend fun addUri(uris: List<String>, options: Map<String, Any> = emptyMap()): Result<String> = withContext(ioDispatcher) {
    val params = mutableListOf<Any>()
    params.add(uris)
    if (options.isNotEmpty()) {
        params.add(options)
    }
    
    val request = JsonRpcRequest(method = "aria2.addUri", params = params)
    return@withContext executeJsonRpc(request, String::class.java)
}

suspend fun tellStatus(gid: String): Result<Aria2Download> = withContext(ioDispatcher) {
    val request = JsonRpcRequest(method = "aria2.tellStatus", params = listOf(gid))
    return@withContext executeJsonRpc(request, Aria2Download::class.java)
}

suspend fun pause(gid: String): Result<String> = withContext(ioDispatcher) {
    val request = JsonRpcRequest(method = "aria2.pause", params = listOf(gid))
    return@withContext executeJsonRpc(request, String::class.java)
}

suspend fun unpause(gid: String): Result<String> = withContext(ioDispatcher) {
    val request = JsonRpcRequest(method = "aria2.unpause", params = listOf(gid))
    return@withContext executeJsonRpc(request, String::class.java)
}

suspend fun remove(gid: String): Result<String> = withContext(ioDispatcher) {
    val request = JsonRpcRequest(method = "aria2.remove", params = listOf(gid))
    return@withContext executeJsonRpc(request, String::class.java)
}

suspend fun getFiles(gid: String): Result<List<Aria2File>> = withContext(ioDispatcher) {
    val request = JsonRpcRequest(method = "aria2.getFiles", params = listOf(gid))
    return@withContext executeJsonRpc(request, Aria2FileList::class.java).map { it.files }
}

6.2 GiteaConnect REST API Implementation (Day 7)

File: Connectors/GiteaConnect/GiteaConnector/src/main/kotlin/com/shareconnect/giteaconnect/data/api/GiteaApiClient.kt

Step 1: Remove Stub Mode

// Remove stub mode and implement real Gitea REST API client
@Singleton
class GiteaApiClient @Inject constructor(
    private val httpClient: OkHttpClient,
    private val gson: Gson,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) {
    companion object {
        private const val TAG = "GiteaApiClient"
        private const val API_VERSION = "v1"
    }
}

Step 2: Implement Authentication

suspend fun authenticate(serverUrl: String, username: String, password: String, token: String? = null): Result<GiteaUser> = withContext(ioDispatcher) {
    return@withContext try {
        val authHeader = if (token != null) {
            "token $token"
        } else {
            val credentials = Credentials.basic(username, password)
            credentials
        }
        
        val request = Request.Builder()
            .url("$serverUrl/api/$API_VERSION/user")
            .header("Authorization", authHeader)
            .get()
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val user = gson.fromJson(response.body?.charStream(), GiteaUser::class.java)
            Result.success(user)
        } else {
            Result.failure(Exception("Authentication failed: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error authenticating with Gitea", e)
        Result.failure(e)
    }
}

suspend fun createPersonalAccessToken(serverUrl: String, username: String, password: String, tokenName: String): Result<GiteaAccessToken> = withContext(ioDispatcher) {
    return@withContext try {
        val authHeader = Credentials.basic(username, password)
        
        val tokenRequest = CreateTokenRequest(
            name = tokenName,
            scopes = listOf("repo", "write:repository", "read:repository")
        )
        
        val requestBody = gson.toJson(tokenRequest).toRequestBody("application/json".toMediaType())
        
        val request = Request.Builder()
            .url("$serverUrl/api/$API_VERSION/users/$username/tokens")
            .header("Authorization", authHeader)
            .post(requestBody)
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val token = gson.fromJson(response.body?.charStream(), GiteaAccessToken::class.java)
            Result.success(token)
        } else {
            Result.failure(Exception("Token creation failed: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error creating access token", e)
        Result.failure(e)
    }
}

Step 3: Implement Repository Operations

suspend fun getUserRepositories(username: String): Result<List<GiteaRepository>> = withContext(ioDispatcher) {
    return@withContext try {
        val request = Request.Builder()
            .url("${buildBaseUrl()}/api/$API_VERSION/users/$username/repos")
            .header("Authorization", "Bearer $authToken")
            .get()
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val repositoriesType = object : TypeToken<List<GiteaRepository>>() {}.type
            val repositories = gson.fromJson<List<GiteaRepository>>(response.body?.charStream(), repositoriesType)
            Result.success(repositories)
        } else {
            Result.failure(Exception("Failed to get repositories: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error getting repositories", e)
        Result.failure(e)
    }
}

suspend fun getRepository(owner: String, repo: String): Result<GiteaRepository> = withContext(ioDispatcher) {
    return@withContext try {
        val request = Request.Builder()
            .url("${buildBaseUrl()}/api/$API_VERSION/repos/$owner/$repo")
            .header("Authorization", "Bearer $authToken")
            .get()
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val repository = gson.fromJson(response.body?.charStream(), GiteaRepository::class.java)
            Result.success(repository)
        } else {
            Result.failure(Exception("Failed to get repository: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error getting repository", e)
        Result.failure(e)
    }
}

suspend fun createRepository(repoRequest: CreateRepositoryRequest): Result<GiteaRepository> = withContext(ioDispatcher) {
    return@withContext try {
        val requestBody = gson.toJson(repoRequest).toRequestBody("application/json".toMediaType())
        
        val request = Request.Builder()
            .url("${buildBaseUrl()}/api/$API_VERSION/user/repos")
            .header("Authorization", "Bearer $authToken")
            .post(requestBody)
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val repository = gson.fromJson(response.body?.charStream(), GiteaRepository::class.java)
            Result.success(repository)
        } else {
            Result.failure(Exception("Failed to create repository: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error creating repository", e)
        Result.failure(e)
    }
}

suspend fun getRepositoryContents(owner: String, repo: String, path: String = ""): Result<List<GiteaContent>> = withContext(ioDispatcher) {
    return@withContext try {
        val encodedPath = if (path.isNotEmpty()) URLEncoder.encode(path, "UTF-8") else ""
        val request = Request.Builder()
            .url("${buildBaseUrl()}/api/$API_VERSION/repos/$owner/$repo/contents/$encodedPath")
            .header("Authorization", "Bearer $authToken")
            .get()
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val responseBody = response.body?.string()
            if (responseBody?.startsWith("[") == true) {
                // Directory listing
                val contentType = object : TypeToken<List<GiteaContent>>() {}.type
                val contents = gson.fromJson<List<GiteaContent>>(responseBody, contentType)
                Result.success(contents)
            } else {
                // Single file
                val content = gson.fromJson(responseBody, GiteaContent::class.java)
                Result.success(listOf(content))
            }
        } else {
            Result.failure(Exception("Failed to get repository contents: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error getting repository contents", e)
        Result.failure(e)
    }
}

DAY 8-9: REPOSITORY PATTERN COMPLETION

8.1 qBitConnect Repository Fixes (Day 8)

File: Connectors/qBitConnect/qBitConnector/src/main/kotlin/com/shareconnect/qbitconnect/data/repositories/TorrentRepository.kt

Step 1: Implement categorySetting()

// Current TODO: "// TODO: Implement category setting"

// Replace with:
override suspend fun setTorrentCategory(hash: String, category: String): Result<Unit> = withContext(ioDispatcher) {
    return@withContext try {
        val success = apiClient.setTorrentCategory(hash, category)
        if (success) {
            // Update local database
            dao.updateTorrentCategory(hash, category)
            Result.success(Unit)
        } else {
            Result.failure(Exception("Failed to set torrent category"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error setting torrent category", e)
        Result.failure(e)
    }
}

File: Connectors/qBitConnect/qBitConnector/src/main/kotlin/com/shareconnect/qbitconnect/data/repositories/RSSRepository.kt

Step 2: Implement addFeed()

// Current TODO: "// TODO: Implement RSS feed addition"

// Replace with:
override suspend fun addFeed(feedUrl: String, folder: String? = null): Result<RssFeed> = withContext(ioDispatcher) {
    return@withContext try {
        val feedRequest = RssFeedRequest(
            url = feedUrl,
            folder = folder
        )
        
        val success = apiClient.addRssFeed(feedRequest)
        if (success) {
            // Refresh feeds to get the new one
            val feeds = apiClient.getRssFeeds()
            val newFeed = feeds.find { it.url == feedUrl }
            
            if (newFeed != null) {
                dao.insertRssFeed(newFeed)
                Result.success(newFeed)
            } else {
                Result.failure(Exception("RSS feed not found after addition"))
            }
        } else {
            Result.failure(Exception("Failed to add RSS feed"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error adding RSS feed", e)
        Result.failure(e)
    }
}

File: Connectors/qBitConnect/qBitConnector/src/main/kotlin/com/shareconnect/qbitconnect/data/repositories/SearchRepository.kt

Step 3: Implement search plugin refresh

// Current TODO: "// TODO: Implement actual API call to refresh search plugins"

// Replace with:
override suspend fun refreshSearchPlugins(): Result<List<SearchPlugin>> = withContext(ioDispatcher) {
    return@withContext try {
        val plugins = apiClient.getSearchPlugins()
        
        // Update local database
        dao.deleteAllSearchPlugins()
        dao.insertSearchPlugins(plugins)
        
        Result.success(plugins)
    } catch (e: Exception) {
        Log.e(TAG, "Error refreshing search plugins", e)
        Result.failure(e)
    }
}

File: Connectors/qBitConnect/qBitConnector/src/main/kotlin/com/shareconnect/qbitconnect/data/repositories/ServerRepository.kt

Step 4: Implement connection testing

// Current TODO: "// TODO: Implement actual connection test"

// Replace with:
override suspend fun testConnection(serverUrl: String, username: String, password: String): Result<ServerConnectionInfo> = withContext(ioDispatcher) {
    return@withContext try {
        // Try to authenticate first
        val authResult = apiClient.authenticate(serverUrl, username, password)
        if (authResult) {
            // Get server info
            val version = apiClient.getVersion()
            val buildInfo = apiClient.getBuildInfo()
            
            val connectionInfo = ServerConnectionInfo(
                serverUrl = serverUrl,
                version = version,
                buildInfo = buildInfo,
                isAuthenticated = true,
                timestamp = System.currentTimeMillis()
            )
            
            Result.success(connectionInfo)
        } else {
            Result.failure(Exception("Authentication failed"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error testing connection", e)
        Result.failure(e)
    }
}

File: Connectors/qBitConnect/qBitConnector/src/main/kotlin/com/shareconnect/qbitconnect/ui/viewmodels/SearchViewModel.kt

Step 5: Implement torrent download from search

// Current TODO: "// TODO: Implement torrent download from search result"

// Replace with:
fun downloadSearchResult(result: SearchResult, savePath: String? = null, category: String? = null) {
    viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true) }
        
        try {
            val downloadRequest = TorrentDownloadRequest(
                urls = listOf(result.fileUrl),
                savePath = savePath ?: currentServer?.downloadPath ?: "",
                category = category
            )
            
            val success = torrentRepository.addTorrent(downloadRequest)
            
            if (success.isSuccess) {
                _uiState.update { it.copy(
                    isLoading = false,
                    message = "Torrent added successfully"
                ) }
                
                // Refresh search results to update status
                searchTorrents(currentQuery, currentFilters)
            } else {
                _uiState.update { it.copy(
                    isLoading = false,
                    error = "Failed to add torrent: ${success.exceptionOrNull()?.message}"
                ) }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error downloading search result", e)
            _uiState.update { it.copy(
                isLoading = false,
                error = "Error downloading torrent: ${e.message}"
            ) }
        }
    }
}

8.2 Mock Server Implementations (Day 9)

File: Connectors/qBitConnect/qBitConnector/src/test/kotlin/com/shareconnect/qbitconnect/mockserver/QBittorrentMockServer.kt

Step 1: Fix torrent-start-now implementation

// Current TODO: "torrent-start-now" command

// Replace with:
when (path) {
    "/api/v2/torrents/start" -> {
        val requestBody = request.body.readUtf8()
        val hashes = requestBody.split("hashes=")[1].split("&")[0]
        
        // Simulate torrent start
        mockTorrentState[hashCode()] = mockTorrentState[hashCode()]?.copy(
            state = "downloading",
            speed = 1048576 // 1MB/s
        )
        
        MockResponse()
            .setBody("OK")
            .setResponseCode(200)
    }
    
    "/api/v2/torrents/startNow" -> {
        val requestBody = request.body.readUtf8()
        val hashes = requestBody.split("hashes=")[1].split("&")[0]
        
        // Simulate immediate torrent start
        mockTorrentState[hashCode()] = mockTorrentState[hashCode()]?.copy(
            state = "downloading",
            speed = 1048576, // 1MB/s
            forceStart = true
        )
        
        MockResponse()
            .setBody("OK")
            .setResponseCode(200)
    }
}

File: Connectors/TransmissionConnect/TransmissionConnector/src/test/kotlin/com/shareconnect/transmissionconnect/mockserver/TransmissionMockServer.kt

Step 2: Fix torrent-start-now implementation

// Current TODO: "torrent-start-now" command

// Replace with:
private fun handleTorrentStartNow(requestBody: String): String {
    val gson = Gson()
    val request = gson.fromJson(requestBody, TransmissionRequest::class.java)
    
    val arguments = request.arguments as Map<String, Any>
    val ids = arguments["ids"] as List<String>
    
    // Force start torrents immediately
    ids.forEach { id ->
        mockTorrentState[id] = mockTorrentState[id]?.copy(
            status = 4, // DOWNLOADING status
            rateDownload = 1048576, // 1MB/s
            forceStart = true
        )
    }
    
    return gson.toJson(TransmissionResponse(
        result = "success",
        arguments = emptyMap<String, Any>()
    ))
}

WEEK 3: JDOWNLOADERCONNECT & SEAFILECONNECT

DAY 10-12: JDOWNLOADERCONNECT INTEGRATION

10.1 My.JDownloader REST API Integration (Day 10)

File: Connectors/JDownloaderConnect/JDownloaderConnector/src/main/kotlin/com/shareconnect/jdownloaderconnect/data/api/JDownloaderApiClient.kt

Step 1: Remove Stub Mode

// Replace with real My.JDownloader API client
@Singleton
class JDownloaderApiClient @Inject constructor(
    private val httpClient: OkHttpClient,
    private val gson: Gson,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) {
    companion object {
        private const val TAG = "JDownloaderApiClient"
        private const val BASE_URL = "https://api.jdownloader.org"
        private const val APP_KEY = "shareconnect"
        private const val API_VERSION = "1"
    }
}

Step 2: Implement Device Registration

suspend fun registerDevice(email: String, password: String): Result<JDownloaderDevice> = withContext(ioDispatcher) {
    return@withContext try {
        val connectionToken = generateConnectionToken()
        
        val registrationRequest = mapOf(
            "email" to email,
            "password" to password,
            "appkey" to APP_KEY,
            "connuuid" to connectionToken
        )
        
        val requestBody = gson.toJson(registrationRequest).toRequestBody("application/json".toMediaType())
        
        val request = Request.Builder()
            .url("$BASE_URL/my/connect")
            .post(requestBody)
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val responseBody = response.body?.string() ?: ""
            val deviceInfo = parseDeviceResponse(responseBody)
            
            // Save device credentials
            currentDevice = deviceInfo
            currentConnectionToken = connectionToken
            
            Result.success(deviceInfo)
        } else {
            Result.failure(Exception("Device registration failed: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error registering device", e)
        Result.failure(e)
    }
}

private fun generateConnectionToken(): String {
    return UUID.randomUUID().toString()
}

private fun parseDeviceResponse(responseBody: String): JDownloaderDevice {
    // Parse My.JDownloader response format
    val gson = Gson()
    return gson.fromJson(responseBody, JDownloaderDevice::class.java)
}

Step 3: Implement Download Management

suspend fun getDownloads(deviceId: String): Result<List<JDownloaderDownload>> = withContext(ioDispatcher) {
    return@withContext try {
        val action = "/downloads/get"
        val requestData = mapOf(
            "rid" to System.currentTimeMillis().toString(),
            "url" to action
        )
        
        val request = buildDeviceRequest(deviceId, requestData)
        val response = httpClient.newCall(request).execute()
        
        if (response.isSuccessful) {
            val responseBody = response.body?.string() ?: ""
            val downloads = parseDownloadsResponse(responseBody)
            Result.success(downloads)
        } else {
            Result.failure(Exception("Failed to get downloads: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error getting downloads", e)
        Result.failure(e)
    }
}

suspend fun addDownload(deviceId: String, urls: List<String>, packageName: String? = null): Result<List<String>> = withContext(ioDispatcher) {
    return@withContext try {
        val action = "/downloads/add"
        val requestData = mutableMapOf(
            "rid" to System.currentTimeMillis().toString(),
            "url" to action,
            "urls" to urls.joinToString("\n")
        )
        
        packageName?.let {
            requestData["packageName"] = it
        }
        
        val request = buildDeviceRequest(deviceId, requestData)
        val response = httpClient.newCall(request).execute()
        
        if (response.isSuccessful) {
            val responseBody = response.body?.string() ?: ""
            val downloadIds = parseAddDownloadsResponse(responseBody)
            Result.success(downloadIds)
        } else {
            Result.failure(Exception("Failed to add downloads: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error adding downloads", e)
        Result.failure(e)
    }
}

suspend fun startDownload(deviceId: String, downloadIds: List<String>): Result<Unit> = withContext(ioDispatcher) {
    return@withContext try {
        val action = "/downloads/start"
        val requestData = mapOf(
            "rid" to System.currentTimeMillis().toString(),
            "url" to action,
            "uuids" to downloadIds.joinToString(",")
        )
        
        val request = buildDeviceRequest(deviceId, requestData)
        val response = httpClient.newCall(request).execute()
        
        if (response.isSuccessful) {
            Result.success(Unit)
        } else {
            Result.failure(Exception("Failed to start downloads: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error starting downloads", e)
        Result.failure(e)
    }
}

private fun buildDeviceRequest(deviceId: String, requestData: Map<String, Any>): Request {
    val requestBody = gson.toJson(requestData).toRequestBody("application/json".toMediaType())
    
    return Request.Builder()
        .url("$BASE_URL/t_$deviceId_$currentConnectionToken")
        .post(requestBody)
        .build()
}

11.1 Fix JDownloaderSyncManager (Day 11)

File: Connectors/JDownloaderConnect/JDownloaderConnector/src/main/kotlin/com/shareconnect/jdownloaderconnect/sync/JDownloaderSyncManager.kt

Step 1: Add Schemas

// Current: exposedSchemas = emptyList(), // TODO: Add JDownloader sync schemas

// Replace with:
override val exposedSchemas = listOf(
    SyncSchema.JDOWNLOADER_DOWNLOADS,
    SyncSchema.JDOWNLOADER_LINKS,
    SyncSchema.JDOWNLOADER_PACKAGES,
    SyncSchema.JDOWNLOADER_SETTINGS
)

@Singleton
class JDownloaderSyncManager @Inject constructor(
    context: Context,
    appId: String,
    appName: String,
    appVersion: String,
    private val jDownloaderRepository: JDownloaderRepository,
    private val apiClient: JDownloaderApiClient,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : AsinkaClient(context, appId, appName, appVersion) {

Step 2: Implement Sync Logic

override suspend fun performSync(): Result<SyncResult> = withContext(ioDispatcher) {
    return@withContext try {
        val device = currentDevice ?: return@withContext Result.failure(Exception("No device registered"))
        
        // Get current downloads from JDownloader
        val downloadsResult = jDownloaderRepository.getDownloads()
        if (downloadsResult.isFailure) {
            return@withContext downloadsResult.map { SyncResult(emptyList()) }
        }
        
        val downloads = downloadsResult.getOrThrow()
        
        // Broadcast download updates
        downloads.forEach { download ->
            broadcastSyncEvent(
                type = SyncEventType.DOWNLOAD_UPDATE,
                data = mapOf(
                    "downloadId" to download.id,
                    "status" to download.status,
                    "progress" to download.progress,
                    "speed" to download.speed
                )
            )
        }
        
        Result.success(SyncResult(
            syncedObjects = downloads.size,
            timestamp = System.currentTimeMillis()
        ))
    } catch (e: Exception) {
        Log.e(TAG, "Error performing sync", e)
        Result.failure(e)
    }
}

12.1 Fix JDownloaderSyncService (Day 12)

File: Connectors/JDownloaderConnect/JDownloaderConnector/src/main/kotlin/com/shareconnect/jdownloaderconnect/service/JDownloaderSyncService.kt

Step 1: Implement Sync Stop Logic

// Current TODO: "Implement sync stop logic"

// Replace with:
@Singleton
class JDownloaderSyncService @Inject constructor(
    private val syncManager: JDownloaderSyncManager,
    @ApplicationScope private val applicationScope: CoroutineScope,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : Service() {

    companion object {
        private const val TAG = "JDownloaderSyncService"
        private const val SYNC_INTERVAL_MS = 30000L // 30 seconds
        private const val NOTIFICATION_ID = 1002
    }

    private var syncJob: Job? = null
    private val binder = LocalBinder()
    private var isSyncRunning = false

    inner class LocalBinder : Binder() {
        fun getService(): JDownloaderSyncService = this@JDownloaderSyncService
    }

    override fun onBind(intent: Intent): IBinder = binder

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        when (intent?.action) {
            ACTION_START_SYNC -> startSync()
            ACTION_STOP_SYNC -> stopSync()
        }
        return START_STICKY
    }

    private fun startSync() {
        if (isSyncRunning) {
            Log.d(TAG, "Sync already running")
            return
        }

        isSyncRunning = true
        
        syncJob = applicationScope.launch(ioDispatcher) {
            Log.d(TAG, "Starting JDownloader sync")
            
            while (isActive && isSyncRunning) {
                try {
                    val syncResult = syncManager.performSync()
                    if (syncResult.isSuccess) {
                        Log.d(TAG, "Sync completed: ${syncResult.getOrNull()?.syncedObjects} objects synced")
                    } else {
                        Log.e(TAG, "Sync failed: ${syncResult.exceptionOrNull()?.message}")
                    }
                    
                    delay(SYNC_INTERVAL_MS)
                } catch (e: Exception) {
                    Log.e(TAG, "Error in sync loop", e)
                    delay(SYNC_INTERVAL_MS * 2) // Backoff on error
                }
            }
        }

        startForeground(NOTIFICATION_ID, createNotification())
    }

    private fun stopSync() {
        Log.d(TAG, "Stopping JDownloader sync")
        isSyncRunning = false
        
        syncJob?.cancel()
        syncJob = null
        
        // Clean up resources
        syncManager.cleanup()
        
        stopForeground(true)
        stopSelf()
    }

    private fun createNotification(): Notification {
        val channelId = "jdownloader_sync_channel"
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                channelId,
                "JDownloader Sync",
                NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = "JDownloader synchronization service"
                setShowBadge(false)
            }
            
            val notificationManager = getSystemService(NotificationManager::class.java)
            notificationManager.createNotificationChannel(channel)
        }

        return NotificationCompat.Builder(this, channelId)
            .setContentTitle("JDownloader Sync Active")
            .setContentText("Synchronizing JDownloader downloads")
            .setSmallIcon(R.drawable.ic_sync)
            .setOngoing(true)
            .build()
    }

    override fun onDestroy() {
        super.onDestroy()
        stopSync()
        Log.d(TAG, "JDownloaderSyncService destroyed")
    }

    companion object {
        const val ACTION_START_SYNC = "com.shareconnect.jdownloaderconnect.START_SYNC"
        const val ACTION_STOP_SYNC = "com.shareconnect.jdownloaderconnect.STOP_SYNC"
    }
}

DAY 13-14: SEAFILECONNECT MANUAL OPERATIONS

13.1 Implement Seafile REST API v2 (Day 13)

File: Connectors/SeafileConnect/SeafileConnector/src/main/kotlin/com/shareconnect/seafileconnect/data/api/SeafileApiClient.kt

Step 1: Implement Authentication

@Singleton
class SeafileApiClient @Inject constructor(
    private val httpClient: OkHttpClient,
    private val gson: Gson,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) {
    companion object {
        private const val TAG = "SeafileApiClient"
        private const val API_VERSION = "v2.1"
    }

    suspend fun authenticate(serverUrl: String, username: String, password: String): Result<SeafileAuthToken> = withContext(ioDispatcher) {
        return@withContext try {
            val authRequest = SeafileAuthRequest(username, password)
            val requestBody = gson.toJson(authRequest).toRequestBody("application/json".toMediaType())
            
            val request = Request.Builder()
                .url("$serverUrl/api/$API_VERSION/auth-token/")
                .post(requestBody)
                .build()
            
            val response = httpClient.newCall(request).execute()
            if (response.isSuccessful) {
                val token = gson.fromJson(response.body?.charStream(), SeafileAuthToken::class.java)
                currentAuthToken = token
                currentServerUrl = serverUrl
                
                Result.success(token)
            } else {
                Result.failure(Exception("Authentication failed: ${response.code}"))
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error authenticating with Seafile", e)
            Result.failure(e)
        }
    }
}

Step 2: Implement File Operations

suspend fun listLibraries(): Result<List<SeafileLibrary>> = withContext(ioDispatcher) {
    return@withContext try {
        val request = Request.Builder()
            .url("${buildBaseUrl()}/api/$API_VERSION/repos/")
            .header("Authorization", "Token ${currentAuthToken?.token}")
            .get()
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val librariesType = object : TypeToken<List<SeafileLibrary>>() {}.type
            val libraries = gson.fromJson<List<SeafileLibrary>>(response.body?.charStream(), librariesType)
            Result.success(libraries)
        } else {
            Result.failure(Exception("Failed to get libraries: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error getting libraries", e)
        Result.failure(e)
    }
}

suspend fun listFiles(libraryId: String, path: String = "/"): Result<List<SeafileFile>> = withContext(ioDispatcher) {
    return@withContext try {
        val encodedPath = if (path.isNotEmpty()) URLEncoder.encode(path, "UTF-8") else ""
        val request = Request.Builder()
            .url("${buildBaseUrl()}/api/$API_VERSION/repos/$libraryId/dir/?p=$encodedPath")
            .header("Authorization", "Token ${currentAuthToken?.token}")
            .get()
            .build()
        
        val response = httpClient.newCall(request).execute()
        if (response.isSuccessful) {
            val filesType = object : TypeToken<List<SeafileFile>>() {}.type
            val files = gson.fromJson<List<SeafileFile>>(response.body?.charStream(), filesType)
            Result.success(files)
        } else {
            Result.failure(Exception("Failed to list files: ${response.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error listing files", e)
        Result.failure(e)
    }
}

suspend fun uploadFile(libraryId: String, filePath: String, fileContent: ByteArray): Result<SeafileFile> = withContext(ioDispatcher) {
    return@withContext try {
        // First, get upload link
        val uploadLinkRequest = Request.Builder()
            .url("${buildBaseUrl()}/api/$API_VERSION/repos/$libraryId/upload-link/?p=$filePath")
            .header("Authorization", "Token ${currentAuthToken?.token}")
            .get()
            .build()
        
        val uploadLinkResponse = httpClient.newCall(uploadLinkRequest).execute()
        if (!uploadLinkResponse.isSuccessful) {
            return@withContext Result.failure(Exception("Failed to get upload link"))
        }
        
        val uploadLink = gson.fromJson(uploadLinkResponse.body?.charStream(), SeafileUploadLink::class.java)
        
        // Upload file to the link
        val requestBody = fileContent.toRequestBody()
        val uploadRequest = Request.Builder()
            .url(uploadLink.url)
            .header("Authorization", "Token ${currentAuthToken?.token}")
            .post(requestBody)
            .build()
        
        val uploadResponse = httpClient.newCall(uploadRequest).execute()
        if (uploadResponse.isSuccessful) {
            val uploadedFile = gson.fromJson(uploadResponse.body?.charStream(), SeafileFile::class.java)
            Result.success(uploadedFile)
        } else {
            Result.failure(Exception("Failed to upload file: ${uploadResponse.code}"))
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error uploading file", e)
        Result.failure(e)
    }
}

14.1 Manual File Addition to MainActivity (Day 14)

File: Connectors/SeafileConnect/SeafileConnector/src/main/kotlin/com/shareconnect/seafileconnect/ui/MainActivity.kt

Step 1: Implement Manual File Addition

// Current TODO: "Implement manual file addition"

// Replace with:
@OptIn(ExperimentalMaterial3Api::class)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    private val viewModel: SeafileViewModel by viewModels()
    private var selectedLibrary: SeafileLibrary? = null
    private var currentPath = "/"
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        setContent {
            SeafileTheme {
                SeafileScreen(
                    uiState = viewModel.uiState.collectAsState().value,
                    onLibrarySelected = { library ->
                        selectedLibrary = library
                        currentPath = "/"
                        viewModel.loadFiles(library.id, currentPath)
                    },
                    onFileSelected = { file ->
                        if (file.type == "file") {
                            viewModel.downloadFile(selectedLibrary?.id ?: return@SeafileScreen, file.path)
                        } else {
                            // Navigate into directory
                            currentPath = file.path
                            viewModel.loadFiles(selectedLibrary?.id ?: return@SeafileScreen, currentPath)
                        }
                    },
                    onAddFile = { showFilePicker() },
                    onNavigateUp = {
                        if (currentPath != "/") {
                            currentPath = currentPath.substringBeforeLast("/", "")
                            if (currentPath.isEmpty()) currentPath = "/"
                            viewModel.loadFiles(selectedLibrary?.id ?: return@SeafileScreen, currentPath)
                        }
                    },
                    onBackPressed = { finish() }
                )
            }
        }
    }

    private fun showFilePicker() {
        val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
            type = "*/*"
            addCategory(Intent.CATEGORY_OPENABLE)
        }
        startActivityForResult(intent, REQUEST_CODE_PICK_FILE)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        
        if (requestCode == REQUEST_CODE_PICK_FILE && resultCode == RESULT_OK) {
            data?.data?.let { uri ->
                uploadFile(uri)
            }
        }
    }

    private fun uploadFile(uri: Uri) {
        val library = selectedLibrary ?: return
        
        // Get file name and content
        val fileName = getFileName(uri) ?: return
        val filePath = "$currentPath$fileName"
        
        try {
            contentResolver.openInputStream(uri)?.use { inputStream ->
                val fileContent = inputStream.readBytes()
                viewModel.uploadFile(library.id, filePath, fileContent)
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error reading file", e)
            Toast.makeText(this, "Error reading file: ${e.message}", Toast.LENGTH_SHORT).show()
        }
    }

    private fun getFileName(uri: Uri): String? {
        return when {
            uri.scheme == "content" -> {
                contentResolver.query(uri, null, null, null, null)?.use { cursor ->
                    val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                    cursor.moveToFirst()
                    cursor.getString(nameIndex)
                }
            }
            uri.scheme == "file" -> uri.lastPathSegment
            else -> null
        }
    }

    companion object {
        private const val TAG = "SeafileMainActivity"
        private const val REQUEST_CODE_PICK_FILE = 1001
    }
}

Step 2: Update UI Component

// File: Connectors/SeafileConnect/SeafileConnector/src/main/kotlin/com/shareconnect/seafileconnect/ui/components/SeafileScreen.kt

@Composable
fun SeafileScreen(
    uiState: SeafileUiState,
    onLibrarySelected: (SeafileLibrary) -> Unit,
    onFileSelected: (SeafileFile) -> Unit,
    onAddFile: () -> Unit,
    onNavigateUp: () -> Unit,
    onBackPressed: () -> Unit
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // Top bar with navigation
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            IconButton(onClick = onBackPressed) {
                Icon(Icons.Default.ArrowBack, contentDescription = "Back")
            }
            
            Text(
                text = "Seafile Connect",
                style = MaterialTheme.typography.titleLarge
            )
            
            IconButton(onClick = onAddFile) {
                Icon(Icons.Default.Add, contentDescription = "Add file")
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        // Content
        when {
            uiState.isLoading -> {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator()
                }
            }
            
            uiState.error != null -> {
                Column(
                    modifier = Modifier.fillMaxSize(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Center
                ) {
                    Text(
                        text = "Error: ${uiState.error}",
                        color = MaterialTheme.colorScheme.error,
                        style = MaterialTheme.typography.bodyLarge
                    )
                    
                    Spacer(modifier = Modifier.height(16.dp))
                    
                    Button(onClick = { /* Retry logic */ }) {
                        Text("Retry")
                    }
                }
            }
            
            uiState.libraries.isNotEmpty() -> {
                // Library selection or file listing
                if (selectedLibrary == null) {
                    LibraryList(
                        libraries = uiState.libraries,
                        onLibrarySelected = onLibrarySelected
                    )
                } else {
                    FileList(
                        files = uiState.files,
                        currentPath = currentPath,
                        onFileSelected = onFileSelected,
                        onNavigateUp = onNavigateUp
                    )
                }
            }
            
            else -> {
                // Empty state
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Column(
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Icon(
                            Icons.Default.CloudQueue,
                            contentDescription = null,
                            modifier = Modifier.size(64.dp),
                            tint = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                        
                        Spacer(modifier = Modifier.height(16.dp))
                        
                        Text(
                            text = "No libraries found",
                            style = MaterialTheme.typography.bodyLarge,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                    }
                }
            }
        }
    }
}

DAY 15: PHASE 1 TESTING & INTEGRATION

15.1 Comprehensive Testing (Day 15)

Step 1: Build All Applications

#!/bin/bash
# Build script: scripts/build_all_phase1_apps.sh

echo "=== Building Phase 1 Applications ==="

# Build the 4 critical connectors
echo "Building PlexConnect..."
cd Connectors/PlexConnect && ./gradlew assembleDebug

echo "Building NextcloudConnect..."
cd ../NextcloudConnect && ./gradlew assembleDebug

echo "Building MotrixConnect..."
cd ../MotrixConnect && ./gradlew assembleDebug

echo "Building GiteaConnect..."
cd ../GiteaConnect && ./gradlew assembleDebug

echo "Building JDownloaderConnect..."
cd ../JDownloaderConnect && ./gradlew assembleDebug

echo "Building SeafileConnect..."
cd ../SeafileConnect && ./gradlew assembleDebug

echo "Building qBitConnect (with fixes)..."
cd ../qBitConnect && ./gradlew assembleDebug

echo "Building TransmissionConnect (with fixes)..."
cd ../TransmissionConnect && ./gradlew assembleDebug

echo "=== Build Complete ==="

Step 2: Run Comprehensive Tests

#!/bin/bash
# Test script: scripts/run_phase1_tests.sh

echo "=== Running Phase 1 Tests ==="

# Run unit tests for all fixed connectors
echo "Running unit tests..."
./gradlew test --tests "*Plex*"
./gradlew test --tests "*Nextcloud*"
./gradlew test --tests "*Motrix*"
./gradlew test --tests "*Gitea*"
./gradlew test --tests "*JDownloader*"
./gradlew test --tests "*Seafile*"

# Run integration tests
echo "Running integration tests..."
./gradlew connectedAndroidTest --tests "*Plex*"
./gradlew connectedAndroidTest --tests "*Nextcloud*"
./gradlew connectedAndroidTest --tests "*Motrix*"
./gradlew connectedAndroidTest --tests "*Gitea*"
./gradlew connectedAndroidTest --tests "*JDownloader*"
./gradlew connectedAndroidTest --tests "*Seafile*"

# Run AI QA tests for new functionality
echo "Running AI QA tests..."
./run_ai_qa_tests.sh --suite phase1_connector_suite

echo "=== Phase 1 Testing Complete ==="

Step 3: Integration Validation

#!/bin/bash
# Integration script: scripts/validate_phase1_integration.sh

echo "=== Validating Phase 1 Integration ==="

# Check if all apps build successfully
echo "Checking build status..."
if ./scripts/build_all_phase1_apps.sh; then
    echo "✅ All apps build successfully"
else
    echo "❌ Build failures detected"
    exit 1
fi

# Check test coverage
echo "Checking test coverage..."
./gradlew jacocoTestReport

# Generate integration report
cat > Documentation/Phase1_Integration_Report.md << EOF
# Phase 1 Integration Report

## Build Status
✅ All Phase 1 connectors build successfully
✅ Zero compilation errors
✅ All dependencies resolved

## Test Coverage
- Unit Tests: Running...
- Integration Tests: Running...
- AI QA Tests: Running...

## Fixed Issues
- ✅ PlexRepositoryImpl: Removed stub mode, implemented full functionality
- ✅ NextcloudConnect: Implemented OAuth2 and WebDAV operations
- ✅ MatrixSyncService: Implemented continuous sync loop
- ✅ MotrixConnect: Implemented Aria2 JSON-RPC client
- ✅ GiteaConnect: Implemented REST API with Git LFS support
- ✅ JDownloaderConnect: Implemented My.JDownloader API integration
- ✅ SeafileConnect: Implemented REST API v2 and manual file addition

## Repository Fixes
- ✅ qBitConnect: Implemented category setting, RSS feed addition, search plugin refresh
- ✅ qBitConnect: Implemented server connection testing
- ✅ qBitConnect: Implemented torrent download from search results
- ✅ Mock servers: Fixed torrent-start-now implementations

## Remaining Work
Proceed to Phase 2: Test Coverage & Disabled Test Reactivation

EOF

echo "=== Phase 1 Integration Validation Complete ==="

PHASE 1 COMPLETION CRITERIA

Technical Requirements

  • All 4 stub-mode connectors fully functional (Plex, Nextcloud, Motrix, Gitea)
  • All TODO items in core functionality resolved
  • All repository pattern implementations complete
  • All mock servers updated with proper implementations
  • All sync services functional with proper error handling

Testing Requirements

  • All new implementations pass unit tests
  • All new implementations pass integration tests
  • All new implementations pass AI QA tests
  • Zero compilation errors across all applications
  • Build process automated and successful

Documentation Requirements

  • All new API methods documented in code
  • All new repository patterns documented
  • Integration guides updated with new implementations
  • Phase 1 integration report generated

Success Metrics

  • 0 stub-mode implementations remaining
  • 0 TODO items in core functionality
  • 100% of Phase 1 target features functional
  • < 5% test failures across all applications
  • < 3 seconds app startup times for all connectors

NEXT STEPS

After Phase 1 completion:

  1. Proceed to Phase 2: Test Coverage & Disabled Test Reactivation
  2. Run full regression test: Ensure Phase 1 changes don't break existing functionality
  3. Update documentation: Document all new implementations and API changes
  4. Prepare for Phase 2: Set up testing infrastructure for test reactivation

Phase 1 delivers critical functionality that enables the remaining phases to proceed with a solid foundation. All core connectors will be fully functional, providing the foundation for comprehensive test coverage and documentation in Phase 2.