diff --git a/.gitignore b/.gitignore index 32b70e14f..51ad34768 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ DerivedData/ compile_commands.json cmake-build-test +/local.properties +/.gradle +/.kotlin diff --git a/Package.swift b/Package.swift index 7391a06ec..9a8e3729d 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,10 @@ let package = Package( name: "MapCoreSharedModuleCpp", targets: ["MapCoreSharedModuleCpp"] ), + .library( + name: "MapCoreObjC", + targets: ["MapCoreObjC"] + ), ], dependencies: [ .package(url: "https://github.com/UbiqueInnovation/djinni.git", .upToNextMinor(from: "1.0.9")), @@ -117,7 +121,7 @@ let package = Package( .product(name: "Atomics", package: "swift-atomics"), ], path: "ios", - exclude: ["readme.md"], + exclude: ["readme.md", "objc"], resources: [ .process("graphics/Shader/Metal/") ] @@ -189,6 +193,15 @@ let package = Package( // .disableWarning("reorder"), ] ), + .target( + name: "MapCoreObjC", + dependencies: [ + "MapCore", + "MapCoreSharedModule", + ], + path: "ios/objc", + publicHeadersPath: "include" + ), ], cxxLanguageStandard: .cxx17 ) diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..21e705874 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,337 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget +import org.jetbrains.kotlin.gradle.tasks.CInteropProcess +import java.net.URI +import javax.inject.Inject + +group = "io.openmobilemaps.mapscore" + +val mapCoreCheckoutPath = project.layout.projectDirectory.asFile.absolutePath +val mapCoreMetalToolchain = providers.environmentVariable("MAPCORE_METAL_TOOLCHAIN") + .orElse(providers.gradleProperty("mapCoreMetalToolchain")) + .orElse("") +val mapCoreMetallibToolchain = providers.environmentVariable("MAPCORE_METALLIB_TOOLCHAIN") + .orElse(providers.gradleProperty("mapCoreMetallibToolchain")) + .orElse("com.apple.dt.toolchain.XcodeDefault") +val mapCoreMetalTargetSimulator = providers.environmentVariable("MAPCORE_METAL_TARGET_SIMULATOR") + .orElse(providers.gradleProperty("mapCoreMetalTargetSimulator")) + .orElse("air64-apple-ios26.0-simulator") +val mapCoreMetalTargetDevice = providers.environmentVariable("MAPCORE_METAL_TARGET_DEVICE") + .orElse(providers.gradleProperty("mapCoreMetalTargetDevice")) + .orElse("air64-apple-ios26.0") + +plugins { + id("org.jetbrains.kotlin.multiplatform") version "2.3.0" + id("com.android.kotlin.multiplatform.library") version "8.13.2" + id("io.github.frankois944.spmForKmp") version "1.4.6" +} + +@OptIn(ExperimentalKotlinGradlePluginApi::class) +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") // Opt-in for expect/actual classes + } + + applyDefaultHierarchyTemplate() + + android { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + namespace = "io.openmobilemaps.mapscore.kmp" + compileSdk = 36 + minSdk = 28 + } + + val mapCoreCinteropName = "MapCoreKmp" + val iosTargets = listOf( + iosArm64(), + iosSimulatorArm64() + ) + + iosTargets.forEach { iosTarget -> + iosTarget.compilations { + val main by getting { + cinterops.create(mapCoreCinteropName) + } + } + } + + sourceSets { + val commonMain by getting { + kotlin.srcDir("kmp/commonMain/kotlin") + dependencies { + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + } + } + val androidMain by getting { + kotlin.srcDir("kmp/androidMain/kotlin") + dependencies { + api("io.openmobilemaps:mapscore:3.6.0") + api("io.openmobilemaps:layer-gps:3.6.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.3") + implementation("ch.ubique.android:djinni-support-lib:1.1.1") + } + } + val iosMain by getting { + kotlin.srcDir("kmp/iosMain/kotlin") + languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") + } + val iosArm64Main by getting { + languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") + } + val iosSimulatorArm64Main by getting { + languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") + } + } +} + +swiftPackageConfig { + create("MapCoreKmp") { + minIos = "14.0" + bridgeSettings { + cSetting { + headerSearchPath = listOf("Sources/djinni-objc") + } + } + dependency { + localPackage( + mapCoreCheckoutPath, + "maps-core" + ) { + add("MapCoreObjC", exportToKotlin = true) + add("MapCoreSharedModule", exportToKotlin = true) + } + remotePackageVersion( + url = URI("https://github.com/openmobilemaps/layer-gps"), + packageName = "layer-gps", + version = "3.6.0", + ) { + add("LayerGpsSharedModule", exportToKotlin = true) + } + } + } +} + +// Avoid overlapping Package.resolved outputs between per-target SwiftPM compile tasks. +tasks + .matching { it.name.startsWith("SwiftPackageConfigAppleMapCoreKmpCompileSwiftPackage") } + .configureEach { + val packageResolveFileGetter = javaClass.methods.firstOrNull { it.name == "getPackageResolveFile" } + val packageResolveFile = + project.layout.buildDirectory.file( + "spmKmpPlugin/MapCoreKmp/package-resolved/${name}/Package.resolved", + ) + val packageResolveProperty = + packageResolveFileGetter + ?.invoke(this) as? RegularFileProperty + packageResolveProperty?.set(packageResolveFile) + + doLast { + val sourceFile = + project.layout.buildDirectory + .file("spmKmpPlugin/MapCoreKmp/Package.resolved") + .get() + .asFile + if (!sourceFile.exists()) return@doLast + val targetFile = + project.layout.buildDirectory + .file("spmKmpPlugin/MapCoreKmp/package-resolved/${name}/Package.resolved") + .get() + .asFile + targetFile.parentFile.mkdirs() + sourceFile.copyTo(targetFile, overwrite = true) + } + } + +tasks.withType().configureEach { + if (name.contains("MapCoreKmp")) { + settings.compilerOpts("-I$mapCoreCheckoutPath/external/djinni/support-lib/objc") + settings.compilerOpts("-I$mapCoreCheckoutPath/bridging/ios") + } +} + +val mapCoreSpmBuiltDir = + project.layout.buildDirectory.dir("spmKmpPlugin/MapCoreKmp/scratch/arm64 x86_64-apple-ios-simulator/release").get().asFile +mapCoreSpmBuiltDir.mkdirs() + +val mapCoreSpmDeviceDir = + project.layout.buildDirectory.dir("spmKmpPlugin/MapCoreKmp/scratch/arm64-apple-ios/release") +val mapCoreSpmSimulatorDir = + project.layout.buildDirectory.dir("spmKmpPlugin/MapCoreKmp/scratch/arm64-apple-ios-simulator/release") + +afterEvaluate { + val deviceTaskName = "SwiftPackageConfigAppleMapCoreKmpCompileSwiftPackageIosArm64" + val simulatorTaskName = "SwiftPackageConfigAppleMapCoreKmpCompileSwiftPackageIosSimulatorArm64" + if (tasks.findByName(deviceTaskName) != null) return@afterEvaluate + runCatching { + tasks.register(deviceTaskName) { + group = "io.github.frankois944.spmForKmp.tasks" + description = "Fallback: copy simulator SwiftPM output for iOS device metal compilation" + dependsOn(simulatorTaskName) + doLast { + val sourceDir = mapCoreSpmSimulatorDir.get().asFile + if (!sourceDir.exists()) return@doLast + val targetDir = mapCoreSpmDeviceDir.get().asFile + targetDir.mkdirs() + copy { + from(sourceDir) + into(targetDir) + } + } + } + }.onFailure { error -> + val message = error.message.orEmpty() + if (!message.contains("already exists")) { + throw error + } + } +} + +abstract class CompileMapCoreMetallibTask : DefaultTask() { + @get:Input + abstract val sdk: Property + + @get:Input + abstract val toolchainId: Property + + @get:Input + abstract val metallibToolchainId: Property + + @get:Input + abstract val targetTriple: Property + + @get:InputDirectory + abstract val bundleDir: DirectoryProperty + + @get:InputFiles + abstract val metalSources: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @get:OutputFile + abstract val metallibFile: RegularFileProperty + + @get:Inject + abstract val execOperations: ExecOperations + + @TaskAction + fun run() { + val bundleRoot = bundleDir.get().asFile + if (!bundleRoot.exists()) return + val metalFiles = metalSources.files.sortedBy { it.name } + if (metalFiles.isEmpty()) return + val toolchain = toolchainId.orNull?.takeIf { it.isNotBlank() } + val metallibToolchain = metallibToolchainId.orNull?.takeIf { it.isNotBlank() } + + val outputRoot = outputDir.get().asFile + outputRoot.mkdirs() + + val airFiles = metalFiles.map { file -> + File(outputRoot, "${file.nameWithoutExtension}.air") + } + + metalFiles.zip(airFiles).forEach { (metalFile, airFile) -> + execOperations.exec { + val args = buildList { + add("xcrun") + if (toolchain != null) { + add("--toolchain") + add(toolchain) + } + addAll( + listOf( + "-sdk", + sdk.get(), + "metal", + "-target", + targetTriple.get(), + "-c", + metalFile.absolutePath, + "-o", + airFile.absolutePath, + ), + ) + } + commandLine(args) + } + } + + val metallibArgs = buildList { + add("xcrun") + if (metallibToolchain != null) { + add("--toolchain") + add(metallibToolchain) + } + addAll( + listOf( + "-sdk", + sdk.get(), + "metallib", + ), + ) + airFiles.forEach { add(it.absolutePath) } + add("-o") + add(metallibFile.get().asFile.absolutePath) + } + + execOperations.exec { + commandLine(metallibArgs) + } + } +} + +val compileMapCoreMetallibIosSimulator = tasks.register("compileMapCoreMetallibIosSimulator") { + dependsOn("SwiftPackageConfigAppleMapCoreKmpCompileSwiftPackageIosSimulatorArm64") + sdk.set("iphonesimulator") + toolchainId.set(mapCoreMetalToolchain) + metallibToolchainId.set(mapCoreMetallibToolchain) + targetTriple.set(mapCoreMetalTargetSimulator) + bundleDir.set( + project.layout.buildDirectory + .dir("spmKmpPlugin/MapCoreKmp/scratch/arm64-apple-ios-simulator/release/MapCore_MapCore.bundle"), + ) + outputDir.set(project.layout.buildDirectory.dir("spmKmpPlugin/MapCoreKmp/metal/iphonesimulator")) + metalSources.from(bundleDir.map { it.asFileTree.matching { include("**/*.metal") } }) + metallibFile.set(bundleDir.map { it.file("default.metallib") }) +} + +val compileMapCoreMetallibIosArm64 = tasks.register("compileMapCoreMetallibIosArm64") { + dependsOn("SwiftPackageConfigAppleMapCoreKmpCompileSwiftPackageIosArm64") + sdk.set("iphoneos") + toolchainId.set(mapCoreMetalToolchain) + metallibToolchainId.set(mapCoreMetallibToolchain) + targetTriple.set(mapCoreMetalTargetDevice) + bundleDir.set( + project.layout.buildDirectory + .dir("spmKmpPlugin/MapCoreKmp/scratch/arm64-apple-ios/release/MapCore_MapCore.bundle"), + ) + outputDir.set(project.layout.buildDirectory.dir("spmKmpPlugin/MapCoreKmp/metal/iphoneos")) + metalSources.from(bundleDir.map { it.asFileTree.matching { include("**/*.metal") } }) + metallibFile.set(bundleDir.map { it.file("default.metallib") }) +} + +tasks.matching { it.name == "SwiftPackageConfigAppleMapCoreKmpCopyPackageResourcesIosSimulatorArm64" } + .configureEach { dependsOn(compileMapCoreMetallibIosSimulator) } +tasks.matching { it.name == "SwiftPackageConfigAppleMapCoreKmpCopyPackageResourcesIosArm64" } + .configureEach { dependsOn(compileMapCoreMetallibIosArm64) } + +tasks.matching { it.name == "compileKotlinIosSimulatorArm64" } + .configureEach { dependsOn(compileMapCoreMetallibIosSimulator) } +tasks.matching { it.name == "compileKotlinIosArm64" } + .configureEach { dependsOn(compileMapCoreMetallibIosArm64) } diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..b65e5f51d --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +kotlin.mpp.enableCInteropCommonization=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..980502d16 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..128196a7a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-milestone-1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..faf93008b --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..9b42019c7 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ios/graphics/Texture/TextureHolder.swift b/ios/graphics/Texture/TextureHolder.swift index 34a2fcd44..f785b812d 100644 --- a/ios/graphics/Texture/TextureHolder.swift +++ b/ios/graphics/Texture/TextureHolder.swift @@ -16,7 +16,7 @@ enum TextureHolderError: Error { case emptyData } -@objc +@objcMembers public class TextureHolder: NSObject, @unchecked Sendable { public let texture: MTLTexture @@ -85,6 +85,15 @@ public class TextureHolder: NSObject, @unchecked Sendable { self.init(texture, textureUsableSize: textureUsableSize) } + @objc(initWithData:) + public convenience init?(data: Data) { + do { + try self.init(data, textureUsableSize: nil) + } catch { + return nil + } + } + public convenience init(_ size: CGSize, drawCallback: (CGContext) -> Void) throws { guard size.width > 0, size.height > 0 else { throw TextureHolderError.emptyData diff --git a/ios/maps/MCFontLoader.swift b/ios/maps/MCFontLoader.swift index 788b5bf70..7a168b7d2 100644 --- a/ios/maps/MCFontLoader.swift +++ b/ios/maps/MCFontLoader.swift @@ -12,6 +12,7 @@ import MapCoreSharedModule import UIKit import os +@objcMembers open class MCFontLoader: NSObject, MCFontLoaderInterface, @unchecked Sendable { // MARK: - Font Atlas Dictionary @@ -22,10 +23,18 @@ open class MCFontLoader: NSObject, MCFontLoaderInterface, @unchecked Sendable { // MARK: - Init private let bundle: Bundle + private let resourcePath: String? // the bundle to use for searching for fonts - public init(bundle: Bundle, preload: [String] = []) { + @objc(initWithBundle:preload:) + public convenience init(bundle: Bundle, preload: [String] = []) { + self.init(bundle: bundle, resourcePath: nil, preload: preload) + } + + @objc(initWithBundle:resourcePath:preload:) + public init(bundle: Bundle, resourcePath: String?, preload: [String] = []) { self.bundle = bundle + self.resourcePath = resourcePath pixelsPerInch = if Thread.isMainThread { MainActor.assumeIsolated { @@ -70,7 +79,7 @@ open class MCFontLoader: NSObject, MCFontLoaderInterface, @unchecked Sendable { if let fontData = fontDataDictionary[font.name] { return fontData } - if let path = bundle.path(forResource: font.name, ofType: "json") { + if let path = fontResourcePath(name: font.name, ext: "json") { do { let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) let jsonResult = try JSONSerialization.jsonObject(with: data, options: .mutableLeaves) @@ -146,16 +155,21 @@ open class MCFontLoader: NSObject, MCFontLoaderInterface, @unchecked Sendable { return fontData } - let image = UIImage(named: font.name, in: bundle, compatibleWith: nil) - - guard let cgImage = image?.cgImage, + guard let path = fontResourcePath(name: font.name, ext: "png"), + let image = UIImage(contentsOfFile: path), + let cgImage = image.cgImage, let textureHolder = try? TextureHolder(cgImage) - else { - return nil - } + else { return nil } fontAtlasDictionary[font.name] = textureHolder return textureHolder } + + private func fontResourcePath(name: String, ext: String) -> String? { + if let resourcePath { + return bundle.path(forResource: name, ofType: ext, inDirectory: resourcePath) + } + return bundle.path(forResource: name, ofType: ext) + } } diff --git a/ios/maps/MCMapView.swift b/ios/maps/MCMapView.swift index e231975e8..d8b31420d 100644 --- a/ios/maps/MCMapView.swift +++ b/ios/maps/MCMapView.swift @@ -13,6 +13,7 @@ import Foundation @preconcurrency import MetalKit import os +@objcMembers open class MCMapView: MTKView { public let mapInterface: MCMapInterface private let renderingContext: RenderingContext @@ -72,6 +73,11 @@ open class MCMapView: MTKView { setup() } + @objc(initWithMapConfig:pixelsPerInch:is3D:) + public convenience init(mapConfig: MCMapConfig, pixelsPerInch: NSNumber?, is3D: Bool) { + self.init(mapConfig: mapConfig, pixelsPerInch: pixelsPerInch?.floatValue, is3D: is3D) + } + @available(*, unavailable) public required init(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") diff --git a/ios/maps/MCTextureLoader.swift b/ios/maps/MCTextureLoader.swift index c4626e708..9a1b3534d 100644 --- a/ios/maps/MCTextureLoader.swift +++ b/ios/maps/MCTextureLoader.swift @@ -16,7 +16,8 @@ import UIKit @available(iOS 14.0, *) private let logger = Logger(subsystem: "maps-core", category: "MCTextureLoader") -open class MCTextureLoader: MCLoaderInterface, @unchecked Sendable { +@objcMembers +open class MCTextureLoader: NSObject, MCLoaderInterface, @unchecked Sendable { public let session: URLSession public var isRasterDebugModeEnabled: Bool @@ -37,6 +38,7 @@ open class MCTextureLoader: MCLoaderInterface, @unchecked Sendable { } isRasterDebugModeEnabled = UserDefaults.standard.bool(forKey: "io.openmobilemaps.debug.rastertiles.enabled") + super.init() } open func loadTexture(_ url: String, etag: String?) -> MCTextureLoaderResult { diff --git a/ios/objc/MCMapCoreObjCFactory.m b/ios/objc/MCMapCoreObjCFactory.m new file mode 100644 index 000000000..6d2cb256e --- /dev/null +++ b/ios/objc/MCMapCoreObjCFactory.m @@ -0,0 +1,25 @@ +#import "MCMapCoreObjCFactory.h" + +@import MapCore; +@import MapCoreSharedModule; + +@implementation MCMapCoreObjCFactory + ++ (id)createTextureLoader { + return [[MCTextureLoader alloc] initWithUrlSession:nil]; +} + ++ (id)createFontLoaderWithBundle:(NSBundle *)bundle { + return [[MCFontLoader alloc] initWithBundle:bundle preload:@[]]; +} + ++ (id)createFontLoaderWithBundle:(NSBundle *)bundle + resourcePath:(NSString *)resourcePath { + return [[MCFontLoader alloc] initWithBundle:bundle resourcePath:resourcePath preload:@[]]; +} + ++ (id)createTextureHolderWithData:(NSData *)data { + return [[TextureHolder alloc] initWithData:data]; +} + +@end diff --git a/ios/objc/MCMapViewObjC.m b/ios/objc/MCMapViewObjC.m new file mode 100644 index 000000000..f45e5d5c2 --- /dev/null +++ b/ios/objc/MCMapViewObjC.m @@ -0,0 +1,49 @@ +#import "MCMapViewObjC.h" + +@import MapCore; +@import MapCoreSharedModule; + +#import "MapCore-Swift.h" + +@interface MCMapViewObjC () +@property (nonatomic, strong) MCMapView *mapViewInternal; +@end + +@implementation MCMapViewObjC + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + [self commonInit]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)coder { + if (self = [super initWithCoder:coder]) { + [self commonInit]; + } + return self; +} + +- (UIView *)mapView { + return self.mapViewInternal; +} + +- (MCMapInterface *)mapInterface { + return self.mapViewInternal.mapInterface; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.mapViewInternal.frame = self.bounds; +} + +- (void)commonInit { + MCMapConfig *config = [[MCMapConfig alloc] initWithMapCoordinateSystem:[MCCoordinateSystemFactory getEpsg3857System]]; + self.mapViewInternal = [[MCMapView alloc] initWithMapConfig:config pixelsPerInch:nil is3D:NO]; + self.mapViewInternal.frame = self.bounds; + self.mapViewInternal.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [self addSubview:self.mapViewInternal]; +} + +@end diff --git a/ios/objc/include/MCMapCoreObjCFactory.h b/ios/objc/include/MCMapCoreObjCFactory.h new file mode 100644 index 000000000..50cd58531 --- /dev/null +++ b/ios/objc/include/MCMapCoreObjCFactory.h @@ -0,0 +1,16 @@ +#import +@import MapCoreSharedModule; + +NS_ASSUME_NONNULL_BEGIN + +@interface MCMapCoreObjCFactory : NSObject + ++ (id)createTextureLoader; ++ (id)createFontLoaderWithBundle:(NSBundle *)bundle; ++ (id)createFontLoaderWithBundle:(NSBundle *)bundle + resourcePath:(nullable NSString *)resourcePath; ++ (id)createTextureHolderWithData:(NSData *)data; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/objc/include/MCMapViewObjC.h b/ios/objc/include/MCMapViewObjC.h new file mode 100644 index 000000000..3b62a0110 --- /dev/null +++ b/ios/objc/include/MCMapViewObjC.h @@ -0,0 +1,16 @@ +#import +@import MapCoreSharedModule; + +NS_ASSUME_NONNULL_BEGIN + +@interface MCMapViewObjC : UIView + +- (instancetype)initWithFrame:(CGRect)frame NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; + +@property (nonatomic, readonly) UIView *mapView; +@property (nonatomic, readonly) MCMapInterface *mapInterface; + +@end + +NS_ASSUME_NONNULL_END diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/Coord.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/Coord.kt new file mode 100644 index 000000000..1854f7712 --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/Coord.kt @@ -0,0 +1,5 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import io.openmobilemaps.mapscore.shared.map.coordinates.Coord as MapscoreCoord + +actual typealias Coord = MapscoreCoord diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCoreInterop.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCoreInterop.kt new file mode 100644 index 000000000..176f8c253 --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCoreInterop.kt @@ -0,0 +1,7 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +actual object MapCoreInterop { + actual fun moveToCenter(coord: Coord) { + coord.hashCode() + } +} diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapDataProviderLocalDataProviderImplementation.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapDataProviderLocalDataProviderImplementation.kt new file mode 100644 index 000000000..d4861eec5 --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapDataProviderLocalDataProviderImplementation.kt @@ -0,0 +1,80 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import android.graphics.BitmapFactory +import com.snapchat.djinni.Future +import com.snapchat.djinni.Promise +import io.openmobilemaps.mapscore.graphics.BitmapTextureHolder +import io.openmobilemaps.mapscore.shared.map.layers.tiled.vector.Tiled2dMapVectorLayerLocalDataProviderInterface +import io.openmobilemaps.mapscore.shared.map.loader.DataLoaderResult +import io.openmobilemaps.mapscore.shared.map.loader.LoaderStatus +import io.openmobilemaps.mapscore.shared.map.loader.TextureLoaderResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.nio.ByteBuffer + +internal class MapDataProviderLocalDataProviderImplementation( + private val dataProvider: MapDataProviderProtocol, + private val coroutineScope: CoroutineScope, +) : Tiled2dMapVectorLayerLocalDataProviderInterface() { + + override fun getStyleJson(): String? = dataProvider.getStyleJson() + + override fun loadSpriteAsync(scale: Int): Future { + val promise = Promise() + coroutineScope.launch(Dispatchers.IO) { + val attempt = runCatching { dataProvider.loadSpriteAsync("", "", scale) } + val texture = attempt.getOrNull() + ?.let { bytes -> BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } + ?.let { BitmapTextureHolder(it) } + val result = if (attempt.isFailure) { + TextureLoaderResult(null, null, LoaderStatus.ERROR_NETWORK, null) + } else { + texture?.let { TextureLoaderResult(it, null, LoaderStatus.OK, null) } + ?: TextureLoaderResult(null, null, LoaderStatus.ERROR_NETWORK, null) + } + promise.setValue(result) + } + return promise.future + } + + override fun loadSpriteJsonAsync(scale: Int): Future { + val promise = Promise() + coroutineScope.launch(Dispatchers.IO) { + val attempt = runCatching { dataProvider.loadSpriteJsonAsync("", "", scale) } + val result = if (attempt.isFailure) { + DataLoaderResult(null, null, LoaderStatus.ERROR_NETWORK, null) + } else { + attempt.getOrNull() + ?.let { bytes -> bytes.asDataLoaderResult() } + ?: DataLoaderResult(null, null, LoaderStatus.OK, null) + } + promise.setValue(result) + } + return promise.future + } + + override fun loadGeojson(sourceName: String, url: String): Future { + val promise = Promise() + coroutineScope.launch(Dispatchers.IO) { + val attempt = runCatching { dataProvider.loadGeojson(sourceName, url) } + val result = if (attempt.isFailure) { + DataLoaderResult(null, null, LoaderStatus.ERROR_NETWORK, null) + } else { + attempt.getOrNull() + ?.let { bytes -> bytes.asDataLoaderResult() } + ?: DataLoaderResult(null, null, LoaderStatus.OK, null) + } + promise.setValue(result) + } + return promise.future + } + + private fun ByteArray.asDataLoaderResult(): DataLoaderResult { + val buffer = ByteBuffer.allocateDirect(size).apply { + put(this@asDataLoaderResult) + flip() + } + return DataLoaderResult(buffer, null, LoaderStatus.OK, null) + } +} diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapFactoryImplementation.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapFactoryImplementation.kt new file mode 100644 index 000000000..35e19be20 --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapFactoryImplementation.kt @@ -0,0 +1,94 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import android.content.Context +import androidx.lifecycle.Lifecycle +import io.openmobilemaps.gps.GpsLayer +import io.openmobilemaps.gps.GpsProviderType +import io.openmobilemaps.gps.style.GpsStyleInfoFactory +import io.openmobilemaps.mapscore.map.layers.TiledRasterLayer +import io.openmobilemaps.mapscore.map.loader.DataLoader +import io.openmobilemaps.mapscore.map.loader.FontLoader +import io.openmobilemaps.mapscore.shared.map.layers.tiled.vector.Tiled2dMapVectorLayerInterface as MapscoreVectorLayer +import kotlinx.coroutines.CoroutineScope +import java.io.File + +actual abstract class MapFactory actual constructor( + platformContext: Any?, + coroutineScope: CoroutineScope?, + lifecycle: Any?, +) { + protected val context = platformContext as? Context + protected val coroutineScope = coroutineScope + protected val lifecycle = lifecycle as? Lifecycle + + actual abstract fun createVectorLayer( + layerName: String, + dataProvider: MapDataProviderProtocol, + ): MapVectorLayer? + + actual abstract fun createRasterLayer(config: MapTiled2dMapLayerConfig): MapRasterLayer? + + actual abstract fun createGpsLayer(): MapGpsLayer? + + actual companion object { + actual fun create( + platformContext: Any?, + coroutineScope: CoroutineScope?, + lifecycle: Any?, + ): MapFactory = MapFactoryImpl(platformContext, coroutineScope, lifecycle) + } +} + +private class MapFactoryImpl( + platformContext: Any?, + coroutineScope: CoroutineScope?, + lifecycle: Any?, +) : MapFactory(platformContext, coroutineScope, lifecycle) { + override fun createVectorLayer( + layerName: String, + dataProvider: MapDataProviderProtocol, + ): MapVectorLayer? { + val context = requireNotNull(context) { "MapFactory requires an Android Context" } + val coroutineScope = requireNotNull(coroutineScope) { "MapFactory requires a CoroutineScope" } + val cacheDir = File(context.cacheDir, "vector").apply { mkdirs() } + val provider = MapDataProviderLocalDataProviderImplementation( + dataProvider = dataProvider, + coroutineScope = coroutineScope, + ) + return MapscoreVectorLayer.createExplicitly( + layerName, + null, + false, + arrayListOf(DataLoader(context, cacheDir, 50L * 1024 * 1024)), + FontLoader(context, "map/fonts/", context.resources.displayMetrics.density), + provider, + null, + null, + null, + ).let { MapVectorLayerImpl(it) } + } + + override fun createRasterLayer(config: MapTiled2dMapLayerConfig): MapRasterLayer? { + val context = requireNotNull(context) { "MapFactory requires an Android Context" } + val cacheDir = File(context.cacheDir, "raster").apply { mkdirs() } + val loader = DataLoader(context, cacheDir, 25L * 1024 * 1024) + return MapRasterLayer( + TiledRasterLayer(MapTiled2dMapLayerConfigImplementation(config), arrayListOf(loader)), + ) + } + + override fun createGpsLayer(): MapGpsLayer? { + val context = requireNotNull(context) { "MapFactory requires an Android Context" } + val locationProvider = GpsProviderType.GOOGLE_FUSED.getProvider(context) + val gpsLayer = GpsLayer( + context = context, + style = GpsStyleInfoFactory.createDefaultStyle(context), + initialLocationProvider = locationProvider, + ).apply { + setHeadingEnabled(false) + setFollowInitializeZoom(25_000f) + lifecycle?.let { registerLifecycle(it) } + } + return MapGpsLayerImpl(GpsLayerHandle(gpsLayer, locationProvider)) + } +} diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapImplementations.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapImplementations.kt new file mode 100644 index 000000000..fa9775b3c --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapImplementations.kt @@ -0,0 +1,241 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import io.openmobilemaps.mapscore.kmp.feature.map.model.GpsMode +import io.openmobilemaps.gps.GpsLayer +import io.openmobilemaps.gps.providers.LocationProviderInterface +import io.openmobilemaps.gps.shared.gps.GpsMode as MapscoreGpsMode +import io.openmobilemaps.mapscore.map.view.MapView as MapscoreMapView +import io.openmobilemaps.mapscore.shared.map.LayerInterface +import io.openmobilemaps.mapscore.shared.map.layers.tiled.vector.Tiled2dMapVectorLayerInterface as MapscoreVectorLayer +import io.openmobilemaps.mapscore.shared.map.layers.tiled.vector.Tiled2dMapVectorLayerSelectionCallbackInterface as MapscoreSelectionCallback +import io.openmobilemaps.mapscore.shared.map.layers.tiled.vector.VectorLayerFeatureInfo as MapscoreFeatureInfo +import io.openmobilemaps.mapscore.shared.map.layers.tiled.vector.VectorLayerFeatureInfoValue as MapscoreFeatureInfoValue +import io.openmobilemaps.mapscore.kmp.feature.map.interop.MapVectorLayerFeatureInfo as SharedFeatureInfo +import io.openmobilemaps.mapscore.kmp.feature.map.interop.MapVectorLayerFeatureInfoValue as SharedFeatureInfoValue + +actual abstract class MapInterface actual constructor(nativeHandle: Any?) { + protected val nativeHandle: Any? = nativeHandle + + actual abstract fun addVectorLayer(layer: MapVectorLayer?) + actual abstract fun removeVectorLayer(layer: MapVectorLayer?) + actual abstract fun addRasterLayer(layer: MapRasterLayer?) + actual abstract fun removeRasterLayer(layer: MapRasterLayer?) + actual abstract fun addGpsLayer(layer: MapGpsLayer?) + actual abstract fun removeGpsLayer(layer: MapGpsLayer?) + actual abstract fun getCamera(): MapCameraInterface? + + actual companion object { + actual fun create(nativeHandle: Any?): MapInterface = MapInterfaceImpl(nativeHandle) + } +} + +private class MapInterfaceImpl(nativeHandle: Any?) : MapInterface(nativeHandle) { + private val mapView = nativeHandle as? MapscoreMapView + private val cameraInterface = MapCameraInterfaceImpl(mapView?.getCamera()) + + override fun addVectorLayer(layer: MapVectorLayer?) { + val handle = layer as? MapVectorLayerImpl ?: return + handle.layerInterface()?.let { mapView?.addLayer(it) } + } + + override fun removeVectorLayer(layer: MapVectorLayer?) { + val handle = layer as? MapVectorLayerImpl ?: return + handle.layerInterface()?.let { mapView?.removeLayer(it) } + } + + override fun addRasterLayer(layer: MapRasterLayer?) { + layer?.layerInterface()?.let { mapView?.addLayer(it) } + } + + override fun removeRasterLayer(layer: MapRasterLayer?) { + layer?.layerInterface()?.let { mapView?.removeLayer(it) } + } + + override fun addGpsLayer(layer: MapGpsLayer?) { + val handle = layer as? MapGpsLayerImpl ?: return + handle.layerInterface()?.let { mapView?.addLayer(it) } + } + + override fun removeGpsLayer(layer: MapGpsLayer?) { + val handle = layer as? MapGpsLayerImpl ?: return + handle.layerInterface()?.let { mapView?.removeLayer(it) } + } + + override fun getCamera(): MapCameraInterface? = cameraInterface +} + +actual open class MapCameraInterface actual constructor() { + actual open fun setBounds(bounds: RectCoord) { + } + + actual open fun moveToCenterPositionZoom(centerPosition: Coord, zoom: Double, animated: Boolean) { + } + + actual open fun setMinZoom(minZoom: Double) { + } + + actual open fun setMaxZoom(maxZoom: Double) { + } + + actual open fun setBoundsRestrictWholeVisibleRect(enabled: Boolean) { + } +} + +private class MapCameraInterfaceImpl(private val nativeHandle: Any?) : MapCameraInterface() { + private val camera = nativeHandle as? io.openmobilemaps.mapscore.shared.map.MapCameraInterface + + override fun setBounds(bounds: RectCoord) { + camera?.setBounds(bounds) + } + + override fun moveToCenterPositionZoom(centerPosition: Coord, zoom: Double, animated: Boolean) { + camera?.moveToCenterPositionZoom(centerPosition, zoom, animated) + } + + override fun setMinZoom(minZoom: Double) { + camera?.setMinZoom(minZoom) + } + + override fun setMaxZoom(maxZoom: Double) { + camera?.setMaxZoom(maxZoom) + } + + override fun setBoundsRestrictWholeVisibleRect(enabled: Boolean) { + camera?.setBoundsRestrictWholeVisibleRect(enabled) + } +} + +actual abstract class MapVectorLayer actual constructor(nativeHandle: Any?) { + protected val nativeHandle: Any? = nativeHandle + + actual abstract fun setSelectionDelegate(delegate: MapVectorLayerSelectionCallbackProxy?) + actual abstract fun setGlobalState(state: Map) +} + +class MapVectorLayerImpl(nativeHandle: Any?) : MapVectorLayer(nativeHandle) { + private val layer = nativeHandle as? MapscoreVectorLayer + + override fun setSelectionDelegate(delegate: MapVectorLayerSelectionCallbackProxy?) { + val callback = delegate?.let { MapVectorLayerSelectionCallbackAdapterImplementation(it) } + layer?.setSelectionDelegate(callback) + } + + override fun setGlobalState(state: Map) { + val mapped = HashMap() + state.forEach { (key, value) -> + mapped[key] = value.asMapscore() + } + layer?.setGlobalState(mapped) + } + + internal fun layerInterface(): LayerInterface? = layer?.asLayerInterface() +} + +actual abstract class MapGpsLayer actual constructor(nativeHandle: Any?) { + protected val nativeHandle: Any? = nativeHandle + + actual abstract fun setMode(mode: GpsMode) + actual abstract fun getMode(): GpsMode + actual abstract fun setOnModeChangedListener(listener: ((GpsMode) -> Unit)?) + actual abstract fun notifyPermissionGranted() + actual abstract fun lastLocation(): Coord? +} + +class MapGpsLayerImpl(nativeHandle: Any?) : MapGpsLayer(nativeHandle) { + private val handle = nativeHandle as? GpsLayerHandle + private val gpsLayer = handle?.layer + private val locationProvider = handle?.locationProvider + private var modeListener: ((GpsMode) -> Unit)? = null + + init { + gpsLayer?.setOnModeChangedListener { mode -> + modeListener?.invoke(mode.asShared()) + } + } + + override fun setMode(mode: GpsMode) { + gpsLayer?.setMode(mode.asMapscore()) + } + + override fun getMode(): GpsMode = gpsLayer?.layerInterface?.getMode()?.asShared() ?: GpsMode.DISABLED + + override fun setOnModeChangedListener(listener: ((GpsMode) -> Unit)?) { + modeListener = listener + } + + override fun notifyPermissionGranted() { + locationProvider?.notifyLocationPermissionGranted() + } + + override fun lastLocation(): Coord? = locationProvider?.getLastLocation() + + internal fun layerInterface(): LayerInterface? = gpsLayer?.asLayerInterface() +} + +private class MapVectorLayerSelectionCallbackAdapterImplementation( + private val proxy: MapVectorLayerSelectionCallbackProxy, +) : MapscoreSelectionCallback() { + override fun didSelectFeature(featureInfo: MapscoreFeatureInfo, layerIdentifier: String, coord: Coord): Boolean { + val shared = featureInfo.asShared(layerIdentifier) + return proxy.handler._didSelectFeature(shared, coord) + } + + override fun didMultiSelectLayerFeatures( + featureInfos: ArrayList, + layerIdentifier: String, + coord: Coord, + ): Boolean { + return proxy.handler._didMultiSelectLayerFeatures(layerIdentifier, coord) + } + + override fun didClickBackgroundConfirmed(coord: Coord): Boolean { + return proxy.handler._didClickBackgroundConfirmed(coord) + } +} + +private fun MapscoreFeatureInfo.asShared(layerIdentifier: String): SharedFeatureInfo { + val props = properties.mapValues { it.value.asShared() } + return SharedFeatureInfo( + identifier = identifier, + layerIdentifier = layerIdentifier, + properties = props, + ) +} + +private fun MapscoreFeatureInfoValue.asShared(): SharedFeatureInfoValue { + val stringValue = stringVal + ?: intVal?.toString() + ?: doubleVal?.toString() + ?: boolVal?.toString() + val list = listStringVal?.filterIsInstance() + return SharedFeatureInfoValue(stringVal = stringValue, listStringVal = list) +} + +private fun SharedFeatureInfoValue.asMapscore(): MapscoreFeatureInfoValue = + MapscoreFeatureInfoValue( + stringVal, + null, + null, + null, + null, + null, + listStringVal?.let { ArrayList(it) }, + ) + +private fun GpsMode.asMapscore(): MapscoreGpsMode = when (this) { + GpsMode.DISABLED -> MapscoreGpsMode.DISABLED + GpsMode.STANDARD -> MapscoreGpsMode.STANDARD + GpsMode.FOLLOW -> MapscoreGpsMode.FOLLOW +} + +private fun MapscoreGpsMode.asShared(): GpsMode = when (this) { + MapscoreGpsMode.DISABLED -> GpsMode.DISABLED + MapscoreGpsMode.STANDARD -> GpsMode.STANDARD + MapscoreGpsMode.FOLLOW -> GpsMode.FOLLOW + MapscoreGpsMode.FOLLOW_AND_TURN -> GpsMode.FOLLOW +} + +data class GpsLayerHandle( + val layer: GpsLayer, + val locationProvider: LocationProviderInterface, +) diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapRasterLayer.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapRasterLayer.kt new file mode 100644 index 000000000..849c1ba01 --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapRasterLayer.kt @@ -0,0 +1,11 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import io.openmobilemaps.mapscore.map.layers.TiledRasterLayer +import io.openmobilemaps.mapscore.shared.map.LayerInterface + +actual open class MapRasterLayer actual constructor(nativeHandle: Any?) { + protected val nativeHandle: Any? = nativeHandle + + internal fun layerInterface(): LayerInterface? = + (nativeHandle as? TiledRasterLayer)?.layerInterface() +} diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapTiled2dMapLayerConfigImplementation.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapTiled2dMapLayerConfigImplementation.kt new file mode 100644 index 000000000..7676a8aa9 --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapTiled2dMapLayerConfigImplementation.kt @@ -0,0 +1,120 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import io.openmobilemaps.mapscore.kmp.feature.map.interop.MapTiled2dMapLayerConfig as SharedLayerConfig +import io.openmobilemaps.mapscore.kmp.feature.map.interop.MapTiled2dMapZoomInfo as SharedZoomInfo +import io.openmobilemaps.mapscore.shared.map.coordinates.Coord as MapscoreCoord +import io.openmobilemaps.mapscore.shared.map.coordinates.CoordinateConversionHelperInterface +import io.openmobilemaps.mapscore.shared.map.layers.tiled.Tiled2dMapLayerConfig as MapscoreLayerConfig +import io.openmobilemaps.mapscore.shared.map.layers.tiled.Tiled2dMapZoomInfo as MapscoreZoomInfo +import io.openmobilemaps.mapscore.shared.map.layers.tiled.Tiled2dMapZoomLevelInfo +import io.openmobilemaps.mapscore.shared.map.layers.tiled.Tiled2dMapVectorSettings +import kotlin.math.pow + +class MapTiled2dMapLayerConfigImplementation( + private val config: SharedLayerConfig, +) : MapscoreLayerConfig() { + + private val coordinateConverter by lazy { + CoordinateConversionHelperInterface.independentInstance() + } + + override fun getCoordinateSystemIdentifier(): Int = config.coordinateSystemIdentifier + + override fun getTileUrl(x: Int, y: Int, t: Int, zoom: Int): String { + val tilesPerAxis = 2.0.pow(zoom.toDouble()) + + val bounds = config.bounds + val wmMinX = bounds.topLeft.x + val wmMaxX = bounds.bottomRight.x + val wmMaxY = bounds.topLeft.y + val wmMinY = bounds.bottomRight.y + + val tileWidth = (wmMaxX - wmMinX) / tilesPerAxis + val tileHeight = (wmMaxY - wmMinY) / tilesPerAxis + + val wmMinTileX = wmMinX + x * tileWidth + val wmMaxTileX = wmMinX + (x + 1) * tileWidth + val wmMaxTileY = wmMaxY - y * tileHeight + val wmMinTileY = wmMaxY - (y + 1) * tileHeight + + val wmTopLeft = MapscoreCoord( + systemIdentifier = config.coordinateSystemIdentifier, + x = wmMinTileX, + y = wmMaxTileY, + z = 0.0, + ) + val wmBottomRight = MapscoreCoord( + systemIdentifier = config.coordinateSystemIdentifier, + x = wmMaxTileX, + y = wmMinTileY, + z = 0.0, + ) + + val lv95TopLeft = coordinateConverter.convert(config.bboxCoordinateSystemIdentifier, wmTopLeft) + val lv95BottomRight = coordinateConverter.convert(config.bboxCoordinateSystemIdentifier, wmBottomRight) + + val bbox = listOf( + lv95TopLeft.x, + lv95BottomRight.y, + lv95BottomRight.x, + lv95TopLeft.y, + ).joinToString(separator = ",") { "%.3f".format(it) } + + val width = config.tileWidth + val height = config.tileHeight + + return config.urlFormat + .replace("{bbox}", bbox, ignoreCase = true) + .replace("{width}", width.toString(), ignoreCase = true) + .replace("{height}", height.toString(), ignoreCase = true) + } + + override fun getZoomLevelInfos(): ArrayList = + ArrayList().apply { + for (zoom in config.minZoomLevel..config.maxZoomLevel) { + add(createZoomLevelInfo(zoom)) + } + } + + override fun getVirtualZoomLevelInfos(): ArrayList = + ArrayList().apply { + if (config.minZoomLevel > 0) { + for (zoom in 0 until config.minZoomLevel) { + add(createZoomLevelInfo(zoom)) + } + } + } + + override fun getZoomInfo(): MapscoreZoomInfo = config.zoomInfo.asMapscore() + + override fun getLayerName(): String = config.layerName + + override fun getVectorSettings(): Tiled2dMapVectorSettings? = null + + override fun getBounds() = config.bounds + + private fun createZoomLevelInfo(zoomLevel: Int): Tiled2dMapZoomLevelInfo { + val tileCount = 2.0.pow(zoomLevel.toDouble()) + val zoom = config.baseZoom / tileCount + val width = (config.baseWidth / tileCount).toFloat() + return Tiled2dMapZoomLevelInfo( + zoom = zoom, + tileWidthLayerSystemUnits = width, + numTilesX = tileCount.toInt(), + numTilesY = tileCount.toInt(), + numTilesT = 1, + zoomLevelIdentifier = zoomLevel, + bounds = config.bounds, + ) + } +} + +private fun SharedZoomInfo.asMapscore(): MapscoreZoomInfo = MapscoreZoomInfo( + zoomLevelScaleFactor = zoomLevelScaleFactor.toFloat(), + numDrawPreviousLayers = numDrawPreviousLayers, + numDrawPreviousOrLaterTLayers = numDrawPreviousOrLaterTLayers, + adaptScaleToScreen = adaptScaleToScreen, + maskTile = maskTile, + underzoom = underzoom, + overzoom = overzoom, +) diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerSelectionCallbackProxy.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerSelectionCallbackProxy.kt new file mode 100644 index 000000000..3bb6a53e1 --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerSelectionCallbackProxy.kt @@ -0,0 +1,7 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +actual class MapVectorLayerSelectionCallbackProxy actual constructor( + handler: MapVectorLayerSelectionCallback, +) { + actual val handler: MapVectorLayerSelectionCallback = handler +} diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapViewWrapper.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapViewWrapper.kt new file mode 100644 index 000000000..e12624fbb --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapViewWrapper.kt @@ -0,0 +1,22 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import android.view.View +import io.openmobilemaps.mapscore.kmp.feature.map.interop.MapInterface + +actual typealias PlatformMapView = View + +actual class MapViewWrapper actual constructor() { + private lateinit var viewInternal: View + private lateinit var mapInterfaceInternal: MapInterface + + actual val view: View + get() = viewInternal + + actual val mapInterface: MapInterface + get() = mapInterfaceInternal + + constructor(view: View, mapInterface: MapInterface) : this() { + viewInternal = view + mapInterfaceInternal = mapInterface + } +} diff --git a/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/RectCoord.kt b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/RectCoord.kt new file mode 100644 index 000000000..e1bc84bbb --- /dev/null +++ b/kmp/androidMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/RectCoord.kt @@ -0,0 +1,5 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import io.openmobilemaps.mapscore.shared.map.coordinates.RectCoord as MapscoreRectCoord + +actual typealias RectCoord = MapscoreRectCoord diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/Coord.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/Coord.kt new file mode 100644 index 000000000..0cf764b13 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/Coord.kt @@ -0,0 +1,13 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +expect class Coord( + systemIdentifier: Int, + x: Double, + y: Double, + z: Double, +) { + val systemIdentifier: Int + val x: Double + val y: Double + val z: Double +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCameraInterface.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCameraInterface.kt new file mode 100644 index 000000000..9b647fa9f --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCameraInterface.kt @@ -0,0 +1,9 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +expect open class MapCameraInterface() { + open fun setBounds(bounds: RectCoord) + open fun moveToCenterPositionZoom(centerPosition: Coord, zoom: Double, animated: Boolean) + open fun setMinZoom(minZoom: Double) + open fun setMaxZoom(maxZoom: Double) + open fun setBoundsRestrictWholeVisibleRect(enabled: Boolean) +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCoreInterop.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCoreInterop.kt new file mode 100644 index 000000000..eb3724df6 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCoreInterop.kt @@ -0,0 +1,5 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +expect object MapCoreInterop { + fun moveToCenter(coord: Coord) +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapDataProviderProtocol.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapDataProviderProtocol.kt new file mode 100644 index 000000000..79bad348d --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapDataProviderProtocol.kt @@ -0,0 +1,8 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +interface MapDataProviderProtocol { + fun getStyleJson(): String? + suspend fun loadGeojson(resourcePath: String, url: String): ByteArray? + suspend fun loadSpriteAsync(resourcePath: String, url: String, scale: Int): ByteArray? + suspend fun loadSpriteJsonAsync(resourcePath: String, url: String, scale: Int): ByteArray? +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapFactory.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapFactory.kt new file mode 100644 index 000000000..abc9a977c --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapFactory.kt @@ -0,0 +1,24 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import kotlinx.coroutines.CoroutineScope + +expect abstract class MapFactory constructor( + platformContext: Any? = null, + coroutineScope: CoroutineScope? = null, + lifecycle: Any? = null, +) { + abstract fun createVectorLayer( + layerName: String, + dataProvider: MapDataProviderProtocol, + ): MapVectorLayer? + abstract fun createRasterLayer(config: MapTiled2dMapLayerConfig): MapRasterLayer? + abstract fun createGpsLayer(): MapGpsLayer? + + companion object { + fun create( + platformContext: Any? = null, + coroutineScope: CoroutineScope? = null, + lifecycle: Any? = null, + ): MapFactory + } +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapGpsLayer.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapGpsLayer.kt new file mode 100644 index 000000000..7d70b910d --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapGpsLayer.kt @@ -0,0 +1,11 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import io.openmobilemaps.mapscore.kmp.feature.map.model.GpsMode + +expect abstract class MapGpsLayer constructor(nativeHandle: Any? = null) { + abstract fun setMode(mode: GpsMode) + abstract fun getMode(): GpsMode + abstract fun setOnModeChangedListener(listener: ((GpsMode) -> Unit)?) + abstract fun notifyPermissionGranted() + abstract fun lastLocation(): Coord? +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapInterface.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapInterface.kt new file mode 100644 index 000000000..8a7a106d0 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapInterface.kt @@ -0,0 +1,15 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +expect abstract class MapInterface constructor(nativeHandle: Any? = null) { + abstract fun addVectorLayer(layer: MapVectorLayer?) + abstract fun removeVectorLayer(layer: MapVectorLayer?) + abstract fun addRasterLayer(layer: MapRasterLayer?) + abstract fun removeRasterLayer(layer: MapRasterLayer?) + abstract fun addGpsLayer(layer: MapGpsLayer?) + abstract fun removeGpsLayer(layer: MapGpsLayer?) + abstract fun getCamera(): MapCameraInterface? + + companion object { + fun create(nativeHandle: Any?): MapInterface + } +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapRasterLayer.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapRasterLayer.kt new file mode 100644 index 000000000..8f8188186 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapRasterLayer.kt @@ -0,0 +1,3 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +expect open class MapRasterLayer constructor(nativeHandle: Any? = null) diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapTiled2dMapLayerConfig.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapTiled2dMapLayerConfig.kt new file mode 100644 index 000000000..f6f778579 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapTiled2dMapLayerConfig.kt @@ -0,0 +1,26 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +data class MapTiled2dMapZoomInfo( + val zoomLevelScaleFactor: Double, + val numDrawPreviousLayers: Int, + val numDrawPreviousOrLaterTLayers: Int, + val adaptScaleToScreen: Boolean, + val maskTile: Boolean, + val underzoom: Boolean, + val overzoom: Boolean, +) + +data class MapTiled2dMapLayerConfig( + val layerName: String, + val urlFormat: String, + val zoomInfo: MapTiled2dMapZoomInfo, + val minZoomLevel: Int, + val maxZoomLevel: Int, + val coordinateSystemIdentifier: Int, + val bboxCoordinateSystemIdentifier: Int, + val bounds: RectCoord, + val baseZoom: Double, + val baseWidth: Double, + val tileWidth: Int = 512, + val tileHeight: Int = 512, +) diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayer.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayer.kt new file mode 100644 index 000000000..50f7930f9 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayer.kt @@ -0,0 +1,6 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +expect abstract class MapVectorLayer constructor(nativeHandle: Any? = null) { + abstract fun setSelectionDelegate(delegate: MapVectorLayerSelectionCallbackProxy?) + abstract fun setGlobalState(state: Map) +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerFeatureInfo.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerFeatureInfo.kt new file mode 100644 index 000000000..180822d32 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerFeatureInfo.kt @@ -0,0 +1,12 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +data class MapVectorLayerFeatureInfo( + val identifier: String, + val layerIdentifier: String, + val properties: Map, +) + +data class MapVectorLayerFeatureInfoValue( + val stringVal: String? = null, + val listStringVal: List? = null, +) diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerSelectionCallback.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerSelectionCallback.kt new file mode 100644 index 000000000..50a144fc4 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerSelectionCallback.kt @@ -0,0 +1,11 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +interface MapVectorLayerSelectionCallback { + fun _didSelectFeature(featureInfo: MapVectorLayerFeatureInfo, coord: Coord): Boolean + fun _didMultiSelectLayerFeatures(layerIdentifier: String, coord: Coord): Boolean + fun _didClickBackgroundConfirmed(coord: Coord): Boolean +} + +expect class MapVectorLayerSelectionCallbackProxy(handler: MapVectorLayerSelectionCallback) { + val handler: MapVectorLayerSelectionCallback +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapViewWrapper.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapViewWrapper.kt new file mode 100644 index 000000000..33bfa52fd --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapViewWrapper.kt @@ -0,0 +1,10 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import io.openmobilemaps.mapscore.kmp.feature.map.interop.MapInterface + +expect class PlatformMapView + +expect class MapViewWrapper() { + val view: PlatformMapView + val mapInterface: MapInterface +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/RectCoord.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/RectCoord.kt new file mode 100644 index 000000000..ea3365679 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/RectCoord.kt @@ -0,0 +1,9 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +expect class RectCoord( + topLeft: Coord, + bottomRight: Coord, +) { + val topLeft: Coord + val bottomRight: Coord +} diff --git a/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/model/GpsMode.kt b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/model/GpsMode.kt new file mode 100644 index 000000000..53b1c4d28 --- /dev/null +++ b/kmp/commonMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/model/GpsMode.kt @@ -0,0 +1,7 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.model + +enum class GpsMode { + DISABLED, + STANDARD, + FOLLOW, +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/Coord.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/Coord.kt new file mode 100644 index 000000000..4e299f4c5 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/Coord.kt @@ -0,0 +1,5 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import MapCoreSharedModule.MCCoord + +actual typealias Coord = MCCoord diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/GpsCoord.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/GpsCoord.kt new file mode 100644 index 000000000..b4fa1fce3 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/GpsCoord.kt @@ -0,0 +1,5 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import LayerGpsSharedModule.MCCoord + +typealias GpsCoord = MCCoord diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCameraInterface.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCameraInterface.kt new file mode 100644 index 000000000..e4d20d588 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCameraInterface.kt @@ -0,0 +1,5 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import MapCoreSharedModule.MCMapCameraInterface + +actual typealias MapCameraInterface = MCMapCameraInterface diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCoreInterop.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCoreInterop.kt new file mode 100644 index 000000000..e31a8d44a --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapCoreInterop.kt @@ -0,0 +1,12 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +@OptIn(ExperimentalObjCName::class) +@ObjCName("MapCoreInterop", exact = true) +actual object MapCoreInterop { + actual fun moveToCenter(coord: Coord) { + coord.hashCode() + } +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapDataProviderLocalDataProviderImplementation.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapDataProviderLocalDataProviderImplementation.kt new file mode 100644 index 000000000..46ff95756 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapDataProviderLocalDataProviderImplementation.kt @@ -0,0 +1,66 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import MapCoreObjC.MCMapCoreObjCFactory +import MapCoreSharedModule.MCDataLoaderResult +import MapCoreSharedModule.DJFuture +import MapCoreSharedModule.DJPromise +import MapCoreSharedModule.MCLoaderStatusOK +import MapCoreSharedModule.MCTextureLoaderResult +import MapCoreSharedModule.MCTextureHolderInterfaceProtocol +import MapCoreSharedModule.MCTiled2dMapVectorLayerLocalDataProviderInterfaceProtocol +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import platform.Foundation.NSData +import platform.Foundation.create +import platform.darwin.NSObject + +internal class MapDataProviderLocalDataProviderImplementation( + private val dataProvider: MapDataProviderProtocol, +) : NSObject(), MCTiled2dMapVectorLayerLocalDataProviderInterfaceProtocol { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + override fun getStyleJson(): String? = dataProvider.getStyleJson() + + override fun loadGeojson(sourceName: String, url: String): DJFuture { + val promise = DJPromise() + scope.launch { + val result = runCatching { dataProvider.loadGeojson(sourceName, url) }.getOrNull() + val data = result?.toNSData() + promise.setValue(MCDataLoaderResult(data = data, etag = null, status = MCLoaderStatusOK, errorCode = null)) + } + return promise.getFuture() + } + + override fun loadSpriteAsync(spriteId: String, url: String, scale: Int): DJFuture { + val promise = DJPromise() + scope.launch { + val result = runCatching { dataProvider.loadSpriteAsync(spriteId, url, scale) }.getOrNull() + val holder = result?.toNSData() + ?.let { MCMapCoreObjCFactory.createTextureHolderWithData(it) as? MCTextureHolderInterfaceProtocol } + promise.setValue(MCTextureLoaderResult(data = holder, etag = null, status = MCLoaderStatusOK, errorCode = null)) + } + return promise.getFuture() + } + + override fun loadSpriteJsonAsync(spriteId: String, url: String, scale: Int): DJFuture { + val promise = DJPromise() + scope.launch { + val result = runCatching { dataProvider.loadSpriteJsonAsync(spriteId, url, scale) }.getOrNull() + val data = result?.toNSData() + promise.setValue(MCDataLoaderResult(data = data, etag = null, status = MCLoaderStatusOK, errorCode = null)) + } + return promise.getFuture() + } +} + +@OptIn(kotlinx.cinterop.BetaInteropApi::class) +private fun ByteArray.toNSData(): NSData { + if (isEmpty()) return NSData() + return usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) + } +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapFactoryImplementation.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapFactoryImplementation.kt new file mode 100644 index 000000000..d612ed07e --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapFactoryImplementation.kt @@ -0,0 +1,128 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +import MapCoreObjC.MCMapCoreObjCFactory +import MapCoreSharedModule.MCFontLoaderInterfaceProtocol +import MapCoreSharedModule.MCLoaderInterfaceProtocol +import MapCoreSharedModule.MCTiled2dMapRasterLayerInterface +import MapCoreSharedModule.MCTiled2dMapVectorLayerInterface +import platform.Foundation.NSBundle +import platform.Foundation.NSLog +import platform.Foundation.NSNumber + +@OptIn(ExperimentalObjCName::class) +@ObjCName("MapFactory", exact = true) +actual abstract class MapFactory actual constructor( + platformContext: Any?, + coroutineScope: kotlinx.coroutines.CoroutineScope?, + lifecycle: Any?, +) { + protected val platformContext: Any? = platformContext + protected val coroutineScope: kotlinx.coroutines.CoroutineScope? = coroutineScope + protected val lifecycle: Any? = lifecycle + + actual abstract fun createVectorLayer( + layerName: String, + dataProvider: MapDataProviderProtocol, + ): MapVectorLayer? + actual abstract fun createRasterLayer(config: MapTiled2dMapLayerConfig): MapRasterLayer? + actual abstract fun createGpsLayer(): MapGpsLayer? + + actual companion object { + actual fun create( + platformContext: Any?, + coroutineScope: kotlinx.coroutines.CoroutineScope?, + lifecycle: Any?, + ): MapFactory = MapFactoryImpl(platformContext, coroutineScope, lifecycle) + } + + protected fun findMapBundle(): NSBundle? { + return sharedResourcesBundle() + } + + protected fun logMissing(resource: String) { + NSLog("MapFactory: missing %s", resource) + } + + protected fun sharedResourcesBundle(): NSBundle? { + return MapResourceBundleRegistry.bundleProvider?.invoke() + } + +} + +private class MapFactoryImpl( + platformContext: Any?, + coroutineScope: kotlinx.coroutines.CoroutineScope?, + lifecycle: Any?, +) : MapFactory(platformContext, coroutineScope, lifecycle) { + override fun createVectorLayer( + layerName: String, + dataProvider: MapDataProviderProtocol, + ): MapVectorLayer? { + val styleJson = dataProvider.getStyleJson() ?: run { + logMissing("style json for $layerName") + return null + } + val bundle = findMapBundle() ?: run { + logMissing("bundle for MapFonts") + return null + } + val fontLoader = MCMapCoreObjCFactory.createFontLoaderWithBundle(bundle) as? MCFontLoaderInterfaceProtocol + ?: run { + logMissing("font loader") + return null + } + val loader = MCMapCoreObjCFactory.createTextureLoader() as? MCLoaderInterfaceProtocol + ?: run { + logMissing("texture loader") + return null + } + val loaders = listOf(loader) + val provider = MapDataProviderLocalDataProviderImplementation(dataProvider) + val layer = MCTiled2dMapVectorLayerInterface.createExplicitly( + layerName, + styleJson = styleJson, + localStyleJson = NSNumber(bool = true), + loaders = loaders, + fontLoader = fontLoader, + localDataProvider = provider, + customZoomInfo = null, + symbolDelegate = null, + sourceUrlParams = null, + ) + return layer?.let { MapVectorLayerImpl(it) } + } + + override fun createRasterLayer(config: MapTiled2dMapLayerConfig): MapRasterLayer? { + val loader = MCMapCoreObjCFactory.createTextureLoader() as? MCLoaderInterfaceProtocol + ?: run { + logMissing("texture loader") + return null + } + val loaders = listOf(loader) + val layer = MCTiled2dMapRasterLayerInterface.create( + MapTiled2dMapLayerConfigImplementation(config), + loaders = loaders, + ) + return layer?.let { MapRasterLayer(it) } + } + + override fun createGpsLayer(): MapGpsLayer? { + return MapGpsLayerImpl(null) + } +} + +private val fontNames = listOf( + "Frutiger Neue Bold", + "Frutiger Neue Condensed Bold", + "Frutiger Neue Condensed Medium", + "Frutiger Neue Condensed Regular", + "Frutiger Neue Italic", + "Frutiger Neue LT Condensed Bold", + "Frutiger Neue Light", + "Frutiger Neue Medium", + "Frutiger Neue Regular", + "FrutigerLTStd-Roman", +) diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapGpsLayerImplementation.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapGpsLayerImplementation.kt new file mode 100644 index 000000000..e7e9d60a0 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapGpsLayerImplementation.kt @@ -0,0 +1,174 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +import LayerGpsSharedModule.MCGpsLayerCallbackInterfaceProtocol +import LayerGpsSharedModule.MCGpsLayerInterface +import LayerGpsSharedModule.MCGpsMode +import LayerGpsSharedModule.MCGpsModeDISABLED +import LayerGpsSharedModule.MCGpsModeFOLLOW +import LayerGpsSharedModule.MCGpsModeFOLLOW_AND_TURN +import LayerGpsSharedModule.MCGpsModeSTANDARD +import LayerGpsSharedModule.MCGpsStyleInfoInterface +import LayerGpsSharedModule.MCColor +import LayerGpsSharedModule.MCTextureHolderInterfaceProtocol as GpsTextureHolderInterfaceProtocol +import MapCoreObjC.MCMapCoreObjCFactory +import MapCoreSharedModule.MCCoordinateSystemIdentifiers +import MapCoreSharedModule.MCLayerInterfaceProtocol as MapCoreLayerInterfaceProtocol +import io.openmobilemaps.mapscore.kmp.feature.map.model.GpsMode +import kotlinx.cinterop.useContents +import platform.CoreLocation.CLHeading +import platform.CoreLocation.CLLocation +import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.CLLocationManagerDelegateProtocol +import platform.darwin.NSObject +import platform.UIKit.UIImage +import platform.UIKit.UIImagePNGRepresentation + +@OptIn(ExperimentalObjCName::class) +@ObjCName("MapGpsLayer", exact = true) +actual abstract class MapGpsLayer actual constructor(nativeHandle: Any?) { + protected val nativeHandle: Any? = nativeHandle + + actual abstract fun setMode(mode: GpsMode) + actual abstract fun getMode(): GpsMode + actual abstract fun setOnModeChangedListener(listener: ((GpsMode) -> Unit)?) + actual abstract fun notifyPermissionGranted() + actual abstract fun lastLocation(): Coord? +} + +class MapGpsLayerImpl(nativeHandle: Any?) : MapGpsLayer(nativeHandle) { + private val gpsLayer: MCGpsLayerInterface = + requireNotNull(MCGpsLayerInterface.create(styleInfo = defaultStyle())) + private val locationManager = CLLocationManager() + private val locationDelegate = GpsLocationDelegate(this) + private val callbackHandler = GpsLayerCallbackHandler(this) + private var modeListener: ((GpsMode) -> Unit)? = null + private var lastKnownLocation: Coord? = null + + init { + gpsLayer.setCallbackHandler(callbackHandler) + gpsLayer.enableHeading(true) + locationManager.delegate = locationDelegate + locationManager.desiredAccuracy = platform.CoreLocation.kCLLocationAccuracyBest + locationManager.headingFilter = 1.0 + } + + override fun setMode(mode: GpsMode) { + gpsLayer.setMode(mode.asLayerMode()) + } + + override fun getMode(): GpsMode = gpsLayer.getMode().asSharedMode() + + override fun setOnModeChangedListener(listener: ((GpsMode) -> Unit)?) { + modeListener = listener + } + + override fun notifyPermissionGranted() { + locationManager.startUpdatingLocation() + locationManager.startUpdatingHeading() + } + + override fun lastLocation(): Coord? = lastKnownLocation + + internal fun layerInterface(): MapCoreLayerInterfaceProtocol? = + gpsLayer.asLayerInterface() as? MapCoreLayerInterfaceProtocol + + internal fun updateLocation(location: CLLocation) { + val coord = location.coordinate.useContents { + GpsCoord( + systemIdentifier = MCCoordinateSystemIdentifiers.EPSG4326(), + x = longitude, + y = latitude, + z = location.altitude, + ) + } + gpsLayer.setDrawPoint(shouldDrawHeading()) + gpsLayer.setDrawHeading(shouldDrawHeading()) + gpsLayer.updatePosition(coord, horizontalAccuracyM = location.horizontalAccuracy) + lastKnownLocation = Coord( + systemIdentifier = coord.systemIdentifier(), + x = coord.x(), + y = coord.y(), + z = coord.z(), + ) + } + + internal fun updateHeading(heading: Double) { + gpsLayer.updateHeading(heading.toFloat()) + } + + internal fun handleModeChanged(mode: MCGpsMode) { + modeListener?.invoke(mode.asSharedMode()) + } + + private fun shouldDrawHeading(): Boolean = true +} + +private class GpsLayerCallbackHandler( + private val layer: MapGpsLayerImpl, +) : NSObject(), MCGpsLayerCallbackInterfaceProtocol { + override fun modeDidChange(mode: MCGpsMode) { + layer.handleModeChanged(mode) + } + + override fun onPointClick(coordinate: GpsCoord) { + // no-op + } +} + +private class GpsLocationDelegate( + private val layer: MapGpsLayerImpl, +) : NSObject(), CLLocationManagerDelegateProtocol { + override fun locationManager(manager: CLLocationManager, didUpdateLocations: List<*>) { + val location = didUpdateLocations.lastOrNull() as? CLLocation ?: return + layer.updateLocation(location) + } + + override fun locationManager(manager: CLLocationManager, didUpdateHeading: CLHeading) { + layer.updateHeading(didUpdateHeading.trueHeading) + } + + override fun locationManager(manager: CLLocationManager, didFailWithError: platform.Foundation.NSError) { + layer.setMode(GpsMode.DISABLED) + } +} + +private fun GpsMode.asLayerMode(): MCGpsMode = when (this) { + GpsMode.DISABLED -> MCGpsModeDISABLED + GpsMode.STANDARD -> MCGpsModeSTANDARD + GpsMode.FOLLOW -> MCGpsModeFOLLOW +} + +private fun MCGpsMode.asSharedMode(): GpsMode = when (this) { + MCGpsModeDISABLED -> GpsMode.DISABLED + MCGpsModeSTANDARD -> GpsMode.STANDARD + MCGpsModeFOLLOW -> GpsMode.FOLLOW + MCGpsModeFOLLOW_AND_TURN -> GpsMode.FOLLOW + else -> GpsMode.STANDARD +} + +private fun defaultStyle(): MCGpsStyleInfoInterface? { + val pointTexture = loadTexture("ic_gps_point") + val headingTexture = loadTexture("ic_gps_direction") + val accuracyColor = MCColor(r = 112f / 255f, g = 173f / 255f, b = 204f / 255f, a = 0.2f) + return MCGpsStyleInfoInterface.create(pointTexture, headingTexture = headingTexture, courseTexture = null, accuracyColor = accuracyColor) +} + +private fun loadTexture(name: String): GpsTextureHolderInterfaceProtocol? { + val bundle = findBundleWithImage(name) ?: return null + val image = UIImage.imageNamed(name, inBundle = bundle, compatibleWithTraitCollection = null) ?: return null + val data = UIImagePNGRepresentation(image) ?: return null + return MCMapCoreObjCFactory.createTextureHolderWithData(data) as? GpsTextureHolderInterfaceProtocol +} + +private fun findBundleWithImage(name: String): platform.Foundation.NSBundle? { + val bundles = (platform.Foundation.NSBundle.allBundles + platform.Foundation.NSBundle.allFrameworks) + .mapNotNull { it as? platform.Foundation.NSBundle } + for (bundle in bundles) { + val image = UIImage.imageNamed(name, inBundle = bundle, compatibleWithTraitCollection = null) + if (image != null) return bundle + } + return null +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapInterfaceImplementation.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapInterfaceImplementation.kt new file mode 100644 index 000000000..6dbf49aa7 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapInterfaceImplementation.kt @@ -0,0 +1,60 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +import MapCoreSharedModule.MCMapInterface + +@OptIn(ExperimentalObjCName::class) +@ObjCName("MapInterface", exact = true) +actual abstract class MapInterface actual constructor(nativeHandle: Any?) { + protected val nativeHandle: Any? = nativeHandle + + actual abstract fun addVectorLayer(layer: MapVectorLayer?) + actual abstract fun removeVectorLayer(layer: MapVectorLayer?) + actual abstract fun addRasterLayer(layer: MapRasterLayer?) + actual abstract fun removeRasterLayer(layer: MapRasterLayer?) + actual abstract fun addGpsLayer(layer: MapGpsLayer?) + actual abstract fun removeGpsLayer(layer: MapGpsLayer?) + actual abstract fun getCamera(): MapCameraInterface? + + actual companion object { + actual fun create(nativeHandle: Any?): MapInterface = MapInterfaceImpl(nativeHandle) + } +} + +private class MapInterfaceImpl(nativeHandle: Any?) : MapInterface(nativeHandle) { + private val nativeMapInterface = nativeHandle as? MCMapInterface + + override fun addVectorLayer(layer: MapVectorLayer?) { + val handle = layer as? MapVectorLayerImpl ?: return + handle.layerInterface()?.let { nativeMapInterface?.addLayer(it) } + } + + override fun removeVectorLayer(layer: MapVectorLayer?) { + val handle = layer as? MapVectorLayerImpl ?: return + handle.layerInterface()?.let { nativeMapInterface?.removeLayer(it) } + } + + override fun addRasterLayer(layer: MapRasterLayer?) { + val handle = layer ?: return + handle.layerInterface()?.let { nativeMapInterface?.addLayer(it) } + } + + override fun removeRasterLayer(layer: MapRasterLayer?) { + val handle = layer ?: return + handle.layerInterface()?.let { nativeMapInterface?.removeLayer(it) } + } + + override fun addGpsLayer(layer: MapGpsLayer?) { + val handle = layer as? MapGpsLayerImpl ?: return + handle.layerInterface()?.let { nativeMapInterface?.addLayer(it) } + } + + override fun removeGpsLayer(layer: MapGpsLayer?) { + val handle = layer as? MapGpsLayerImpl ?: return + handle.layerInterface()?.let { nativeMapInterface?.removeLayer(it) } + } + + override fun getCamera(): MapCameraInterface? = nativeMapInterface?.getCamera() +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapRasterLayerImplementation.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapRasterLayerImplementation.kt new file mode 100644 index 000000000..566061987 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapRasterLayerImplementation.kt @@ -0,0 +1,18 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +import MapCoreSharedModule.MCTiled2dMapRasterLayerInterface +import MapCoreSharedModule.MCLayerInterfaceProtocol + +@OptIn(ExperimentalObjCName::class) +@ObjCName("MapRasterLayer", exact = true) +actual open class MapRasterLayer actual constructor( + nativeHandle: Any?, +) { + private val layer = nativeHandle as? MCTiled2dMapRasterLayerInterface + + internal fun layerInterface(): MCLayerInterfaceProtocol? = + layer?.asLayerInterface() +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapResourceBundleRegistry.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapResourceBundleRegistry.kt new file mode 100644 index 000000000..33e68d396 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapResourceBundleRegistry.kt @@ -0,0 +1,7 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import platform.Foundation.NSBundle + +object MapResourceBundleRegistry { + var bundleProvider: (() -> NSBundle?)? = null +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapTiled2dMapLayerConfigImplementation.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapTiled2dMapLayerConfigImplementation.kt new file mode 100644 index 000000000..e866007a6 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapTiled2dMapLayerConfigImplementation.kt @@ -0,0 +1,109 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import MapCoreSharedModule.MCCoordinateConversionHelperInterface +import MapCoreSharedModule.MCTiled2dMapLayerConfigProtocol +import MapCoreSharedModule.MCTiled2dMapVectorSettings +import MapCoreSharedModule.MCTiled2dMapZoomInfo +import MapCoreSharedModule.MCTiled2dMapZoomLevelInfo +import kotlin.math.pow +import platform.darwin.NSObject + +class MapTiled2dMapLayerConfigImplementation( + private val config: MapTiled2dMapLayerConfig, +) : NSObject(), MCTiled2dMapLayerConfigProtocol { + private val coordinateConverter = MCCoordinateConversionHelperInterface.independentInstance() + + override fun getCoordinateSystemIdentifier(): Int = config.coordinateSystemIdentifier + + override fun getTileUrl(x: Int, y: Int, t: Int, zoom: Int): String { + var url = config.urlFormat + val tilesPerAxis = 2.0.pow(zoom.toDouble()) + + val bounds = config.bounds + val wmMinX = bounds.topLeft.x + val wmMaxX = bounds.bottomRight.x + val wmMaxY = bounds.topLeft.y + val wmMinY = bounds.bottomRight.y + + val totalWidth = wmMaxX - wmMinX + val totalHeight = wmMaxY - wmMinY + + val tileWidth = totalWidth / tilesPerAxis + val tileHeight = totalHeight / tilesPerAxis + + val wmMinTileX = wmMinX + x.toDouble() * tileWidth + val wmMaxTileX = wmMinX + (x + 1.0) * tileWidth + val wmMaxTileY = wmMaxY - y.toDouble() * tileHeight + val wmMinTileY = wmMaxY - (y + 1.0) * tileHeight + + val wmTopLeft = Coord( + systemIdentifier = config.coordinateSystemIdentifier, + x = wmMinTileX, + y = wmMaxTileY, + z = 0.0, + ) + val wmBottomRight = Coord( + systemIdentifier = config.coordinateSystemIdentifier, + x = wmMaxTileX, + y = wmMinTileY, + z = 0.0, + ) + + val targetSystem = config.bboxCoordinateSystemIdentifier + val topLeft = coordinateConverter?.convert(targetSystem, coordinate = wmTopLeft) ?: wmTopLeft + val bottomRight = coordinateConverter?.convert(targetSystem, coordinate = wmBottomRight) ?: wmBottomRight + + val bboxString = "${topLeft.x},${bottomRight.y},${bottomRight.x},${topLeft.y}" + + url = url.replace("{bbox}", bboxString) + url = url.replace("{width}", config.tileWidth.toString()) + url = url.replace("{height}", config.tileHeight.toString()) + url = url.replace("{WIDTH}", config.tileWidth.toString()) + url = url.replace("{HEIGHT}", config.tileHeight.toString()) + return url + } + + override fun getZoomLevelInfos(): List { + return (config.minZoomLevel..config.maxZoomLevel).map { getZoomLevelInfo(it) } + } + + override fun getVirtualZoomLevelInfos(): List { + val minZoom = config.minZoomLevel + if (minZoom <= 0) return emptyList() + return (0 until minZoom).map { getZoomLevelInfo(it) } + } + + override fun getZoomInfo(): MCTiled2dMapZoomInfo { + val info = config.zoomInfo + return MCTiled2dMapZoomInfo( + zoomLevelScaleFactor = info.zoomLevelScaleFactor.toFloat(), + numDrawPreviousLayers = info.numDrawPreviousLayers, + numDrawPreviousOrLaterTLayers = info.numDrawPreviousOrLaterTLayers, + adaptScaleToScreen = info.adaptScaleToScreen, + maskTile = info.maskTile, + underzoom = info.underzoom, + overzoom = info.overzoom, + ) + } + + override fun getLayerName(): String = config.layerName + + override fun getVectorSettings(): MCTiled2dMapVectorSettings? = null + + override fun getBounds(): RectCoord? = config.bounds + + private fun getZoomLevelInfo(zoomLevel: Int): MCTiled2dMapZoomLevelInfo { + val tileCount = 2.0.pow(zoomLevel.toDouble()) + val zoom = config.baseZoom / tileCount + val width = config.baseWidth / tileCount + return MCTiled2dMapZoomLevelInfo( + zoom = zoom, + tileWidthLayerSystemUnits = width.toFloat(), + numTilesX = tileCount.toInt(), + numTilesY = tileCount.toInt(), + numTilesT = 1, + zoomLevelIdentifier = zoomLevel, + bounds = config.bounds, + ) + } +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerImplementation.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerImplementation.kt new file mode 100644 index 000000000..d07760f8f --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerImplementation.kt @@ -0,0 +1,45 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +import MapCoreSharedModule.MCTiled2dMapVectorLayerInterface +import MapCoreSharedModule.MCVectorLayerFeatureInfoValue + +@OptIn(ExperimentalObjCName::class) +@ObjCName("MapVectorLayer", exact = true) +actual abstract class MapVectorLayer actual constructor(nativeHandle: Any?) { + protected val nativeHandle: Any? = nativeHandle + + actual abstract fun setSelectionDelegate(delegate: MapVectorLayerSelectionCallbackProxy?) + actual abstract fun setGlobalState(state: Map) +} + +class MapVectorLayerImpl(nativeHandle: Any?) : MapVectorLayer(nativeHandle) { + private val layer = nativeHandle as? MCTiled2dMapVectorLayerInterface + + override fun setSelectionDelegate(delegate: MapVectorLayerSelectionCallbackProxy?) { + layer?.setSelectionDelegate(delegate) + } + + override fun setGlobalState(state: Map) { + val mapped = mutableMapOf() + state.forEach { (key, value) -> + mapped[key] = value.asMapCore() + } + layer?.setGlobalState(mapped) + } + + internal fun layerInterface() = layer?.asLayerInterface() +} + +private fun MapVectorLayerFeatureInfoValue.asMapCore() = + MCVectorLayerFeatureInfoValue( + stringVal = stringVal, + doubleVal = null, + intVal = null, + boolVal = null, + colorVal = null, + listFloatVal = null, + listStringVal = listStringVal, + ) diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerSelectionCallbackProxy.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerSelectionCallbackProxy.kt new file mode 100644 index 000000000..575fbba02 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapVectorLayerSelectionCallbackProxy.kt @@ -0,0 +1,56 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +import MapCoreSharedModule.MCVectorLayerFeatureInfo +import MapCoreSharedModule.MCVectorLayerFeatureInfoValue +import MapCoreSharedModule.MCTiled2dMapVectorLayerSelectionCallbackInterfaceProtocol +import platform.darwin.NSObject + +@OptIn(ExperimentalObjCName::class) +@ObjCName("MapVectorLayerSelectionCallbackProxy", exact = true) +actual class MapVectorLayerSelectionCallbackProxy actual constructor( + handler: MapVectorLayerSelectionCallback, +) : NSObject(), MCTiled2dMapVectorLayerSelectionCallbackInterfaceProtocol { + actual val handler: MapVectorLayerSelectionCallback = handler + override fun didSelectFeature(featureInfo: MCVectorLayerFeatureInfo, layerIdentifier: String, coord: Coord): Boolean { + val sharedFeatureInfo = featureInfo.asShared(layerIdentifier) + return handler._didSelectFeature(featureInfo = sharedFeatureInfo, coord = coord) + } + + override fun didMultiSelectLayerFeatures( + featureInfos: List<*>, + layerIdentifier: String, + coord: Coord, + ): Boolean { + return handler._didMultiSelectLayerFeatures(layerIdentifier = layerIdentifier, coord = coord) + } + + override fun didClickBackgroundConfirmed(coord: Coord): Boolean { + return handler._didClickBackgroundConfirmed(coord = coord) + } +} + +private fun MCVectorLayerFeatureInfo.asShared(layerIdentifier: String): MapVectorLayerFeatureInfo { + val props = mutableMapOf() + for (entry in properties.entries) { + val key = entry.key as? String ?: continue + val value = entry.value as? MCVectorLayerFeatureInfoValue ?: continue + props[key] = value.asShared() + } + return MapVectorLayerFeatureInfo( + identifier = identifier, + layerIdentifier = layerIdentifier, + properties = props, + ) +} + +private fun MCVectorLayerFeatureInfoValue.asShared(): MapVectorLayerFeatureInfoValue { + val stringValue = stringVal + ?: intVal?.stringValue + ?: doubleVal?.stringValue + ?: boolVal?.stringValue + val list = listStringVal?.mapNotNull { it as? String } + return MapVectorLayerFeatureInfoValue(stringVal = stringValue, listStringVal = list) +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapViewWrapper.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapViewWrapper.kt new file mode 100644 index 000000000..a2b34c227 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/MapViewWrapper.kt @@ -0,0 +1,21 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +import MapCoreObjC.MCMapViewObjC +import MapCoreSharedModule.MCMapInterface +import platform.UIKit.UIView + +actual typealias PlatformMapView = UIView + +@OptIn(ExperimentalObjCName::class) +@ObjCName("MapViewWrapper", exact = true) +actual class MapViewWrapper actual constructor() { + private val mapView = MCMapViewObjC() + @Suppress("CAST_NEVER_SUCCEEDS") + private val mapInterfaceImplementation = MapInterface.create(mapView.mapInterface as MCMapInterface) + + actual val view: UIView = mapView + actual val mapInterface: MapInterface = mapInterfaceImplementation +} diff --git a/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/RectCoord.kt b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/RectCoord.kt new file mode 100644 index 000000000..5d87abc56 --- /dev/null +++ b/kmp/iosMain/kotlin/io/openmobilemaps/mapscore/kmp/core/feature/map/interop/RectCoord.kt @@ -0,0 +1,5 @@ +package io.openmobilemaps.mapscore.kmp.feature.map.interop + +import MapCoreSharedModule.MCRectCoord + +actual typealias RectCoord = MCRectCoord diff --git a/kmp/swift/MapCoreKmp/Bundle+Shared.swift b/kmp/swift/MapCoreKmp/Bundle+Shared.swift new file mode 100644 index 000000000..7c04bf7a7 --- /dev/null +++ b/kmp/swift/MapCoreKmp/Bundle+Shared.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Bundle { + static var shared: Bundle { + Bundle.module + } +} diff --git a/kmp/swift/MapCoreKmp/StartYourBridgeHere.swift b/kmp/swift/MapCoreKmp/StartYourBridgeHere.swift new file mode 100644 index 000000000..057ec9525 --- /dev/null +++ b/kmp/swift/MapCoreKmp/StartYourBridgeHere.swift @@ -0,0 +1,15 @@ +import Foundation +/** +This is a starting class to set up your bridge. +Ensure that your class is public and has the @objcMembers / @objc annotation. +This file has been created because the folder is empty. +Ignore this file if you don't need it. +**/ + +/** +@objcMembers public class StartHere: NSObject { + public override init() { + super.init() + } +} +**/ diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..f5330b064 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +rootProject.name = "maps-core" + +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +}