diff --git a/app/schemas/ca.cgagnier.wlednativeandroid.repository.DevicesDatabase/11.json b/app/schemas/ca.cgagnier.wlednativeandroid.repository.DevicesDatabase/11.json new file mode 100644 index 00000000..c907dc5d --- /dev/null +++ b/app/schemas/ca.cgagnier.wlednativeandroid.repository.DevicesDatabase/11.json @@ -0,0 +1,220 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "2760473c3744dbf6f299b09b0c84a05d", + "entities": [ + { + "tableName": "Device2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `address` TEXT NOT NULL, `isHidden` INTEGER NOT NULL, `originalName` TEXT NOT NULL DEFAULT '', `customName` TEXT NOT NULL DEFAULT '', `skipUpdateTag` TEXT NOT NULL DEFAULT '', `branch` TEXT NOT NULL DEFAULT 'UNKNOWN', `lastSeen` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`macAddress`))", + "fields": [ + { + "fieldPath": "macAddress", + "columnName": "macAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalName", + "columnName": "originalName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "customName", + "columnName": "customName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "skipUpdateTag", + "columnName": "skipUpdateTag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "branch", + "columnName": "branch", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'UNKNOWN'" + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "macAddress" + ] + } + }, + { + "tableName": "Version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tagName` TEXT NOT NULL, `repository` TEXT NOT NULL DEFAULT 'wled/WLED', `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPrerelease` INTEGER NOT NULL, `publishedDate` TEXT NOT NULL, `htmlUrl` TEXT NOT NULL, PRIMARY KEY(`tagName`, `repository`))", + "fields": [ + { + "fieldPath": "tagName", + "columnName": "tagName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repository", + "columnName": "repository", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'wled/WLED'" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrerelease", + "columnName": "isPrerelease", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publishedDate", + "columnName": "publishedDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tagName", + "repository" + ] + } + }, + { + "tableName": "Asset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`versionTagName` TEXT NOT NULL, `repository` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `downloadUrl` TEXT NOT NULL, `assetId` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`versionTagName`, `repository`, `name`), FOREIGN KEY(`versionTagName`, `repository`) REFERENCES `Version`(`tagName`, `repository`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "versionTagName", + "columnName": "versionTagName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repository", + "columnName": "repository", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadUrl", + "columnName": "downloadUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assetId", + "columnName": "assetId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "versionTagName", + "repository", + "name" + ] + }, + "indices": [ + { + "name": "index_Asset_versionTagName", + "unique": false, + "columnNames": [ + "versionTagName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Asset_versionTagName` ON `${TABLE_NAME}` (`versionTagName`)" + }, + { + "name": "index_Asset_repository", + "unique": false, + "columnNames": [ + "repository" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Asset_repository` ON `${TABLE_NAME}` (`repository`)" + } + ], + "foreignKeys": [ + { + "table": "Version", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "versionTagName", + "repository" + ], + "referencedColumns": [ + "tagName", + "repository" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2760473c3744dbf6f299b09b0c84a05d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/model/Asset.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/model/Asset.kt index 77d21794..e3148155 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/model/Asset.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/model/Asset.kt @@ -5,11 +5,11 @@ import androidx.room.Entity import androidx.room.ForeignKey @Entity( - primaryKeys = ["versionTagName", "name"], + primaryKeys = ["versionTagName", "repository", "name"], foreignKeys = [ForeignKey( entity = Version::class, - parentColumns = arrayOf("tagName"), - childColumns = arrayOf("versionTagName"), + parentColumns = arrayOf("tagName", "repository"), + childColumns = arrayOf("versionTagName", "repository"), onDelete = ForeignKey.CASCADE )] ) @@ -17,6 +17,8 @@ data class Asset( @ColumnInfo(index = true) val versionTagName: String, + @ColumnInfo(index = true) + val repository: String, val name: String, val size: Long, val downloadUrl: String, diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/model/Version.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/model/Version.kt index 11c1d6f7..6bb2fe5c 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/model/Version.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/model/Version.kt @@ -1,12 +1,15 @@ package ca.cgagnier.wlednativeandroid.model +import androidx.room.ColumnInfo import androidx.room.Entity -import androidx.room.PrimaryKey -@Entity +@Entity( + primaryKeys = ["tagName", "repository"] +) data class Version( - @PrimaryKey val tagName: String, + @ColumnInfo(defaultValue = "'wled/WLED'") + val repository: String, val name: String, val description: String, val isPrerelease: Boolean, @@ -18,6 +21,7 @@ data class Version( fun getPreviewVersion(): Version { return Version( tagName = "v1.0.0", + repository = "wled/WLED", name = "new version", description = "this is a test version", isPrerelease = false, diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/model/wledapi/Info.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/model/wledapi/Info.kt index d0f0a7e1..f2ae6b27 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/model/wledapi/Info.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/model/wledapi/Info.kt @@ -19,6 +19,8 @@ data class Info( @param:Json(name = "cn") val codeName: String? = null, // Added in 0.15 @param:Json(name = "release") val release: String? = null, + // Added in 0.15.2 + @param:Json(name = "repo") val repo: String? = null, @param:Json(name = "name") val name: String, @param:Json(name = "str") val syncToggleReceive: Boolean? = null, @param:Json(name = "udpport") val udpPort: Int? = null, diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/DevicesDatabase.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/DevicesDatabase.kt index c63995b3..ab125823 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/DevicesDatabase.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/DevicesDatabase.kt @@ -11,6 +11,8 @@ import ca.cgagnier.wlednativeandroid.model.Device import ca.cgagnier.wlednativeandroid.model.Version import ca.cgagnier.wlednativeandroid.repository.migrations.DbMigration7To8 import ca.cgagnier.wlednativeandroid.repository.migrations.DbMigration8To9 +import ca.cgagnier.wlednativeandroid.repository.migrations.MIGRATION_9_10 +import ca.cgagnier.wlednativeandroid.repository.migrations.MIGRATION_10_11 @Database( entities = [ @@ -18,7 +20,7 @@ import ca.cgagnier.wlednativeandroid.repository.migrations.DbMigration8To9 Version::class, Asset::class, ], - version = 9, + version = 11, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -47,7 +49,9 @@ abstract class DevicesDatabase : RoomDatabase() { context.applicationContext, DevicesDatabase::class.java, "devices_database" - ).build() + ) + .addMigrations(MIGRATION_9_10, MIGRATION_10_11) + .build() INSTANCE = instance instance } diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/VersionDao.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/VersionDao.kt index 4744dcce..18800cf3 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/VersionDao.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/VersionDao.kt @@ -36,16 +36,16 @@ interface VersionDao { suspend fun deleteAll() @Transaction - @Query("SELECT * FROM version WHERE isPrerelease = 0 AND tagName != '$IGNORED_TAG' ORDER BY publishedDate DESC LIMIT 1") - suspend fun getLatestStableVersionWithAssets(): VersionWithAssets? + @Query("SELECT * FROM version WHERE repository = :repository AND isPrerelease = 0 AND tagName != '$IGNORED_TAG' ORDER BY publishedDate DESC LIMIT 1") + suspend fun getLatestStableVersionWithAssets(repository: String): VersionWithAssets? @Transaction - @Query("SELECT * FROM version WHERE tagName != '$IGNORED_TAG' ORDER BY publishedDate DESC LIMIT 1") - suspend fun getLatestBetaVersionWithAssets(): VersionWithAssets? + @Query("SELECT * FROM version WHERE repository = :repository AND tagName != '$IGNORED_TAG' ORDER BY publishedDate DESC LIMIT 1") + suspend fun getLatestBetaVersionWithAssets(repository: String): VersionWithAssets? @Transaction - @Query("SELECT * FROM version WHERE tagName = :tagName LIMIT 1") - suspend fun getVersionByTagName(tagName: String): VersionWithAssets? + @Query("SELECT * FROM version WHERE repository = :repository AND tagName = :tagName LIMIT 1") + suspend fun getVersionByTagName(repository: String, tagName: String): VersionWithAssets? @Transaction @Query("SELECT * FROM version") diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/VersionWithAssetsRepository.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/VersionWithAssetsRepository.kt index 6cef3a63..835fac0b 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/VersionWithAssetsRepository.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/VersionWithAssetsRepository.kt @@ -22,15 +22,15 @@ class VersionWithAssetsRepository @Inject constructor( } } - suspend fun getLatestStableVersionWithAssets(): VersionWithAssets? { - return versionDao.getLatestStableVersionWithAssets() + suspend fun getLatestStableVersionWithAssets(repository: String): VersionWithAssets? { + return versionDao.getLatestStableVersionWithAssets(repository) } - suspend fun getLatestBetaVersionWithAssets(): VersionWithAssets? { - return versionDao.getLatestBetaVersionWithAssets() + suspend fun getLatestBetaVersionWithAssets(repository: String): VersionWithAssets? { + return versionDao.getLatestBetaVersionWithAssets(repository) } - suspend fun getVersionByTag(tagName: String): VersionWithAssets? { - return versionDao.getVersionByTagName(tagName) + suspend fun getVersionByTag(repository: String, tagName: String): VersionWithAssets? { + return versionDao.getVersionByTagName(repository, tagName) } } \ No newline at end of file diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/migrations/DbMigration10To11.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/migrations/DbMigration10To11.kt new file mode 100644 index 00000000..5265021f --- /dev/null +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/migrations/DbMigration10To11.kt @@ -0,0 +1,23 @@ +package ca.cgagnier.wlednativeandroid.repository.migrations + +import android.util.Log +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +private const val TAG = "DbMigration10To11" + +/** + * Migration from 10->11 removes the old Version and Asset tables after data has been migrated + * to the new schema with repository tracking support. + */ +val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + Log.i(TAG, "Starting migration from 10 to 11") + + // Drop the old tables if they exist (cleanup from previous migration) + db.execSQL("DROP TABLE IF EXISTS Version_old") + db.execSQL("DROP TABLE IF EXISTS Asset_old") + + Log.i(TAG, "Migration from 10 to 11 complete!") + } +} diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/migrations/DbMigration9To10.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/migrations/DbMigration9To10.kt new file mode 100644 index 00000000..230ca414 --- /dev/null +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/repository/migrations/DbMigration9To10.kt @@ -0,0 +1,142 @@ +package ca.cgagnier.wlednativeandroid.repository.migrations + +import android.util.Log +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +private const val TAG = "DbMigration9To10" + +/** + * Migration from 9->10 adds repository information to Version and Asset tables + * to support tracking releases from multiple WLED repositories/forks. + * + * We rename the old tables, create new ones with repository field, + * copy existing data with default repository "wled/WLED", then drop the old tables. + */ +val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + Log.i(TAG, "Starting migration from 9 to 10") + + // Rename old tables + db.execSQL("ALTER TABLE Version RENAME TO Version_old") + db.execSQL("ALTER TABLE Asset RENAME TO Asset_old") + + // Create new Version table with repository column + db.execSQL(""" + CREATE TABLE IF NOT EXISTS Version ( + tagName TEXT NOT NULL, + repository TEXT NOT NULL DEFAULT 'wled/WLED', + name TEXT NOT NULL, + description TEXT NOT NULL, + isPrerelease INTEGER NOT NULL, + publishedDate TEXT NOT NULL, + htmlUrl TEXT NOT NULL, + PRIMARY KEY(tagName, repository) + ) + """.trimIndent()) + + // Create new Asset table with repository column + db.execSQL(""" + CREATE TABLE IF NOT EXISTS Asset ( + versionTagName TEXT NOT NULL, + repository TEXT NOT NULL DEFAULT 'wled/WLED', + name TEXT NOT NULL, + size INTEGER NOT NULL, + downloadUrl TEXT NOT NULL, + assetId INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(versionTagName, repository, name), + FOREIGN KEY(versionTagName, repository) REFERENCES Version(tagName, repository) ON DELETE CASCADE + ) + """.trimIndent()) + + // Create indices for Asset table + db.execSQL("CREATE INDEX IF NOT EXISTS index_Asset_versionTagName ON Asset (versionTagName)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_Asset_repository ON Asset (repository)") + + // Migrate Version data + val originalVersionCountCursor = db.query("SELECT COUNT(*) FROM Version_old") + var originalVersionCount = 0 + if (originalVersionCountCursor.moveToFirst()) { + originalVersionCount = originalVersionCountCursor.getInt(0) + } + originalVersionCountCursor.close() + Log.i(TAG, "Total versions in old 'Version' table: $originalVersionCount") + + // Copy data from Version_old to Version with default repository + db.execSQL( + """ + INSERT OR IGNORE INTO Version ( + tagName, + repository, + name, + description, + isPrerelease, + publishedDate, + htmlUrl + ) + SELECT + tagName, + 'wled/WLED' AS repository, + name, + description, + isPrerelease, + publishedDate, + htmlUrl + FROM Version_old + """.trimIndent() + ) + + val migratedVersionCountCursor = db.query("SELECT COUNT(*) FROM Version") + var migratedVersionCount = 0 + if (migratedVersionCountCursor.moveToFirst()) { + migratedVersionCount = migratedVersionCountCursor.getInt(0) + } + migratedVersionCountCursor.close() + Log.i(TAG, "Versions migrated to new table: $migratedVersionCount") + + // Migrate Asset data + val originalAssetCountCursor = db.query("SELECT COUNT(*) FROM Asset_old") + var originalAssetCount = 0 + if (originalAssetCountCursor.moveToFirst()) { + originalAssetCount = originalAssetCountCursor.getInt(0) + } + originalAssetCountCursor.close() + Log.i(TAG, "Total assets in old 'Asset' table: $originalAssetCount") + + // Copy data from Asset_old to Asset with default repository + db.execSQL( + """ + INSERT OR IGNORE INTO Asset ( + versionTagName, + repository, + name, + size, + downloadUrl, + assetId + ) + SELECT + versionTagName, + 'wled/WLED' AS repository, + name, + size, + downloadUrl, + assetId + FROM Asset_old + """.trimIndent() + ) + + val migratedAssetCountCursor = db.query("SELECT COUNT(*) FROM Asset") + var migratedAssetCount = 0 + if (migratedAssetCountCursor.moveToFirst()) { + migratedAssetCount = migratedAssetCountCursor.getInt(0) + } + migratedAssetCountCursor.close() + Log.i(TAG, "Assets migrated to new table: $migratedAssetCount") + + // Drop old tables + db.execSQL("DROP TABLE IF EXISTS Version_old") + db.execSQL("DROP TABLE IF EXISTS Asset_old") + + Log.i(TAG, "Migration from 9 to 10 complete!") + } +} diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/api/github/GithubApi.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/api/github/GithubApi.kt index 6a2013d7..0845c662 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/api/github/GithubApi.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/api/github/GithubApi.kt @@ -18,12 +18,12 @@ import javax.inject.Singleton @Singleton class GithubApi @Inject constructor(private val apiEndpoints: GithubApiEndpoints) { - suspend fun getAllReleases(): Result> { - Log.d(TAG, "retrieving latest release") + suspend fun getAllReleases(repoOwner: String, repoName: String): Result> { + Log.d(TAG, "retrieving latest releases from $repoOwner/$repoName") return try { - Result.success(apiEndpoints.getAllReleases(REPO_OWNER, REPO_NAME)) + Result.success(apiEndpoints.getAllReleases(repoOwner, repoName)) } catch (e: Exception) { - Log.w(TAG, "Error retrieving releases: ${e.message}") + Log.w(TAG, "Error retrieving releases from $repoOwner/$repoName: ${e.message}") Result.failure(e) } } @@ -33,8 +33,9 @@ class GithubApi @Inject constructor(private val apiEndpoints: GithubApiEndpoints ): Flow = flow { try { emit(DownloadState.Downloading(0)) + val (repoOwner, repoName) = ca.cgagnier.wlednativeandroid.service.update.splitRepository(asset.repository) val responseBody = - apiEndpoints.downloadReleaseBinary(REPO_OWNER, REPO_NAME, asset.assetId) + apiEndpoints.downloadReleaseBinary(repoOwner, repoName, asset.assetId) emitAll(responseBody.saveFile(targetFile)) } catch (e: Exception) { emit(DownloadState.Failed(e)) @@ -69,7 +70,5 @@ class GithubApi @Inject constructor(private val apiEndpoints: GithubApiEndpoints companion object { private const val TAG = "github-release" - const val REPO_OWNER = "Aircoookie" - const val REPO_NAME = "WLED" } } \ No newline at end of file diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/DeviceUpdateManager.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/DeviceUpdateManager.kt index dfa6b2d0..8f50bc5e 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/DeviceUpdateManager.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/DeviceUpdateManager.kt @@ -30,16 +30,15 @@ class DeviceUpdateManager @Inject constructor( .map { (info, branch, skipUpdateTag) -> if (info == null) return@map null - val source = UpdateSourceRegistry.getSource(info) ?: return@map null + val repository = getRepositoryFromInfo(info) Log.d( TAG, - "Checking for software update for ${deviceWithState.device.macAddress} on ${source.githubOwner}:${source.githubRepo}" + "Checking for software update for ${deviceWithState.device.macAddress} on $repository" ) releaseService.getNewerReleaseTag( deviceInfo = info, branch = branch, ignoreVersion = skipUpdateTag, - updateSourceDefinition = source, ) } } diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/DeviceUpdateService.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/DeviceUpdateService.kt index 109d3e4a..7ebf49da 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/DeviceUpdateService.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/DeviceUpdateService.kt @@ -56,7 +56,7 @@ class DeviceUpdateService( val versionWithRelease = if (combined.startsWith("v", ignoreCase = true)) combined.drop(1) else combined assetName = "WLED_${versionWithRelease}.bin" - return findAsset(assetName) + return findAsset(versionWithRelease) } /** @@ -77,13 +77,14 @@ class DeviceUpdateService( val versionWithPlatform = if (combined.startsWith("v", ignoreCase = true)) combined.drop(1) else combined assetName = "WLED_${versionWithPlatform}.bin" - return findAsset(assetName) + return findAsset(versionWithPlatform) } private fun findAsset(assetName: String): Boolean { for (asset in versionWithAssets.assets) { - if (asset.name == assetName) { + if (asset.name.endsWith("${assetName}.bin")) { this.asset = asset + this.assetName = asset.name couldDetermineAsset = true return true } diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/ReleaseService.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/ReleaseService.kt index adfdfc77..41e697d5 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/ReleaseService.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/update/ReleaseService.kt @@ -13,18 +13,21 @@ import ca.cgagnier.wlednativeandroid.service.api.github.GithubApi import com.vdurmont.semver4j.Semver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import javax.inject.Inject private const val TAG = "updateService" +const val DEFAULT_REPO = "wled/WLED" enum class UpdateSourceType { - OFFICIAL_WLED, QUINLED, CUSTOM + OFFICIAL_WLED, QUINLED, CUSTOM, MOONMODULES } data class UpdateSourceDefinition( val type: UpdateSourceType, val brandPattern: String, val githubOwner: String, - val githubRepo: String + val githubRepo: String, + val product: String? = null ) object UpdateSourceRegistry { @@ -32,24 +35,70 @@ object UpdateSourceRegistry { UpdateSourceDefinition( type = UpdateSourceType.OFFICIAL_WLED, brandPattern = "WLED", - githubOwner = "Aircoookie", + githubOwner = "wled", githubRepo = "WLED" ), UpdateSourceDefinition( type = UpdateSourceType.QUINLED, brandPattern = "QuinLED", githubOwner = "intermittech", githubRepo = "QuinLED-Firmware" - ) + ), + UpdateSourceDefinition( + type = UpdateSourceType.MOONMODULES, + brandPattern = "WLED", + product = "MoonModules", + githubOwner = "MoonModules", + githubRepo = "WLED-MM" + ), ) fun getSource(info: Info): UpdateSourceDefinition? { - return sources.find { - info.brand == it.brandPattern - } + val brandMatches = sources.filter { it.brandPattern == info.brand } + return brandMatches.find { it.product == info.product } + ?: brandMatches.find { it.product == null } + } +} + +/** + * Extracts repository from device info using a three-tier fallback strategy: + * 1. First: Use the repo field if available (format: "owner/name") - added in WLED 0.15.2 + * 2. Second: Use UpdateSourceRegistry based on brand pattern matching + * 3. Third: Default to "wled/WLED" + */ +fun getRepositoryFromInfo(info: Info): String { + + + // Second priority: Use brand-based registry lookup + val source = UpdateSourceRegistry.getSource(info) + if (source != null) { + return "${source.githubOwner}/${source.githubRepo}" + } + + // TODO: Should be first, but possible bad MoonModules build with the wrong repo - TBC + if (!info.repo.isNullOrBlank()) { + return info.repo } + + // Final fallback: Default repository + return DEFAULT_REPO } -class ReleaseService(private val versionWithAssetsRepository: VersionWithAssetsRepository) { +/** + * Splits a repository string (e.g., "owner/name") into owner and name parts for API calls. + * Returns a pair of (owner, name). Defaults to ("wled", "WLED") if format is invalid. + */ +fun splitRepository(repository: String): Pair { + val parts = repository.split("/") + if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) { + return Pair(parts[0], parts[1]) + } else { + Log.w(TAG, "Invalid repo format: $repository, using default") + val defaultParts = DEFAULT_REPO.split("/") + return Pair(defaultParts[0], defaultParts[1]) + } +} + +class ReleaseService @Inject constructor(private val versionWithAssetsRepository: VersionWithAssetsRepository) { /** * If a new version is available, returns the version tag of it. @@ -65,20 +114,16 @@ class ReleaseService(private val versionWithAssetsRepository: VersionWithAssetsR deviceInfo: Info, branch: Branch, ignoreVersion: String, - updateSourceDefinition: UpdateSourceDefinition, ): String? { if (deviceInfo.version.isNullOrEmpty()) { return null } - if (deviceInfo.brand != updateSourceDefinition.brandPattern) { - return null - } if (!deviceInfo.isOtaEnabled) { return null } - // TODO: Modify this to use repositoryOwner and repositoryName - val latestVersion = getLatestVersionWithAssets(branch) ?: return null + val repository = getRepositoryFromInfo(deviceInfo) + val latestVersion = getLatestVersionWithAssets(repository, branch) ?: return null val latestTagName = latestVersion.version.tagName if (latestTagName == ignoreVersion) { @@ -124,37 +169,53 @@ class ReleaseService(private val versionWithAssetsRepository: VersionWithAssetsR return null } - private suspend fun getLatestVersionWithAssets(branch: Branch): VersionWithAssets? { + private suspend fun getLatestVersionWithAssets( + repository: String, + branch: Branch + ): VersionWithAssets? { if (branch == Branch.BETA) { - return versionWithAssetsRepository.getLatestBetaVersionWithAssets() + return versionWithAssetsRepository.getLatestBetaVersionWithAssets(repository) } - return versionWithAssetsRepository.getLatestStableVersionWithAssets() + return versionWithAssetsRepository.getLatestStableVersionWithAssets(repository) } - suspend fun refreshVersions(githubApi: GithubApi) = withContext(Dispatchers.IO) { - githubApi.getAllReleases().onFailure { exception -> - Log.w(TAG, "Failed to refresh versions from Github", exception) - return@onFailure - }.onSuccess { allVersions -> - if (allVersions.isEmpty()) { - Log.w(TAG, "GitHub returned 0 releases. Skipping DB update to preserve cache.") - return@onSuccess - } - val (versions, assets) = withContext(Dispatchers.Default) { - val v = allVersions.map { createVersion(it) } - val a = allVersions.flatMap { createAssetsForVersion(it) } - Pair(v, a) + /** + * Refreshes versions from multiple repositories. + * Gets a list of unique repositories, then fetches releases for each. + */ + suspend fun refreshVersions(githubApi: GithubApi, repositories: Set) = withContext(Dispatchers.IO) { + val allVersions = mutableListOf() + val allAssets = mutableListOf() + + for (repository in repositories) { + val (repoOwner, repoName) = splitRepository(repository) + Log.i(TAG, "Fetching releases from $repository") + githubApi.getAllReleases(repoOwner, repoName).onFailure { exception -> + Log.w(TAG, "Failed to refresh versions from $repository", exception) + }.onSuccess { releases -> + if (releases.isEmpty()) { + Log.w(TAG, "GitHub returned 0 releases for $repository.") + } else { + val versions = releases.map { createVersion(it, repository) } + val assets = releases.flatMap { createAssetsForVersion(it, repository) } + allVersions.addAll(versions) + allAssets.addAll(assets) + Log.i(TAG, "Added ${versions.size} versions and ${assets.size} assets from $repository") + } } + } - Log.i(TAG, "Replacing DB with ${versions.size} versions and ${assets.size} assets") - versionWithAssetsRepository.replaceAll(versions, assets) + if (allVersions.isNotEmpty()) { + Log.i(TAG, "Replacing DB with ${allVersions.size} versions and ${allAssets.size} assets total") + versionWithAssetsRepository.replaceAll(allVersions, allAssets) } } - private fun createVersion(version: Release): Version { + private fun createVersion(version: Release, repository: String): Version { return Version( sanitizeTagName(version.tagName), + repository, version.name, version.body, version.prerelease, @@ -163,13 +224,14 @@ class ReleaseService(private val versionWithAssetsRepository: VersionWithAssetsR ) } - private fun createAssetsForVersion(version: Release): List { + private fun createAssetsForVersion(version: Release, repository: String): List { val assetsModels = mutableListOf() val sanitizedTagName = sanitizeTagName(version.tagName) for (asset in version.assets) { assetsModels.add( Asset( sanitizedTagName, + repository, asset.name, asset.size, asset.browserDownloadUrl, diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/websocket/WebsocketClientManager.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/websocket/WebsocketClientManager.kt new file mode 100644 index 00000000..17bc3d30 --- /dev/null +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/websocket/WebsocketClientManager.kt @@ -0,0 +1,26 @@ +package ca.cgagnier.wlednativeandroid.service.websocket + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages the active WebSocket client instances. + * This is a singleton that provides access to all active websocket clients. + */ +@Singleton +class WebsocketClientManager @Inject constructor() { + private val _clients = MutableStateFlow>(emptyMap()) + val clients: StateFlow> = _clients.asStateFlow() + + fun updateClients(newClients: Map) { + _clients.value = newClients + } + + fun getClients(): Map { + return _clients.value + } +} + diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/MainViewModel.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/MainViewModel.kt index eb7f1dc3..ac6854f4 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/MainViewModel.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/MainViewModel.kt @@ -3,9 +3,13 @@ package ca.cgagnier.wlednativeandroid.ui import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import ca.cgagnier.wlednativeandroid.repository.DeviceRepository import ca.cgagnier.wlednativeandroid.repository.UserPreferencesRepository import ca.cgagnier.wlednativeandroid.service.api.github.GithubApi +import ca.cgagnier.wlednativeandroid.service.update.DEFAULT_REPO import ca.cgagnier.wlednativeandroid.service.update.ReleaseService +import ca.cgagnier.wlednativeandroid.service.update.getRepositoryFromInfo +import ca.cgagnier.wlednativeandroid.service.websocket.WebsocketClientManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -19,7 +23,9 @@ private const val TAG = "MainViewModel" class MainViewModel @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository, private val releaseService: ReleaseService, - private val githubApi: GithubApi + private val githubApi: GithubApi, + private val deviceRepository: DeviceRepository, + private val websocketClientManager: WebsocketClientManager ) : ViewModel() { fun downloadUpdateMetadata() { @@ -30,7 +36,22 @@ class MainViewModel @Inject constructor( Log.i(TAG, "Not updating version list since it was done recently.") return@launch } - releaseService.refreshVersions(githubApi) + + // Collect unique repositories from all connected devices + val repositories = mutableSetOf() + repositories.add(DEFAULT_REPO) // Always include the default WLED repository + + websocketClientManager.getClients().values.forEach { client -> + val info = client.deviceState.stateInfo.value?.info + if (info != null) { + val repo = getRepositoryFromInfo(info) + repositories.add(repo) + Log.d(TAG, "Found device using repository: $repo") + } + } + + Log.i(TAG, "Refreshing versions from ${repositories.size} repositories: $repositories") + releaseService.refreshVersions(githubApi, repositories) // Set the next date to check in minimum 24 hours from now. userPreferencesRepository.updateLastUpdateCheckDate(now + DAYS.toMillis(1)) } diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEdit.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEdit.kt index 78536c74..fa7fd9b8 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEdit.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEdit.kt @@ -168,7 +168,7 @@ fun DeviceEdit( device, currentUpdateTag, seeUpdateDetails = { - viewModel.showUpdateDetails(currentUpdateTag) + viewModel.showUpdateDetails(device.device, device.stateInfo.value, currentUpdateTag) } ) } else { diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEditViewModel.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEditViewModel.kt index b82f7f17..b0fe5d85 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEditViewModel.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEditViewModel.kt @@ -6,10 +6,14 @@ import androidx.lifecycle.viewModelScope import ca.cgagnier.wlednativeandroid.model.Branch import ca.cgagnier.wlednativeandroid.model.Device import ca.cgagnier.wlednativeandroid.model.VersionWithAssets +import ca.cgagnier.wlednativeandroid.model.wledapi.DeviceStateInfo import ca.cgagnier.wlednativeandroid.repository.DeviceRepository import ca.cgagnier.wlednativeandroid.repository.VersionWithAssetsRepository import ca.cgagnier.wlednativeandroid.service.api.github.GithubApi +import ca.cgagnier.wlednativeandroid.service.update.DEFAULT_REPO import ca.cgagnier.wlednativeandroid.service.update.ReleaseService +import ca.cgagnier.wlednativeandroid.service.update.getRepositoryFromInfo +import ca.cgagnier.wlednativeandroid.service.websocket.WebsocketClientManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -23,7 +27,9 @@ const val TAG = "DeviceEditViewModel" class DeviceEditViewModel @Inject constructor( private val repository: DeviceRepository, private val versionWithAssetsRepository: VersionWithAssetsRepository, - private val githubApi: GithubApi + private val githubApi: GithubApi, + private val websocketClientManager: WebsocketClientManager, + private val releaseService: ReleaseService ) : ViewModel() { private var _updateDetailsVersion: MutableStateFlow = MutableStateFlow(null) @@ -68,8 +74,10 @@ class DeviceEditViewModel @Inject constructor( repository.update(updatedDevice) } - fun showUpdateDetails(version: String) = viewModelScope.launch(Dispatchers.IO) { - _updateDetailsVersion.value = versionWithAssetsRepository.getVersionByTag(version) + fun showUpdateDetails(device: Device, deviceStateInfo: DeviceStateInfo?, version: String) = viewModelScope.launch(Dispatchers.IO) { + // Extract repository from device info, defaulting to "wled/WLED" + val repository = deviceStateInfo?.info?.let { getRepositoryFromInfo(it) } ?: DEFAULT_REPO + _updateDetailsVersion.value = versionWithAssetsRepository.getVersionByTag(repository, version) } fun hideUpdateDetails() { @@ -108,8 +116,23 @@ class DeviceEditViewModel @Inject constructor( val updatedDevice = device.copy(skipUpdateTag = "") repository.update(updatedDevice) try { - val releaseService = ReleaseService(versionWithAssetsRepository) - releaseService.refreshVersions(githubApi) + // Get repository for this specific device + val repositories = mutableSetOf() + repositories.add(DEFAULT_REPO) // Always include the default WLED repository + + // Look up the specific device's websocket client to get its repository + val client = websocketClientManager.getClients()[device.macAddress] + val info = client?.deviceState?.stateInfo?.value?.info + if (info != null) { + val repo = getRepositoryFromInfo(info) + repositories.add(repo) + Log.d(TAG, "Refreshing versions for device repository: $repo") + } else { + Log.d(TAG, "Device info not available, using default repository only") + } + + Log.i(TAG, "Refreshing versions from ${repositories.size} repositories: $repositories") + releaseService.refreshVersions(githubApi, repositories) } finally { _isCheckingUpdates.value = false } diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt index a70a8dde..549aaf00 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt @@ -13,6 +13,7 @@ import ca.cgagnier.wlednativeandroid.repository.UserPreferencesRepository import ca.cgagnier.wlednativeandroid.service.update.DeviceUpdateManager import ca.cgagnier.wlednativeandroid.service.websocket.DeviceWithState import ca.cgagnier.wlednativeandroid.service.websocket.WebsocketClient +import ca.cgagnier.wlednativeandroid.service.websocket.WebsocketClientManager import com.squareup.moshi.Moshi import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -35,7 +36,8 @@ class DeviceWebsocketListViewModel @Inject constructor( private val deviceRepository: DeviceRepository, private val deviceUpdateManager: DeviceUpdateManager, private val okHttpClient: OkHttpClient, - private val moshi: Moshi + private val moshi: Moshi, + private val websocketClientManager: WebsocketClientManager ) : ViewModel(), DefaultLifecycleObserver { private val activeClients = MutableStateFlow>(emptyMap()) private val devicesFromDb = deviceRepository.allDevices @@ -106,6 +108,8 @@ class DeviceWebsocketListViewModel @Inject constructor( }.flowOn(Dispatchers.IO).collect { updatedClients -> // Emit the new map of clients to the StateFlow. activeClients.value = updatedClients + // Update the manager so other components can access the clients + websocketClientManager.updateClients(updatedClients) } }