• Stars
    star
    951
  • Rank 46,360 (Top 1.0 %)
  • Language
    Kotlin
  • License
    Apache License 2.0
  • Created over 4 years ago
  • Updated 4 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Model-View-ViewModel architecture components for mobile (android & ios) Kotlin Multiplatform development

moko-mvvm
GitHub license Download kotlin-version badge badge badge badge badge badge badge badge badge

Mobile Kotlin Model-View-ViewModel architecture components

This is a Kotlin Multiplatform library that provides architecture components of Model-View-ViewModel for UI applications. Components are lifecycle-aware on Android.

Table of Contents

Features

  • ViewModel - store and manage UI-related data. Interop with Android Architecture Components - on Android it's precisely androidx.lifecycle.ViewModel;
  • LiveData, MutableLiveData, MediatorLiveData - lifecycle-aware reactive data holders with set of operators to transform, merge, etc.;
  • EventsDispatcher - dispatch events from ViewModel to View with automatic lifecycle control and explicit interface of required events;
  • DataBinding, ViewBinding, Jetpack Compose, SwiftUI support - integrate to Android & iOS app with commonly used tools;
  • All Kotlin targets support - core, flow and livedata modules support all Kotlin targets.

Requirements

  • Gradle version 6.8+
  • Android API 16+
  • iOS version 11.0+

Installation

root build.gradle

allprojects {
    repositories {
        mavenCentral()
    }
}

project build.gradle

dependencies {
    commonMainApi("dev.icerock.moko:mvvm-core:0.16.1") // only ViewModel, EventsDispatcher, Dispatchers.UI
    commonMainApi("dev.icerock.moko:mvvm-flow:0.16.1") // api mvvm-core, CFlow for native and binding extensions
    commonMainApi("dev.icerock.moko:mvvm-livedata:0.16.1") // api mvvm-core, LiveData and extensions
    commonMainApi("dev.icerock.moko:mvvm-state:0.16.1") // api mvvm-livedata, ResourceState class and extensions
    commonMainApi("dev.icerock.moko:mvvm-livedata-resources:0.16.1") // api mvvm-core, moko-resources, extensions for LiveData with moko-resources
    commonMainApi("dev.icerock.moko:mvvm-flow-resources:0.16.1") // api mvvm-core, moko-resources, extensions for Flow with moko-resources
    
    // compose multiplatform
    commonMainApi("dev.icerock.moko:mvvm-compose:0.16.1") // api mvvm-core, getViewModel for Compose Multiplatfrom
    commonMainApi("dev.icerock.moko:mvvm-flow-compose:0.16.1") // api mvvm-flow, binding extensions for Compose Multiplatfrom
    commonMainApi("dev.icerock.moko:mvvm-livedata-compose:0.16.1") // api mvvm-livedata, binding extensions for Compose Multiplatfrom

    androidMainApi("dev.icerock.moko:mvvm-livedata-material:0.16.1") // api mvvm-livedata, Material library android extensions
    androidMainApi("dev.icerock.moko:mvvm-livedata-glide:0.16.1") // api mvvm-livedata, Glide library android extensions
    androidMainApi("dev.icerock.moko:mvvm-livedata-swiperefresh:0.16.1") // api mvvm-livedata, SwipeRefreshLayout library android extensions
    androidMainApi("dev.icerock.moko:mvvm-databinding:0.16.1") // api mvvm-livedata, DataBinding support for Android
    androidMainApi("dev.icerock.moko:mvvm-viewbinding:0.16.1") // api mvvm-livedata, ViewBinding support for Android
    
    commonTestImplementation("dev.icerock.moko:mvvm-test:0.16.1") // test utilities
}

Also required export of dependency to iOS framework. For example:

kotlin {
    // export correct artifact to use all classes of library directly from Swift
    targets.withType(org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget::class.java).all {
        binaries.withType(org.jetbrains.kotlin.gradle.plugin.mpp.Framework::class.java).all {
            export("dev.icerock.moko:mvvm-core:0.16.1")
            export("dev.icerock.moko:mvvm-livedata:0.16.1")
            export("dev.icerock.moko:mvvm-livedata-resources:0.16.1")
            export("dev.icerock.moko:mvvm-state:0.16.1")
        }
    }
}

KSwift

For iOS we recommend use moko-kswift with extensions
generation enabled. All LiveData to UIView bindings is extensions for UI elements.

SwiftUI additions

To use MOKO MVVM with SwiftUI set name of your kotlin framework to MultiPlatformLibrary and add dependency to CocoaPods:

pod 'mokoMvvmFlowSwiftUI', :podspec => 'https://raw.githubusercontent.com/icerockdev/moko-mvvm/release/0.16.1/mokoMvvmFlowSwiftUI.podspec'

required export of mvvm-core and mvvm-flow.

Usage

Simple view model

Let’s say we need a screen with a button click counter. To implement it we should:

common

In commonMain we can create a ViewModel like:

class SimpleViewModel : ViewModel() {
    private val _counter: MutableLiveData<Int> = MutableLiveData(0)
    val counter: LiveData<String> = _counter.map { it.toString() }

    fun onCounterButtonPressed() {
        val current = _counter.value
        _counter.value = current + 1
    }
}

And after that integrate the ViewModel on platform the sides.

Android

SimpleActivity.kt:

class SimpleActivity : MvvmActivity<ActivitySimpleBinding, SimpleViewModel>() {
    override val layoutId: Int = R.layout.activity_simple
    override val viewModelVariableId: Int = BR.viewModel
    override val viewModelClass: Class<SimpleViewModel> = SimpleViewModel::class.java

    override fun viewModelFactory(): ViewModelProvider.Factory {
        return createViewModelFactory { SimpleViewModel() }
    }
}

MvvmActivity automatically loads a databinding layout, resolves ViewModel object and sets a databinding variable.
activity_simple.xml:

<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data>

        <variable
            name="viewModel"
            type="com.icerockdev.library.sample1.SimpleViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:padding="16dp">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@{viewModel.counter.ld}" />

        <Button
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:onClick="@{() -> viewModel.onCounterButtonPressed()}"
            android:text="Press me to count" />
    </LinearLayout>
</layout>

iOS

SimpleViewController.swift:

import MultiPlatformLibrary
import MultiPlatformLibraryMvvm

class SimpleViewController: UIViewController {
    @IBOutlet private var counterLabel: UILabel!
    
    private var viewModel: SimpleViewModel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        viewModel = SimpleViewModel()
        
        counterLabel.bindText(liveData: viewModel.counter)
    }
    
    @IBAction func onCounterButtonPressed() {
        viewModel.onCounterButtonPressed()
    }
    
    override func didMove(toParentViewController parent: UIViewController?) {
        if(parent == nil) { viewModel.onCleared() }
    }
}

bindText is an extension from the MultiPlatformLibraryMvvm CocoaPod.

ViewModel with send events to View

Let’s say we need a screen from which we should go to another screen by pressing a button. To implement it we should:

common

class EventsViewModel(
    val eventsDispatcher: EventsDispatcher<EventsListener>
) : ViewModel() {

    fun onButtonPressed() {
        eventsDispatcher.dispatchEvent { routeToMainPage() }
    }

    interface EventsListener {
        fun routeToMainPage()
    }
}

EventsDispatcher is a special class that automatically removes observers from lifecycle and buffers input events while listener is not attached (on the Android side).

Android

EventsActivity.kt:

class EventsActivity : MvvmActivity<ActivityEventsBinding, EventsViewModel>(),
    EventsViewModel.EventsListener {
    override val layoutId: Int = R.layout.activity_events
    override val viewModelVariableId: Int = BR.viewModel
    override val viewModelClass: Class<EventsViewModel> = EventsViewModel::class.java

    override fun viewModelFactory(): ViewModelProvider.Factory {
        return createViewModelFactory { EventsViewModel(eventsDispatcherOnMain()) }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel.eventsDispatcher.bind(
            lifecycleOwner = this,
            listener = this
        )
    }

    override fun routeToMainPage() {
        Toast.makeText(this, "here must be routing to main page", Toast.LENGTH_SHORT).show()
    }
}

eventsDispatcher.bind attaches EventsDispatcher to the lifecycle (in this case - to an activity) to correctly subscribe and unsubscribe, without memory leaks.

We can also simplify the binding of EventsDispatcher with MvvmEventsActivity and EventsDispatcherOwnder. EventsOwnerViewModel.kt:

class EventsOwnerViewModel(
    override val eventsDispatcher: EventsDispatcher<EventsListener>
) : ViewModel(), EventsDispatcherOwner<EventsOwnerViewModel.EventsListener> {

    fun onButtonPressed() {
        eventsDispatcher.dispatchEvent { routeToMainPage() }
    }

    interface EventsListener {
        fun routeToMainPage()
    }
}

EventsOwnderActivity.kt:

class EventsOwnerActivity :
    MvvmEventsActivity<ActivityEventsOwnerBinding, EventsOwnerViewModel, EventsOwnerViewModel.EventsListener>(),
    EventsOwnerViewModel.EventsListener {

    override val layoutId: Int = R.layout.activity_events_owner
    override val viewModelVariableId: Int = BR.viewModel
    override val viewModelClass: Class<EventsOwnerViewModel> = EventsOwnerViewModel::class.java

    override fun viewModelFactory(): ViewModelProvider.Factory {
        return createViewModelFactory { EventsOwnerViewModel(eventsDispatcherOnMain()) }
    }

    override fun routeToMainPage() {
        Toast.makeText(this, "here must be routing to main page", Toast.LENGTH_SHORT).show()
    }
}

iOS

EventsViewController.swift:

import MultiPlatformLibrary
import MultiPlatformLibraryMvvm

class EventsViewController: UIViewController {
    private var viewModel: EventsViewModel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let eventsDispatcher = EventsDispatcher<EventsViewModelEventsListener>(listener: self)
        viewModel = EventsViewModel(eventsDispatcher: eventsDispatcher)
    }
    
    @IBAction func onButtonPressed() {
        viewModel.onButtonPressed()
    }
    
    override func didMove(toParentViewController parent: UIViewController?) {
        if(parent == nil) { viewModel.onCleared() }
    }
}

extension EventsViewController: EventsViewModelEventsListener {
    func routeToMainPage() {
        showAlert(text: "go to main page")
    }
}

On iOS we create an instance of EventsDispatcher with the link to the listener. We shouldn't call bind like on Android (in iOS this method doesn't exist).

ViewModel with validation of user input

class ValidationMergeViewModel() : ViewModel() {
    val email: MutableLiveData<String> = MutableLiveData("")
    val password: MutableLiveData<String> = MutableLiveData("")

    val isLoginButtonEnabled: LiveData<Boolean> = email.mergeWith(password) { email, password ->
        email.isNotEmpty() && password.isNotEmpty()
    }
}

isLoginButtonEnabled is observable email & password LiveData, and in case there are any changes it calls lambda with the newly calculated value.

We can also use one of these combinations:

class ValidationAllViewModel() : ViewModel() {
    val email: MutableLiveData<String> = MutableLiveData("")
    val password: MutableLiveData<String> = MutableLiveData("")

    private val isEmailValid: LiveData<Boolean> = email.map { it.isNotEmpty() }
    private val isPasswordValid: LiveData<Boolean> = password.map { it.isNotEmpty() }
    val isLoginButtonEnabled: LiveData<Boolean> = listOf(isEmailValid, isPasswordValid).all(true)
}

Here we have separated LiveData with the validation flags - isEmailValid, isPasswordValid and combine both to isLoginButtonEnabled by merging all boolean LiveData in the list with on the condition that "all values must be true".

ViewModel for login feature

common

class LoginViewModel(
    override val eventsDispatcher: EventsDispatcher<EventsListener>,
    private val userRepository: UserRepository
) : ViewModel(), EventsDispatcherOwner<LoginViewModel.EventsListener> {
    val email: MutableLiveData<String> = MutableLiveData("")
    val password: MutableLiveData<String> = MutableLiveData("")

    private val _isLoading: MutableLiveData<Boolean> = MutableLiveData(false)
    val isLoading: LiveData<Boolean> = _isLoading.readOnly()

    val isLoginButtonVisible: LiveData<Boolean> = isLoading.not()

    fun onLoginButtonPressed() {
        val emailValue = email.value
        val passwordValue = password.value

        viewModelScope.launch {
            _isLoading.value = true

            try {
                userRepository.login(email = emailValue, password = passwordValue)

                eventsDispatcher.dispatchEvent { routeToMainScreen() }
            } catch (error: Throwable) {
                val message = error.message ?: error.toString()
                val errorDesc = message.desc()

                eventsDispatcher.dispatchEvent { showError(errorDesc) }
            } finally {
                _isLoading.value = false
            }
        }
    }

    interface EventsListener {
        fun routeToMainScreen()
        fun showError(error: StringDesc)
    }
}

viewModelScope is a CoroutineScope field of the ViewModel class with a default Dispatcher - UI on both platforms. All coroutines will be canceled in onCleared automatically.

Android

LoginActivity.kt:

class LoginActivity :
    MvvmEventsActivity<ActivityLoginBinding, LoginViewModel, LoginViewModel.EventsListener>(),
    LoginViewModel.EventsListener {

    override val layoutId: Int = R.layout.activity_login
    override val viewModelVariableId: Int = BR.viewModel
    override val viewModelClass: Class<LoginViewModel> =
        LoginViewModel::class.java

    override fun viewModelFactory(): ViewModelProvider.Factory {
        return createViewModelFactory {
            LoginViewModel(
                userRepository = MockUserRepository(),
                eventsDispatcher = eventsDispatcherOnMain()
            )
        }
    }

    override fun routeToMainScreen() {
        Toast.makeText(this, "route to main page here", Toast.LENGTH_SHORT).show()
    }

    override fun showError(error: StringDesc) {
        Toast.makeText(this, error.toString(context = this), Toast.LENGTH_SHORT).show()
    }
}

activity_login.xml:

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="viewModel"
            type="com.icerockdev.library.sample6.LoginViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:padding="16dp">

        <EditText
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="email"
            android:text="@={viewModel.email.ld}" />

        <EditText
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:hint="password"
            android:text="@={viewModel.password.ld}" />

        <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:onClick="@{() -> viewModel.onLoginButtonPressed()}"
                android:text="Login"
                app:visibleOrGone="@{viewModel.isLoginButtonVisible.ld}" />

            <ProgressBar
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                app:visibleOrGone="@{viewModel.isLoading.ld}" />
        </FrameLayout>
    </LinearLayout>
</layout>

iOS

LoginViewController.swift:

class LoginViewController: UIViewController {
    @IBOutlet private var emailField: UITextField!
    @IBOutlet private var passwordField: UITextField!
    @IBOutlet private var loginButton: UIButton!
    @IBOutlet private var progressBar: UIActivityIndicatorView!
    
    private var viewModel: LoginViewModel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let eventsDispatcher = EventsDispatcher<LoginViewModelEventsListener>(listener: self)
        viewModel = LoginViewModel(eventsDispatcher: eventsDispatcher,
                                   userRepository: MockUserRepository())
        
        emailField.bindTextTwoWay(liveData: viewModel.email)
        passwordField.bindTextTwoWay(liveData: viewModel.password)
        loginButton.bindVisibility(liveData: viewModel.isLoginButtonVisible)
        progressBar.bindVisibility(liveData: viewModel.isLoading)
    }
    
    @IBAction func onLoginButtonPressed() {
        viewModel.onLoginButtonPressed()
    }
    
    override func didMove(toParentViewController parent: UIViewController?) {
        if(parent == nil) { viewModel.onCleared() }
    }
}

extension LoginViewController: LoginViewModelEventsListener {
    func routeToMainScreen() {
        showAlert(text: "route to main screen")
    }
    
    func showError(error: StringDesc) {
        showAlert(text: error.localized())
    }
}

Samples

Please see more examples in the sample directory.

Set Up Locally

Contributing

All development (both new features and bug fixes) is performed in the develop branch. This way master always contains the sources of the most recently released version. Please send PRs with bug fixes to the develop branch. Documentation fixes in the markdown files are an exception to this rule. They are updated directly in master.

The develop branch is pushed to master on release.

For more details on contributing please see the contributing guide.

We’re hiring a Mobile Developers for our main team in Novosibirsk and remote team with Moscow timezone!

If you like to develop mobile applications, are an expert in iOS/Swift or Android/Kotlin and eager to use Kotlin Multiplatform in production, we'd like to talk to you.

To learn more and apply

License

Copyright 2019 IceRock MAG Inc.

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

   http://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.

More Repositories

1

moko-resources

Resources access for mobile (android & ios) Kotlin Multiplatform development
Kotlin
945
star
2

moko-template

Template project of a Mobile (Android & iOS) Kotlin MultiPlatform project with the MOKO libraries and modularized architecture
Kotlin
452
star
3

moko-widgets

Multiplatform UI DSL with screen management in common code for mobile (android & ios) Kotlin Multiplatform development
Kotlin
363
star
4

moko-kswift

Swift-friendly api generator for Kotlin/Native frameworks
Kotlin
335
star
5

moko-permissions

Runtime permissions controls for mobile (android & ios) Kotlin Multiplatform development
Kotlin
249
star
6

libs.kmp.icerock.dev

Kotlin Multiplatform libraries list with info auto-fetch
JavaScript
229
star
7

moko-network

Network components with codegeneration of rest api for mobile (android & ios) Kotlin Multiplatform development
Kotlin
147
star
8

moko-geo

Geolocation access for mobile (android & ios) Kotlin Multiplatform development
Kotlin
113
star
9

moko-socket-io

MOKO SocketIo by IceRock is Socket.IO implementation Kotlin Multiplatform library
Kotlin
93
star
10

mobile-multiplatform-gradle-plugin

Gradle plugin for simplify Kotlin Multiplatform mobile configurations
Kotlin
92
star
11

moko-maps

Control your map from common code for mobile (android & ios) Kotlin Multiplatform development
Kotlin
71
star
12

moko-media

Media selection & presenting for mobile (android & ios) Kotlin Multiplatform development
Kotlin
66
star
13

moko-parcelize

@Parcelize support for mobile (android & ios) Kotlin Multiplatform development
Kotlin
60
star
14

moko-paging

Pagination logic in common code for mobile (android & ios) Kotlin Multiplatform development
Kotlin
57
star
15

moko-errors

Automated exceptions handler for mobile (android & ios) Kotlin Multiplatform development.
Kotlin
52
star
16

moko-web3

Ethereum Web3 implementation for mobile (android & ios) Kotlin Multiplatform development
Kotlin
41
star
17

moko-biometry

Biometry authentication with Touch ID, Face ID from common code with Kotlin Multiplatform Mobile
Kotlin
34
star
18

moko-crash-reporting

Fatal and Non-Fatal reporting to Crashlytics for Kotlin Multiplatform Mobile
Kotlin
26
star
19

moko-units

Composing units into list and show in RecyclerView/UITableView/UICollectionView. Control your lists from common code for mobile (android & ios) Kotlin Multiplatform development
Kotlin
25
star
20

kmm.icerock.dev

JavaScript
22
star
21

redwood-sample

Kotlin
20
star
22

moko-fields

Input forms for mobile (android & ios) Kotlin Multiplatform development
Kotlin
19
star
23

moko-graphics

Graphics primitives for mobile (android & ios) Kotlin Multiplatform development
Kotlin
16
star
24

moko-javascript

JavaScript evaluation from kotlin common code for android & iOS
Kotlin
14
star
25

moko-tensorflow

Tensorflow Lite bindings for mobile (android & ios) Kotlin Multiplatform development
Kotlin
14
star
26

moko-core

Core classes for mobile (android & ios) Kotlin Multiplatform development
Kotlin
13
star
27

shaper

File structure generation tool from templates on Handlebars
Kotlin
12
star
28

moko-test

Test utilities for mobile (android & ios) Kotlin Multiplatform development
Kotlin
11
star
29

moko-widgets-template

Template project of a Mobile (Android & iOS) Kotlin MultiPlatform project with the MOKO widgets and other MOKO libs
Kotlin
10
star
30

kotlin-version-badge

badge with kotlin version, automatically fetched from mavenCentral package of latest version
Kotlin
10
star
31

moko

MOKO libraries umbrella project
Kotlin
9
star
32

gpuimage-filters-editor

iPad application photo editor with filters by GPUImage
Objective-C
9
star
33

codelabs.kmp.icerock.dev

Education of Kotlin Multiplatform Mobile
JavaScript
8
star
34

mobile-multiplatform-education

Internal lessons of mobile multiplatform development
Kotlin
8
star
35

email-service

Email sending service for kotlin - part of BOKO libs
Kotlin
7
star
36

moko.icerock.dev

Home page of moko libraries
HTML
7
star
37

moko-doctor

doctor script for checking the KMM developer's environment
Shell
6
star
38

admin-toolkit

Toolkit to create admin panels - part of BOKO libs
TypeScript
6
star
39

binance-futures-kotlin-api

Kotlin
6
star
40

sms-service

Sms sending service for kotlin - part of BOKO libs
Kotlin
5
star
41

storage-service

Tools for work with s3 storage and generate preview - part of BOKO libs
Kotlin
5
star
42

fcm-push-service

Tools for sending fcm push by kotlin - part of BOKO libs
Kotlin
5
star
43

yellowdoor-app

Kotlin
4
star
44

prototyping.icerock.dev

JavaScript
4
star
45

moko-time

Timestamp and timers for mobile (android & ios) Kotlin Multiplatform development
Kotlin
4
star
46

moko-gradle-plugin

Kotlin
3
star
47

moko-utils

Utilities for mobile (android & ios) Kotlin Multiplatform development
Kotlin
3
star
48

db-utils

Common tools for work with postgres via exposed - part of BOKO libs
Kotlin
3
star
49

web-utils

Common tools for create web application (ktor based) - part of BOKO libs
Kotlin
3
star
50

tinkoff-merchant-api

Tinkoff Merchant API Kotlin Client - part of BOKO libs
Kotlin
3
star
51

moko-intellij-plugin

Kotlin
3
star
52

slackbot-gitlab-ci

GitLab CI slackbot (beepboophq.com version)
JavaScript
2
star
53

auth-service

Common tools for JWT auth - part of BOKO libs
Kotlin
2
star
54

icerock-talaiot-config-plugin

Kotlin
2
star
55

PopupBottomNavigation

Popup menu with additional tabs for UITabBarController
Swift
2
star
56

IRPDFKit

PDF viewer with search for iOS
JavaScript
2
star
57

kafka-service

Common tools for kafka - part of BOKO libs
Kotlin
2
star
58

boko-validation

Konform based validation for Kotlin/JVM - part of BOKO libs
Kotlin
2
star
59

delivery.icerock.dev

Handlebars
1
star
60

shaper-templates

Templates for Shaper generation tool
Handlebars
1
star
61

kmp.icerock.dev

Kotlin Mobile MultiPlatform materials landing
HTML
1
star