package com.ea.ironmonkey import android.content.Context import android.util.Log import java.io.File /** * Unpacks the game data bundled inside the APK to the place the game expects. * * The ~595 MB archive ships as `assets/game_data.obb`, stored uncompressed * (see `noCompress` in build.gradle.kts - it is already a compressed archive, * so deflating it again would only cost build and install time). On first * launch it is copied to `getObbDir()/main...obb`, which * is exactly the path `GameActivityMain.obbFullPath` builds, so nothing else in * the game needs to know this happened. * * The point is that a tester installs one APK and plays - no separate download, * no file manager, no instructions about where to put an .obb. */ object GameDataInstaller { private const val TAG = "GameDataInstaller" private const val ASSET_NAME = "game_data.obb" /** Progress callback: (bytes copied, total bytes). Called from a worker thread. */ fun interface Progress { fun onProgress(copied: Long, total: Long) } sealed interface Result { /** Already unpacked, or just unpacked successfully. */ object Ready : Result /** Could not unpack - the message is safe to show a tester. */ data class Failed(val message: String) : Result } fun targetFile(context: Context): File { val versionCode = runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionCode }.getOrDefault(0) return File(context.obbDir, ObbHelper.getObbFileName(context, versionCode)) } /** Size of the bundled asset, or -1 if it is not in this build. */ fun bundledSize(context: Context): Long = runCatching { context.assets.openFd(ASSET_NAME).use { it.length } }.getOrElse { // openFd only works for an UNCOMPRESSED asset. If this ever starts // failing, the noCompress rule has been lost and the asset is being // deflated - worth knowing, because the copy below would still work // but every install would be needlessly slower. Log.w(TAG, "openFd($ASSET_NAME) failed - is noCompress still set? $it") runCatching { context.assets.open(ASSET_NAME).use { s -> s.available().toLong() } } .getOrDefault(-1L) } /** True when the data is already in place at its full expected size. */ fun isInstalled(context: Context): Boolean { val expected = bundledSize(context) if (expected <= 0) return false val target = targetFile(context) return target.isFile && target.length() == expected } /** * Copies the bundled data into place. Blocking - call from a worker thread. * * Writes to a temporary file and renames only on success, so an interrupted * copy can never leave a half-written archive that looks complete. A partial * file that passed a mere "does it exist" check would send the game off to * read truncated data, which fails far away from the real cause. */ fun install(context: Context, progress: Progress?): Result { val expected = bundledSize(context) if (expected <= 0) { return Result.Failed("В этой сборке нет игровых данных (assets/$ASSET_NAME).") } val target = targetFile(context) if (target.isFile && target.length() == expected) return Result.Ready val dir = target.parentFile if (dir != null && !dir.isDirectory && !dir.mkdirs()) { return Result.Failed("Не удалось создать каталог ${dir.absolutePath}") } // Check free space before starting rather than failing 500 MB in. val free = dir?.freeSpace ?: 0L if (free in 1 until expected) { return Result.Failed( "Недостаточно места: нужно ${expected / 1_048_576} МБ, свободно ${free / 1_048_576} МБ." ) } val tmp = File(target.parentFile, target.name + ".part") tmp.delete() return try { var copied = 0L context.assets.open(ASSET_NAME).use { input -> tmp.outputStream().buffered(1 shl 20).use { output -> val buf = ByteArray(1 shl 20) while (true) { val n = input.read(buf) if (n <= 0) break output.write(buf, 0, n) copied += n progress?.onProgress(copied, expected) } output.flush() } } if (copied != expected) { tmp.delete() return Result.Failed("Распаковка оборвалась: $copied из $expected байт.") } target.delete() if (!tmp.renameTo(target)) { tmp.delete() return Result.Failed("Не удалось переименовать во ${target.absolutePath}") } Log.i(TAG, "unpacked $expected bytes to ${target.absolutePath}") Result.Ready } catch (e: Throwable) { tmp.delete() Log.w(TAG, "unpack failed", e) Result.Failed("Ошибка распаковки: ${e.message}") } } }