Skip to content

Latest commit

 

History

History
480 lines (346 loc) · 19.3 KB

README.md

File metadata and controls

480 lines (346 loc) · 19.3 KB
readme-banner

DroidKaigi 2023 official app

DroidKaigi 2023 will be held from September 14 to September 16, 2023. We are developing its application. Let's develop the app together and make it exciting.

Features

This is a video of an app in development, and it will be updated as needed.

dark2.webm

Try it out!

Android

Development version

You can test the development app through DeployGate.

Try it on your device via DeployGate

iOS

Beta version of this app is available on TestFlight. You can try and test this app with following public link.

https://testflight.apple.com/join/1dxiwjX0

In this project, distribute beta version with Xcode Cloud.

Contributing

We always welcome any and all contributions! See CONTRIBUTING.md for more information.

For Japanese speakers, please see CONTRIBUTING.ja.md.

Requirements

Stable Android Studio Giraffe or higher. You can download it from this page.

Design

You can check out the design on Figma.

https://www.figma.com/file/MbElhCEnjqnuodmvwabh9K/DroidKaigi-2023-App-UI

Design thumbnail

Architecture

Overview of the architecture

In addition to general Android practices, we are exploring and implementing various concepts. Details for each are discussed further in this README.

architecture diagram

Module structure

We are adopting the module separation approach used in Now in Android, such as splitting into 'feature' and 'core' modules. We've added experimental support for Compose Multiplatform on certain screens, making the features accessible from the iOS app module as well."

image

UI

Composable Function Categorization

Composable functions are categorized into three types: Screen, Section, and Component. This categorization does not have a definitive rule, but it serves as a guide for better structure and improved readability.

image
sessions
├── TimetableScreen.kt
│    ├── TimetableScreenUiState
│    └── TimetableScreen
├── TimetableScreenViewModel.kt
├── component
│   └── TimetableListItem.kt
└── section
    ├── TimetableContent.kt
    │   ├── TimetableContentUiState
    │   └── TimetableContent
    └── TimetableList.kt
        ├── TimetableListUiState
        └── TimetableList

Dependency rule

The basic dependency rule is as follows:

Screen -> Section -> Component

For example, TimetableScreen depends on TimetableContent and TimetableListItem. Also, a Section can depend on other Sections, and components can depend on other components.

Screen

Screen refers to an entire screen within your application. Both Screen and Section are managed with UiState to handle their individual states, which are created by the ViewModel. Typically, each ViewModel is directly linked with a single Screen.

data class TimetableScreenUiState(
    val contentUiState: TimetableContentUiState,
    val isFavoriteFilterChecked: Boolean,
)

@Composable
private fun TimetableScreen(
    uiState: TimetableScreenUiState,
    ...
) {
...

Section

Section refers to groups of components within screens, like containers including lists, which can dynamically adjust in size or complexity as the needs of the application change. An example could be a TimetableList. Both Screen and Section are managed with UiState to handle their individual states, which are created by the ViewModel.

data class TimetableListUiState(
    val timetableItemMap: PersistentMap<String, List<TimetableItem>>,
    val timetable: Timetable,
)

@Composable
fun TimetableList(
    uiState: TimetableListUiState,
    onBookmarkClick: (TimetableItem) -> Unit,
    onTimetableItemClick: (TimetableItem) -> Unit,
    modifier: Modifier = Modifier,
) {
...

Component

'Component' refers to the finer units of UI, designed to serve specific roles within the application. While they may not be as dynamic as Sections, they can still vary in their content or appearance to fit the specific needs of the app. Examples include TimetableListItem and TimeText.

Through clear delineation of roles and responsibilities of different composables, this classification assists in enhancing code organization and maintainability.

A Component should not have its own UiState as it could make things overly complicated.

Advanced Multilanguage System with Kotlin Multiplatform

Our application leverages Kotlin Multiplatform to create a flexible and type-safe system for handling multiple languages. This system exhibits the following key characteristics:

  • Language separation: Each language is managed separately within its distinct mapping structure, providing a clean and well-structured layout.

  • Type-safe handling of strings: We leverage Kotlin's sealed classes and enums to represent strings, which are validated at compile-time.

  • Type-safe arguments: The system allows adding arguments to strings in a type-safe manner, supporting dynamic data inclusion within strings like data class Time(val hours: Int, val minutes: Int)

  • Module-specific management: The system allows managing translations on a per-module basis, enhancing modularity and ease of maintenance.

  • Gradual translation support: Translations can be added gradually, which is beneficial for evolving projects where translations are continuously updated.

  • Assurance of translation completion: Kotlin's when helps detect missing translations, ensuring completeness of all language representations.

Code Example:

sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
    object Timetable : SessionsStrings()
    object Hoge : SessionsStrings()
    data class Time(val hours: Int, val minutes: Int) : SessionsStrings()
    
    private object Bindings : StringsBindings<SessionsStrings>(
        Lang.Japanese to { item, _ ->
            when (item) {
                Timetable -> "タイムテーブル"
                Hoge -> "ホゲ"
                is Time -> "${item.hour}${item.minutes}"
            }
        },
        Lang.English to { item, bindings ->
            when (item) {
                Timetable -> "Timetable"
                // You can use defaultBinding to use default language's string
                Hoge -> bindings.defaultBinding(item, bindings)
                is Time -> "${item.hour}:${item.minutes}"
            }
        },
        default = Lang.Japanese
    )
}

In the above example, SessionsStrings is a sealed class that represents different strings. Each string is defined as an object within the sealed class, and the translations are provided in StringsBindings.

To fetch a string:

println(SessionsStrings.Timetable.asString())

Single Source of Truth with buildUiState() {}

image

The buildUiState() {} function promotes the Single Source of Truth (SSoT) principle in our application by combining multiple StateFlow objects into a single UI state. This ensures that data is managed and accessed from a single, consistent, and reliable source.

By working with StateFlow objects, the function can also compute initial values, further enhancing the SSOT principle.

Here's an example of using the buildUiState() function:

private val timetableContentUiState: StateFlow<TimetableContentUiState> = buildUiState(
    sessionsStateFlow,
    filtersStateFlow,
) { sessionTimetable, filters ->
    if (sessionTimetable.timetableItems.isEmpty()) {
        return@buildUiState TimetableContentUiState.Empty
    }
    TimetableContentUiState.ListTimetable(
        TimetableListUiState(
            timetable = sessionTimetable.filtered(filters),
        ),
    )
}

The buildUiState() function combines the data from sessionsStateFlow and filtersStateFlow into a single timetableContentUiState instance. This simplifies state management and ensures that the UI always displays consistent and up-to-date information.

Build / CI

This project runs on GitHub Actions. This year's workflows contain new challenges!

Provide the same CI experiences for both members and contributors(you!)

This project is an OSS so we cannot assign write-able tokens to workflow-runs that need the codes of the forked repos. To solve this problem, this project shares artifacts with multiple workflows via artifacts API and use them in safe workflows that have more-powerful permission but consist of safe actions.

This achieves to post comments on forked PRs safely. For example, you can see the results of the visual testing reports even on your PRs! (See Architecture > Testing for the visual testing).

Testing

Testing an app involves balancing fidelity, how closely the test resembles actual use, and reliability, the consistency of test results. This year, our goal is to improve both using several methods.

Overview Diagram

image

Detailed Diagram

image

Screenshot Testing with Robolectric Native Graphics (RNG) and Roborazzi

Robolectric Native Graphics (RNG) allows us to take app screenshots without needing an emulator or a device. This approach is faster and more reliable than taking device screenshots. While device screenshots may replicate real-world usage slightly more accurately, we believe the benefits of RNG's speed and reliability outweigh this. We use Roborazzi to compare the current app's screenshots to the old ones, allowing us to spot and fix any visual changes.

What to test: Balancing Screenshot Tests and Assertion Tests

Screenshot tests are extremely effective as they allow us to spot visual changes without writing many assertions. However, there is a risk of mistakenly using incorrect baseline images.
So, for important features, we should add assertion tests to these parts. The tests will typically look like this:

@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@HiltAndroidTest
@Config(
    qualifiers = RobolectricDeviceQualifiers.NexusOne
)
class TimetableScreenTest {
    @get:Rule
    @BindValue val robotTestRule: RobotTestRule = RobotTestRule<MainActivity>(this)

    @Inject lateinit var timetableScreenRobot: TimetableScreenRobot

    // A screenshot test
    @Test
    @Category(ScreenshotTests::class)
    fun checkLaunchShot() {
        timetableScreenRobot {
            setupTimetableScreenContent()
            checkScreenCapture()
        }
    }

    // An assertion test for an important feature
    @Test
    fun checkLaunch() {
        timetableScreenRobot {
            setupTimetableScreenContent()
            checkTimetableItemsDisplayed()
        }
    }
    ...
}

The Companion Branch Approach

We use the companion branch approach to store screenshots of feature branches. This method involves saving screenshots to a companion branch whenever a pull request is made, ensuring that we keep only relevant images and reduce the repository size.

  • Why not GitHub Actions Artifacts, Git LFS, or Feature Branch Commits?

While GitHub Actions Artifacts and Git LFS could be used for storing screenshots, they don't allow for direct image viewing in pull requests. Committing screenshots directly to the feature branch, on the other hand, can lead to an unnecessary increase in the repository size.

Testing Robot Pattern

The Testing Robot Pattern simplifies writing UI tests. It splits the test code into two main parts: the 'how to test' portion, handled by the robot class, and the 'what to test' portion, managed by the test class. This separation provides benefits when writing screenshot tests, making the test code more maintainable and easier to understand.

Testing Section: 'What to Test'

File: TimetableScreenTest.kt

    @Test
    @Category(ScreenshotTests::class)
    fun checkScrollShot() {
        timetableScreenRobot {
            // Define what functionalities of the screen to test
            setupTimetableScreenContent() // Setup the screen with the content
            scrollTimetable()             // Perform a scrolling action
            checkTimetableListCapture()   // Validate the visual state by capturing a screenshot
        }
    }

Robot Section: 'How to Test'

File: TimetableScreenRobot.kt

    // Sets up the content for the Timetable screen
    fun setupTimetableScreenContent() {
        composeTestRule.setContent {
            KaigiTheme {
                TimetableScreen(
                    onSearchClick = { },
                    onTimetableItemClick = { },
                    onBookmarkIconClick = { },
                )
            }
        }
        waitUntilIdle()
    }

    // Performs a scrolling action on the Timetable screen
    fun scrollTimetable() {
        composeTestRule
            .onNode(hasTestTag(TimetableScreenTestTag))
            .performTouchInput {
                swipeUp(
                    startY = visibleSize.height * 3F / 4,
                    endY = visibleSize.height / 2F,
                )
            }
    }

    // Validates the Timetable screen by capturing a screenshot
    fun checkTimetableListCapture() {
        composeTestRule
            .onNode(hasTestTag(TimetableScreenTestTag))
            .captureRoboImage()
    }

And now, you can check the scrolled screenshot!

TimetableScreenTest checkScrollShot

This screenshot testing has been useful in that we can find bugs. For example, we found a bug where the tab was hidden when scrolling.

screenshot-diff

Fake API Server

To ensure stable and comprehensive testing of our app, we opt to fake our API rather than use actual API. We have also designed our API to manage its own state and to allow us to change its status as needed. For instance, although we're not using it here, we could place an AccessCounter field inside the Status class to keep track of how many times the API has been hit. By managing our fake API in this way with Kotlin, we can adapt to changes in the response without having to rewrite the entire application.

interface SessionsApi {
    suspend fun timetable(): Timetable
}

class FakeSessionsApi : SessionsApi {

    sealed class Status : SessionsApi {
        object Operational : Status() {
            override suspend fun timetable(): Timetable {
                return Timetable.fake()
            }
        }

        object Error : Status() {
            override suspend fun timetable(): Timetable {
                throw IOException("Fake IO Exception")
            }
        }
    }

    private var status: Status = Status.Operational

    fun setup(status: Status) {
        this.status = status
    }

    override suspend fun timetable(): Timetable {
        return status.timetable()
    }
}

We use the FakeSessionsApi throughout our tests. It's provided by the FakeSessionsApiModule, which replaces the original SessionsApiModule during testing.

@Module
@TestInstallIn(
    components = [SingletonComponent::class], 
    replaces = [SessionsApiModule::class]
)
class FakeSessionsApiModule {
    @Provides
    fun provideSessionsApi(): SessionsApi {
        return FakeSessionsApi()
    }
}

iOS

Requirements

  1. You need to install the following tools.
  • JDK 17
    • You can install via SDKMAN,
    • sdk install $(cat .sdkmanrc | sed -e 's/=/ /')
  • Xcode, .xcode-version version
  • Ruby, .ruby-version version
    • bundler (you can install by gem install bundler or sudo gem install bundler)
  1. Setup

    1. bundle install
    2. cd app-ios && bundle exec fastlane shared
  2. open DroidKaigi2023.xcodeproj by Xcode

Build

  • You can filter XCFramework arch by arch option at local.properties
    • e.g. if you need only x86_64 binary, you can set arch=x86_64

Debug

  • You can build and debug on Android Studio with KMM plugin
  • After install KMM plugin, you can see app-ios module on Android Studio's run configurations.
    • Run Configuration
  • Set configs like below
    • config

Special thanks