Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiplatform file access #5656

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ dependencies {
// Kotlin
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.3.2")
implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.3.3")

// Date/time
api("org.jetbrains.kotlinx:kotlinx-datetime:0.5.0")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import de.westnordost.streetcomplete.util.logs.DatabaseLogger
import io.ktor.client.HttpClient
import io.ktor.client.plugins.defaultRequest
import io.ktor.http.userAgent
import kotlinx.io.files.FileSystem
import kotlinx.io.files.SystemFileSystem
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module

Expand All @@ -23,4 +25,5 @@ val appModule = module {
userAgent(ApplicationConstants.USER_AGENT)
}
} }
single<FileSystem> { SystemFileSystem }
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ package de.westnordost.streetcomplete.data.meta
import android.content.res.AssetManager
import com.charleskorn.kaml.Yaml
import com.charleskorn.kaml.YamlConfiguration
import com.charleskorn.kaml.decodeFromStream
import de.westnordost.countryboundaries.CountryBoundaries
import java.io.File
import java.io.SequenceInputStream
import kotlinx.io.files.Path
import kotlinx.serialization.decodeFromString

class CountryInfos(private val assetManager: AssetManager) {
private val yaml = Yaml(configuration = YamlConfiguration(
Expand Down Expand Up @@ -40,13 +39,13 @@ class CountryInfos(private val assetManager: AssetManager) {
}

private fun loadCountryInfo(countryCodeIso3166: String): IncompleteCountryInfo {
val filename = "$countryCodeIso3166.yml"
assetManager.open(BASEPATH + File.separator + filename).use { inputStream ->
val countryCode = countryCodeIso3166.split("-").first()
val stream = SequenceInputStream("countryCode: $countryCode\n".byteInputStream(), inputStream)

return yaml.decodeFromStream(stream)
}
val countryCode = countryCodeIso3166.split("-").first()
val path = Path(BASEPATH, "$countryCodeIso3166.yml")
val countryInfos = "countryCode: $countryCode\n" + assetManager
.open(path.name)
.bufferedReader()
.use { it.readText() }
return yaml.decodeFromString(countryInfos)
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,22 @@ import de.westnordost.streetcomplete.util.logs.Log
import io.ktor.client.HttpClient
import io.ktor.client.plugins.expectSuccess
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel
import io.ktor.util.cio.writeChannel
import io.ktor.utils.io.copyAndClose
import java.io.File
import io.ktor.client.statement.readBytes
import kotlinx.io.IOException
import kotlinx.io.buffered
import kotlinx.io.files.FileSystem
import kotlinx.io.files.Path

/** Downloads and stores the OSM avatars of users */
class AvatarsDownloader(
private val httpClient: HttpClient,
private val userApi: UserApi,
private val cacheDir: File
private val fileSystem: FileSystem,
private val cacheDir: Path
) {

suspend fun download(userIds: Collection<Long>) {
if (!ensureCacheDirExists()) {
Log.w(TAG, "Unable to create directories for avatars")
return
}
if (!ensureCacheDirExists()) return

val time = nowAsEpochMilliseconds()
for (userId in userIds) {
Expand All @@ -47,20 +46,31 @@ class AvatarsDownloader(
/** download avatar for the given user and a known avatar url */
suspend fun download(userId: Long, avatarUrl: String) {
if (!ensureCacheDirExists()) return
val avatarFile = File(cacheDir, "$userId")
val avatarFile = Path(cacheDir, userId.toString())
try {
val response = httpClient.get(avatarUrl) {
expectSuccess = true
}
response.bodyAsChannel().copyAndClose(avatarFile.writeChannel())
Log.d(TAG, "Downloaded file: ${avatarFile.path}")
val sink = fileSystem.sink(avatarFile).buffered()
// this reads the whole file into memory first instead of streaming it into the file, see also
// https://youtrack.jetbrains.com/issue/KTOR-6030/Migrate-to-new-kotlinx.io-library
sink.write(response.readBytes())
Log.d(TAG, "Downloaded file: ${avatarFile.name}")
} catch (e: Exception) {
Log.w(TAG, "Unable to download avatar for user id $userId")
}
}

private fun ensureCacheDirExists(): Boolean =
cacheDir.exists() || cacheDir.mkdirs()
private fun ensureCacheDirExists(): Boolean {
return try {
fileSystem.createDirectories(cacheDir, mustCreate = false)
true
} catch (e: IOException) {
Log.w(TAG, "Unable to create directories for avatars")
false
}
}


companion object {
private const val TAG = "OsmAvatarsDownload"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ package de.westnordost.streetcomplete.data.osmnotes

import android.content.Context
import de.westnordost.streetcomplete.ApplicationConstants
import kotlinx.io.files.Path
import org.koin.core.qualifier.named
import org.koin.dsl.module
import java.io.File

val notesModule = module {
factory(named("AvatarsCacheDirectory")) { File(get<Context>().cacheDir, ApplicationConstants.AVATARS_CACHE_DIRECTORY) }
factory { AvatarsDownloader(get(), get(), get(named("AvatarsCacheDirectory"))) }
factory(named("AvatarsCacheDirectory")) { Path(get<Context>().cacheDir.path, ApplicationConstants.AVATARS_CACHE_DIRECTORY) }
factory { AvatarsDownloader(get(), get(), get(), get(named("AvatarsCacheDirectory"))) }
factory { AvatarsInNotesUpdater(get()) }
factory { NoteDao(get()) }
factory { NotesDownloader(get(), get()) }
factory { StreetCompleteImageUploader(get(), ApplicationConstants.SC_PHOTO_SERVICE_URL) }
factory { StreetCompleteImageUploader(get(), get(), ApplicationConstants.SC_PHOTO_SERVICE_URL) }

single {
NoteController(get()).apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.http.defaultForFile
import io.ktor.util.cio.readChannel
import io.ktor.http.defaultForFilePath
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.errors.IOException
import kotlinx.io.buffered
import kotlinx.io.files.FileSystem
import kotlinx.io.files.Path
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.io.File

@Serializable
private data class PhotoUploadResponse(
Expand All @@ -28,6 +30,7 @@ private data class PhotoUploadResponse(
* <a href="https://github.com/streetcomplete/sc-photo-service">StreetComplete image hosting service</a>
*/
class StreetCompleteImageUploader(
private val fileSystem: FileSystem,
private val httpClient: HttpClient,
private val baseUrl: String
) {
Expand All @@ -44,14 +47,14 @@ class StreetCompleteImageUploader(
val imageLinks = ArrayList<String>()

for (path in imagePaths) {
val file = File(path)
if (!file.exists()) continue
val file = Path(path)
if (!fileSystem.exists(file)) continue

try {
val response = httpClient.post(baseUrl + "upload.php") {
contentType(ContentType.defaultForFile(file))
contentType(ContentType.defaultForFilePath(path))
header("Content-Transfer-Encoding", "binary")
setBody(file.readChannel())
setBody(ByteReadChannel(fileSystem.source(file).buffered()))
}

val status = response.status
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ import de.westnordost.streetcomplete.view.RoundRectOutlineProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.io.files.FileSystem
import kotlinx.io.files.Path
import org.koin.android.ext.android.inject
import org.koin.core.qualifier.named
import java.io.File

class NoteDiscussionForm : AbstractQuestForm() {

Expand All @@ -52,7 +53,8 @@ class NoteDiscussionForm : AbstractQuestForm() {
private val noteSource: NotesWithEditsSource by inject()
private val noteEditsController: NoteEditsController by inject()
private val osmNoteQuestController: OsmNoteQuestController by inject()
private val avatarsCacheDir: File by inject(named("AvatarsCacheDirectory"))
private val fileSystem: FileSystem by inject()
private val avatarsCacheDir: Path by inject(named("AvatarsCacheDirectory"))

private val attachPhotoFragment get() =
childFragmentManager.findFragmentById(R.id.attachPhotoFragment) as? AttachPhotoFragment
Expand Down Expand Up @@ -184,8 +186,8 @@ class NoteDiscussionForm : AbstractQuestForm() {
}

private val User.avatar: Bitmap? get() {
val file = File(avatarsCacheDir.toString() + File.separator + id)
return if (file.exists()) BitmapFactory.decodeFile(file.path) else null
val file = Path(avatarsCacheDir.name, id.toString())
return if (fileSystem.exists(file)) BitmapFactory.decodeFile(file.name) else null
}

private val NoteComment.Action.actionResourceId get() = when (this) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import org.koin.dsl.module

val userScreenModule = module {
factory<ProfileViewModel> { ProfileViewModelImpl(
get(), get(), get(), get(), get(), get(), get(named("AvatarsCacheDirectory")), get()
get(), get(), get(), get(), get(), get(), get(), get(named("AvatarsCacheDirectory")), get()
) }

factory<LoginViewModel> { LoginViewModelImpl(get(), get(), get(), get()) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class ProfileFragment : Fragment(R.layout.fragment_profile) {
binding.userNameTextView.text = name
}
observe(viewModel.userAvatarFile) { file ->
val avatar = if (file.exists()) BitmapFactory.decodeFile(file.path) else anonAvatar
val avatar = if (file != null) BitmapFactory.decodeFile(file.name) else anonAvatar
binding.userAvatarImageView.setImageBitmap(avatar)
}
observe(viewModel.editCount) { count ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.datetime.LocalDate
import kotlinx.io.files.FileSystem
import kotlinx.io.files.Path
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File

abstract class ProfileViewModel : ViewModel() {
abstract val userName: StateFlow<String?>
abstract val userAvatarFile: StateFlow<File>
abstract val userAvatarFile: StateFlow<Path?>

abstract val achievementLevels: StateFlow<Int>

Expand Down Expand Up @@ -58,7 +59,8 @@ class ProfileViewModelImpl(
private val statisticsSource: StatisticsSource,
private val achievementsSource: AchievementsSource,
private val unsyncedChangesCountSource: UnsyncedChangesCountSource,
private val avatarsCacheDirectory: File,
private val fileSystem: FileSystem,
private val avatarsCacheDirectory: Path,
private val prefs: ObservableSettings
) : ProfileViewModel() {

Expand Down Expand Up @@ -203,8 +205,10 @@ class ProfileViewModelImpl(
}
}

private fun getUserAvatarFile(): File =
File(avatarsCacheDirectory, userDataSource.userId.toString())
private fun getUserAvatarFile(): Path? {
val path = Path(avatarsCacheDirectory, userDataSource.userId.toString())
return if (fileSystem.exists(path)) path else null
}

override fun onCleared() {
unsyncedChangesCountSource.removeListener(unsyncedChangesCountListener)
Expand Down