$30 off During Our Annual Pro Sale. View Details »

The rollercoaster of releasing an Android, iOS,...

The rollercoaster of releasing an Android, iOS, and macOS app with Kotlin Multiplatform | droidcon Italy

With the rise of Kotlin Multiplatform, the possibility of expanding to multiple platforms has increased, especially for Android Developers. It's easier than before to build for other platforms.

But how to release your app to multiple platforms?

In this talk, I will share all the things I've learned around distributing FeedFlow, an Android, iOS, and macOS app built with Kotlin Multiplatform, coming from an Android development background.

We will cover the deployment of the binary, automating everything with CI, crash reporting, logging, internationalization, and all you need to know to successfully distribute your KMP app.

Marco Gomiero

November 30, 2024
Tweet

More Decks by Marco Gomiero

Other Decks in Programming

Transcript

  1. Marco Gomiero @marcogomiero.com Senior Android Developer @ Airalo Google Developer

    Expert for Kotlin The rollercoaster of releasing an Android, iOS, and macOS app with Kotlin Multiplatform
  2. @marcogomiero.com • Sign the app • Provisioning profiles • Upload

    to TestFlight • All handled by Xcode (or manually on the CI) iOS
  3. @marcogomiero.com Notarization $ xcrun notarytool submit $BINARY_PATH --apple-id $APPLE_ID_NOTARIZATION --password

    $NOTARIZATION_PWD --team-id $APPSTORE_TEAM_ID --wait $ xcrun stapler staple $BINARY_PATH
  4. @marcogomiero.com macOS - App Store • Sign the app •

    Provisioning profiles • Upload to TestFlight
  5. @marcogomiero.com Native libraries on JVM • Native libraries are embedded

    in the dependencies jar • When needed, they are extracted and loaded
  6. @marcogomiero.com Native libraries on JVM • Cannot load “random” native

    library • Signing and embedding in the binary is the way
  7. @marcogomiero.com val isSandboxed = System.getenv("APP_SANDBOX_CONTAINER_ID") != null if (isSandboxed) {

    val resourcesPath = System.getProperty("compose.application.resources.dir") // sqlite-jdbc System.setProperty("org.sqlite.lib.path", resourcesPath) } Load native libraries
  8. @marcogomiero.com val isSandboxed = System.getenv("APP_SANDBOX_CONTAINER_ID") != null if (isSandboxed) {

    val resourcesPath = System.getProperty("compose.application.resources.dir") // sqlite-jdbc System.setProperty("org.sqlite.lib.path", resourcesPath) } Load native libraries
  9. @marcogomiero.com val isSandboxed = System.getenv("APP_SANDBOX_CONTAINER_ID") != null if (isSandboxed) {

    val resourcesPath = System.getProperty("compose.application.resources.dir") // sqlite-jdbc System.setProperty("org.sqlite.lib.path", resourcesPath) } Load native libraries
  10. @marcogomiero.com val logger = Logger( config = StaticConfig( logWriterList =

    listOf( platformLogWriter(), crashReportingLogWriter(), ), ), tag = "FeedFlow", )
  11. @marcogomiero.com val logger = Logger( config = StaticConfig( logWriterList =

    listOf( platformLogWriter(), crashReportingLogWriter(), ), ), tag = "FeedFlow", )
  12. @marcogomiero.com val logger = Logger( config = StaticConfig( logWriterList =

    listOf( platformLogWriter(), crashReportingLogWriter(), ), ), tag = "FeedFlow", )
  13. @marcogomiero.com internal expect fun crashReportingLogWriter(): LogWriter actual fun crashReportingLogWriter(): LogWriter

    = CrashlyticsLogWriter() actual fun crashReportingLogWriter(): LogWriter = SentryLogWriter()
  14. @marcogomiero.com class SentryLogWriter : LogWriter() { override fun isLoggable(tag: String,

    severity: Severity): Boolean { // .. } override fun log( severity: Severity, message: String, tag: String, throwable: Throwable? ) { // .. } }
  15. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) FeedFlowTheme { val lyricist = rememberFeedFlowStrings() ProvideFeedFlowStrings(lyricist) { MyAppEntrypoint() } }
  16. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) FeedFlowTheme { val lyricist = rememberFeedFlowStrings() ProvideFeedFlowStrings(lyricist) { MyAppEntrypoint() } } Text(text = LocalFeedFlowStrings.current.feedUrl)
  17. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) func startKoin() { let languageCode = Locale.current.language.languageCode?.identifier let regionCode = Locale.current.region?.identifier let koinApplication = doInitKoinIos( languageCode: languageCode, regionCode: regionCode ) _feedFlowStrings = KotlinDependencies.shared.getFeedFlowStrings() } private var _feedFlowStrings: FeedFlowStrings? var feedFlowStrings: FeedFlowStrings { return _feedFlowStrings! }
  18. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) func startKoin() { let languageCode = Locale.current.language.languageCode?.identifier let regionCode = Locale.current.region?.identifier let koinApplication = doInitKoinIos( languageCode: languageCode, regionCode: regionCode ) _feedFlowStrings = KotlinDependencies.shared.getFeedFlowStrings() } private var _feedFlowStrings: FeedFlowStrings? var feedFlowStrings: FeedFlowStrings { return _feedFlowStrings! }
  19. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) func startKoin() { let languageCode = Locale.current.language.languageCode?.identifier let regionCode = Locale.current.region?.identifier let koinApplication = doInitKoinIos( languageCode: languageCode, single<FeedFlowStrings> { when { languageCode == null -> EnFeedFlowStrings regionCode == null -> feedFlowStrings[languageCode] ?: EnFeedFlowStrings else -> { val locale = "${languageCode}_$regionCode" feedFlowStrings[locale] ?: feedFlowStrings[languageCode] ?: EnFeedFlowStrings } } }
  20. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) func startKoin() { let languageCode = Locale.current.language.languageCode?.identifier let regionCode = Locale.current.region?.identifier let koinApplication = doInitKoinIos( languageCode: languageCode, single<FeedFlowStrings> { when { languageCode == null -> EnFeedFlowStrings regionCode == null -> feedFlowStrings[languageCode] ?: EnFeedFlowStrings else -> { val locale = "${languageCode}_$regionCode" feedFlowStrings[locale] ?: feedFlowStrings[languageCode] ?: EnFeedFlowStrings } } }
  21. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) func startKoin() { let languageCode = Locale.current.language.languageCode?.identifier let regionCode = Locale.current.region?.identifier let koinApplication = doInitKoinIos( languageCode: languageCode, single<FeedFlowStrings> { when { languageCode == null -> EnFeedFlowStrings regionCode == null -> feedFlowStrings[languageCode] ?: EnFeedFlowStrings else -> { val locale = "${languageCode}_$regionCode" feedFlowStrings[locale] ?: feedFlowStrings[languageCode] ?: EnFeedFlowStrings } } }
  22. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) func startKoin() { let languageCode = Locale.current.language.languageCode?.identifier let regionCode = Locale.current.region?.identifier let koinApplication = doInitKoinIos( languageCode: languageCode, single<FeedFlowStrings> { when { languageCode == null -> EnFeedFlowStrings regionCode == null -> feedFlowStrings[languageCode] ?: EnFeedFlowStrings else -> { val locale = "${languageCode}_$regionCode" feedFlowStrings[locale] ?: feedFlowStrings[languageCode] ?: EnFeedFlowStrings } } }
  23. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) func startKoin() { let languageCode = Locale.current.language.languageCode?.identifier let regionCode = Locale.current.region?.identifier let koinApplication = doInitKoinIos( languageCode: languageCode, regionCode: regionCode ) _feedFlowStrings = KotlinDependencies.shared.getFeedFlowStrings() } private var _feedFlowStrings: FeedFlowStrings? var feedFlowStrings: FeedFlowStrings { return _feedFlowStrings! }
  24. @marcogomiero.com val feedFlowStrings: Map<String, FeedFlowStrings> = mapOf( Locales.It to ItFeedFlowStrings,

    Locales.En to EnFeedFlowStrings, Locales.De to DeFeedFlowStrings, ) Text(feedFlowStrings.searchHintTitle) func startKoin() { let languageCode = Locale.current.language.languageCode?.identifier let regionCode = Locale.current.region?.identifier let koinApplication = doInitKoinIos( languageCode: languageCode,
  25. @marcogomiero.com Conclusions • Start from things you already know •

    Go incrementally • Exploring other planets enriches your vision
  26. @marcogomiero.com Thank you! Marco Gomiero Senior Android Developer @ Airalo

    Google Developer Expert for Kotlin > Twitter: @marcoGomier > Github: prof18 > Website: marcogomiero.com > Mastodon: androiddev.social/@marcogom > BlueSky: @marcogomiero.com
  27. Bibliography / Useful Links • https: / / feedflow.dev/ •

    https: / / developer.apple.com/documentation/security/notarizing_macos_software_before_distribution • https: / / help.apple.com/itc/transporteruserguide/en.lproj/static.html#apd70774093eddb4 • https: / / developer.apple.com/documentation/security/app_sandbox/protecting_user_data_with_app_sandbox • https: / / github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Native_distributions_and_local_execution/README.md#adding-files-to- packaged-application • https: / / slack-chats.kotlinlang.org/t/16371916/hey-there-wave-i-m-trying-to-publish-my-app-on-the-mac-app-s#0be57312-7e43-4d30- add9-1cd5a0b7421b • https: / / firebase.google.com/products/crashlytics • https: / / docs.sentry.io/platforms/java/ • https: / / github.com/touchlab/CrashKiOS • https: / / github.com/touchlab/Kermit • https: / / github.com/adrielcafe/lyricist • https: / / www.jetbrains.com/help/kotlin-multiplatform-dev/compose-images-resources.html#strings • https: / / www.marcogomiero.com/posts/2024/kmp-ci-android • https: / / www.marcogomiero.com/posts/2024/kmp-ci-ios • https: / / www.marcogomiero.com/posts/2024/kmp-ci-macos-github-releases • https: / / www.marcogomiero.com/posts/2024/kmp-ci-macos-appstore • https: / / www.marcogomiero.com/posts/2024/compose-macos-app-store/