OXYGENHUB
# 645469f5227b oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / nav / AppNavigation.kt
22.3 KiB · last changed This phone as the second factor: a Keystore key opened by fingerprint, face or PIN approves sign-ins and sensitive changes on the web by the number the browser shows (closes #32) 3 days ago source blame history raw
1
package io.oxygenhub.android.ui.nav
2
3
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection
4
import androidx.compose.animation.core.tween
5
import androidx.compose.animation.fadeIn
6
import androidx.compose.animation.fadeOut
7
import androidx.compose.material.icons.Icons
8
import androidx.compose.material.icons.filled.AccountCircle
9
import androidx.compose.material.icons.filled.Book
10
import androidx.compose.material.icons.filled.Dashboard
11
import androidx.compose.material.icons.filled.Inbox
12
import androidx.compose.material.icons.filled.RocketLaunch
13
import androidx.compose.material.icons.filled.Search
14
import androidx.compose.material.icons.filled.ViewKanban
15
import androidx.compose.material.icons.outlined.AccountCircle
16
import androidx.compose.material.icons.outlined.Book
17
import androidx.compose.material.icons.outlined.Dashboard
18
import androidx.compose.material.icons.outlined.Inbox
19
import androidx.compose.material.icons.outlined.RocketLaunch
20
import androidx.compose.material.icons.outlined.Search
21
import androidx.compose.material.icons.outlined.ViewKanban
22
import androidx.compose.material3.Badge
23
import androidx.compose.material3.BadgedBox
24
import androidx.compose.material3.Icon
25
import androidx.compose.material3.Text
26
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
27
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
28
import androidx.compose.runtime.Composable
29
import androidx.compose.runtime.LaunchedEffect
30
import androidx.compose.runtime.getValue
31
import androidx.compose.runtime.rememberCoroutineScope
32
import androidx.compose.ui.graphics.vector.ImageVector
33
import androidx.compose.ui.platform.LocalUriHandler
34
import androidx.lifecycle.ViewModel
35
import androidx.lifecycle.compose.collectAsStateWithLifecycle
36
import androidx.lifecycle.viewmodel.compose.viewModel
37
import androidx.lifecycle.viewmodel.initializer
38
import androidx.lifecycle.viewmodel.viewModelFactory
39
import androidx.navigation.NavDestination.Companion.hasRoute
40
import androidx.navigation.NavDestination.Companion.hierarchy
41
import androidx.navigation.NavGraph.Companion.findStartDestination
42
import androidx.navigation.NavHostController
43
import androidx.navigation.compose.NavHost
44
import androidx.navigation.compose.composable
45
import androidx.navigation.compose.currentBackStackEntryAsState
46
import androidx.navigation.compose.rememberNavController
47
import androidx.navigation.toRoute
48
import androidx.window.core.layout.WindowSizeClass
49
import io.oxygenhub.android.api.HubApi
50
import io.oxygenhub.android.auth.Session
51
import io.oxygenhub.android.ui.account.AccountScreen
52
import io.oxygenhub.android.ui.backdrop.repoSkyOrNull
53
import io.oxygenhub.android.ui.code.ChangeScreen
54
import io.oxygenhub.android.ui.code.ChangeViewModel
55
import io.oxygenhub.android.ui.code.CodeViewModel
56
import io.oxygenhub.android.ui.code.FileScreen
57
import io.oxygenhub.android.ui.code.FileViewModel
58
import io.oxygenhub.android.ui.code.HistoryScreen
59
import io.oxygenhub.android.ui.code.HistoryViewModel
60
import io.oxygenhub.android.ui.common.rememberAvatar
61
import io.oxygenhub.android.ui.dashboard.DashboardScreen
62
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
63
import io.oxygenhub.android.ui.flow.FlowMonitorScreen
64
import io.oxygenhub.android.ui.flow.FlowMonitorViewModel
65
import io.oxygenhub.android.ui.flow.JobLogScreen
66
import io.oxygenhub.android.ui.flow.JobLogViewModel
67
import io.oxygenhub.android.ui.flow.RunScreen
68
import io.oxygenhub.android.ui.flow.RunViewModel
69
import io.oxygenhub.android.ui.flow.RunsViewModel
70
import io.oxygenhub.android.ui.inbox.InboxScreen
71
import io.oxygenhub.android.ui.inbox.InboxViewModel
72
import io.oxygenhub.android.ui.issues.IssueScreen
73
import io.oxygenhub.android.ui.issues.IssueViewModel
74
import io.oxygenhub.android.ui.issues.IssuesViewModel
75
import io.oxygenhub.android.ui.issues.NewIssueScreen
76
import io.oxygenhub.android.ui.issues.NewIssueViewModel
77
import io.oxygenhub.android.ui.owners.OwnerScreen
78
import io.oxygenhub.android.ui.owners.OwnerViewModel
79
import io.oxygenhub.android.ui.projects.BoardScreen
80
import io.oxygenhub.android.ui.projects.BoardViewModel
81
import io.oxygenhub.android.ui.projects.ProjectsScreen
82
import io.oxygenhub.android.ui.projects.ProjectsViewModel
83
import io.oxygenhub.android.ui.proposals.ProposalFilesScreen
84
import io.oxygenhub.android.ui.proposals.ProposalFilesViewModel
85
import io.oxygenhub.android.ui.proposals.ProposalScreen
86
import io.oxygenhub.android.ui.proposals.ProposalViewModel
87
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
88
import io.oxygenhub.android.ui.releases.ReleasesViewModel
89
import io.oxygenhub.android.ui.releases.SearchScreen
90
import io.oxygenhub.android.ui.releases.SearchViewModel
91
import io.oxygenhub.android.ui.repos.NewRepositoryScreen
92
import io.oxygenhub.android.ui.repos.NewRepositoryViewModel
93
import io.oxygenhub.android.secondfactor.ApprovalSheet
94
import io.oxygenhub.android.secondfactor.ApprovalsViewModel
95
import io.oxygenhub.android.secondfactor.PhoneKey
96
import io.oxygenhub.android.ui.repos.RepositoriesScreen
97
import io.oxygenhub.android.ui.repos.RepositoriesViewModel
98
import io.oxygenhub.android.ui.repos.RepositoryScreen
99
import kotlin.reflect.KClass
100
import kotlinx.coroutines.launch
101
import kotlinx.serialization.Serializable
102
103
// The app's routes. Top-level ones are in the navigation bar (or rail, or
104
// drawer, as the window allows); the rest are pushed over them. Browse
105
// (oxygenhub-android#3) adds a repository's overview, code and history
106
// under the repository's route.
107
108
@Serializable data object DashboardRoute
109
110
@Serializable data object RepositoriesRoute
111
112
@Serializable data object NewRepositoryRoute
113
114
@Serializable data object FlowRoute
115
116
@Serializable data object InboxRoute
117
118
@Serializable data object ProjectsRoute
119
120
@Serializable data object SearchRoute
121
122
/** A project board. */
123
@Serializable data class BoardRoute(val owner: String, val number: Long)
124
125
@Serializable data object AccountRoute
126
127
/** A repository's issues, opened from outside the repositories list (the dashboard). */
128
@Serializable data class IssuesRoute(val owner: String, val name: String)
129
130
/** One proposal, pushed over the repository it belongs to. */
131
@Serializable data class ProposalRoute(val owner: String, val name: String, val number: Long)
132
133
/** A proposal's files with their diffs (#4). */
134
@Serializable data class ProposalFilesRoute(val owner: String, val name: String, val number: Long)
135
136
/** A file at a reference, a history (of a file when [path] is given), and a change (#3). */
137
@Serializable data class FileRoute(val owner: String, val name: String, val ref: String, val path: String)
138
139
@Serializable data class HistoryRoute(val owner: String, val name: String, val ref: String, val path: String? = null)
140
141
@Serializable data class ChangeRoute(val owner: String, val name: String, val hash: String)
142
143
/** An issue, and a new one (#4). */
144
@Serializable data class IssueRoute(val owner: String, val name: String, val number: Long)
145
146
@Serializable data class NewIssueRoute(val owner: String, val name: String)
147
148
/** An account's or organisation's page. */
149
@Serializable data class OwnerRoute(val name: String)
150
151
/** A Flow run, and a job's log (#5). */
152
@Serializable data class RunRoute(val owner: String, val name: String, val number: Long)
153
154
@Serializable data class JobLogRoute(val owner: String, val name: String, val number: Long, val job: String)
155
156
/**
157
 * Where the bar or rail goes. [wide] ones show only when there is room (the
158
 * unfolded Fold, a tablet: #14); on a phone the bar keeps its four and they
159
 * are reached from the dashboard.
160
 */
161
enum class TopLevel(
162
    val label: String,
163
    val icon: ImageVector,
164
    val selectedIcon: ImageVector,
165
    val route: Any,
166
    val routeClass: KClass<*>,
167
    val wide: Boolean = false,
168
) {
169
    Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class),
170
    Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class),
171
    Flow("Flow", Icons.Outlined.RocketLaunch, Icons.Filled.RocketLaunch, FlowRoute, FlowRoute::class),
172
    Projects("Projects", Icons.Outlined.ViewKanban, Icons.Filled.ViewKanban, ProjectsRoute, ProjectsRoute::class, wide = true),
173
    Search("Search", Icons.Outlined.Search, Icons.Filled.Search, SearchRoute, SearchRoute::class, wide = true),
174
    Inbox("Inbox", Icons.Outlined.Inbox, Icons.Filled.Inbox, InboxRoute, InboxRoute::class),
175
    Account("Account", Icons.Outlined.AccountCircle, Icons.Filled.AccountCircle, AccountRoute, AccountRoute::class),
176
}
177
178
/** A view model made by [make], kept by the screen's back stack entry (or [key] within it). */
179
@Composable
180
inline fun <reified VM : ViewModel> rememberViewModel(key: String? = null, crossinline make: () -> VM): VM =
181
    viewModel(key = key, factory = viewModelFactory { initializer { make() } })
182
183
/** The signed-in app: navigation around the dashboard, repositories, inbox and account. */
184
@Composable
185
fun SignedInApp(
186
    session: Session,
187
    api: HubApi,
188
    nativeCore: () -> String,
189
    onSignOut: suspend () -> Unit,
190
    start: TopLevel = TopLevel.Dashboard,
191
    /** A repository to show beside the list from the start (`owner/name`). */
192
    selectedRepository: String? = null,
193
    /** A screen to open, as a notification (#20) or the widget (#31) asks; [onOpened] once it is. */
194
    open: Any? = null,
195
    onOpened: () -> Unit = {},
196
) {
197
    val nav = rememberNavController()
198
    // One inbox for the badge, the dashboard's count and the inbox screen.
199
    val inbox = rememberViewModel(key = "inbox:${session.hub}:${session.username}") { InboxViewModel(api) }
200
    val inboxState by inbox.state.collectAsStateWithLifecycle()
201
    val unread = inboxState.valueOrNull?.unread ?: 0
202
    val entry by nav.currentBackStackEntryAsState()
203
    // A hub page opens on its own screen when the app has one, else in the browser.
204
    val uri = LocalUriHandler.current
205
    val openLink: (String) -> Unit = { url -> routeFor(url, session.hub)?.let { nav.navigate(it) } ?: uri.openUri(url) }
206
    val destination = entry?.destination
207
208
    val roomy = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
209
    NavigationSuiteScaffold(
210
        navigationSuiteItems = {
211
            TopLevel.entries.filter { roomy || !it.wide }.forEach { top ->
212
                val selected = destination?.hierarchy?.any { it.hasRoute(top.routeClass) } == true
213
                item(
214
                    selected = selected,
215
                    onClick = { nav.goTo(top) },
216
                    icon = {
217
                        val icon = if (selected) top.selectedIcon else top.icon
218
                        if (top == TopLevel.Inbox && unread > 0) {
219
                            BadgedBox(badge = { Badge { Text(if (unread > 99) "99+" else unread.toString()) } }) {
220
                                Icon(icon, contentDescription = "${top.label}, $unread unread")
221
                            }
222
                        } else {
223
                            Icon(icon, contentDescription = top.label)
224
                        }
225
                    },
226
                    label = { Text(top.label) },
227
                )
228
            }
229
        },
230
    ) {
231
        NavHost(
232
            nav,
233
            startDestination = start.route,
234
            // A screen opened comes in a little from the side it leads to as it fades in, and
235
            // goes back the way it came (#23): where you are going reads in the motion.
236
            enterTransition = { slideIntoContainer(SlideDirection.Start, tween(NAV_MS), initialOffset = { it / 8 }) + fadeIn(tween(NAV_MS)) },
237
            exitTransition = { fadeOut(tween(NAV_MS * 2 / 3)) },
238
            popEnterTransition = { fadeIn(tween(NAV_MS)) },
239
            popExitTransition = { slideOutOfContainer(SlideDirection.End, tween(NAV_MS), targetOffset = { it / 8 }) + fadeOut(tween(NAV_MS * 2 / 3)) },
240
        ) {
241
            composable<DashboardRoute> {
242
                DashboardScreen(
243
                    vm = rememberViewModel { DashboardViewModel(api, session) },
244
                    hub = session.hub,
245
                    inbox = inboxState,
246
                    onRefreshInbox = inbox::refresh,
247
                    onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) },
248
                    onAllRepositories = { nav.goTo(TopLevel.Repositories) },
249
                    onInbox = { nav.goTo(TopLevel.Inbox) },
250
                    onOpenProposal = { nav.navigate(ProposalRoute(it.owner, it.name, it.number)) },
251
                    onOpenIssue = { nav.navigate(IssueRoute(it.repository.substringBefore('/'), it.repository.substringAfter('/'), it.number)) },
252
                    onSearch = { nav.navigate(SearchRoute) },
253
                    onProjects = if (roomy) null else { { nav.navigate(ProjectsRoute) } },
254
                )
255
            }
256
            composable<SearchRoute> {
257
                SearchScreen(rememberViewModel { SearchViewModel(api) }, onBack = { nav.popBackStack() }) { h ->
258
                    nav.navigate(if (h.kind == "proposal") ProposalRoute(h.owner, h.name, h.number) else IssueRoute(h.owner, h.name, h.number))
259
                }
260
            }
261
            composable<RepositoriesRoute> {
262
                RepositoriesScreen(
263
                    rememberViewModel { RepositoriesViewModel(api, session.username) },
264
                    selectedRepository,
265
                    onNew = { nav.navigate(NewRepositoryRoute) },
266
                ) { slug, onBack ->
267
                    val (owner, name) = slug.split('/', limit = 2)
268
                    RepositoryScreen(
269
                        rememberViewModel(key = "code:$slug") { CodeViewModel(api, owner, name) },
270
                        rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) },
271
                        rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) },
272
                        rememberViewModel(key = "runs:$slug") { RunsViewModel(api, owner, name) },
273
                        rememberViewModel(key = "releases:$slug") { ReleasesViewModel(api, owner, name) },
274
                        onBack,
275
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
276
                        onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) },
277
                        onOpenHistory = { ref -> nav.navigate(HistoryRoute(owner, name, ref)) },
278
                        onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) },
279
                        onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null,
280
                        onOpenRun = { nav.navigate(RunRoute(owner, name, it)) },
281
                        onOpenOwner = { nav.navigate(OwnerRoute(owner)) },
282
                        loadSky = { repoSkyOrNull(api, owner, name) },
283
                    )
284
                }
285
            }
286
            composable<IssuesRoute> { e ->
287
                val r = e.toRoute<IssuesRoute>()
288
                RepositoryScreen(
289
                    rememberViewModel { CodeViewModel(api, r.owner, r.name) },
290
                    rememberViewModel { IssuesViewModel(api, r.owner, r.name) },
291
                    rememberViewModel { ProposalsViewModel(api, r.owner, r.name) },
292
                    rememberViewModel { RunsViewModel(api, r.owner, r.name) },
293
                    rememberViewModel { ReleasesViewModel(api, r.owner, r.name) },
294
                    onBack = { nav.popBackStack() },
295
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
296
                    onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) },
297
                    onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) },
298
                    onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) },
299
                    onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null,
300
                    onOpenRun = { nav.navigate(RunRoute(r.owner, r.name, it)) },
301
                    onOpenOwner = { nav.navigate(OwnerRoute(r.owner)) },
302
                    loadSky = { repoSkyOrNull(api, r.owner, r.name) },
303
                )
304
            }
305
            composable<OwnerRoute> { e ->
306
                val r = e.toRoute<OwnerRoute>()
307
                OwnerScreen(
308
                    rememberViewModel { OwnerViewModel(api, r.name) },
309
                    api,
310
                    onBack = { nav.popBackStack() },
311
                    onOpenOwner = { nav.navigate(OwnerRoute(it)) },
312
                    onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) },
313
                    onOpenProject = { nav.navigate(BoardRoute(it.owner, it.number)) },
314
                )
315
            }
316
            composable<ProjectsRoute> {
317
                ProjectsScreen(rememberViewModel { ProjectsViewModel(api, session.username) }) { p -> nav.navigate(BoardRoute(p.owner, p.number)) }
318
            }
319
            composable<BoardRoute> { e ->
320
                val r = e.toRoute<BoardRoute>()
321
                BoardScreen(
322
                    rememberViewModel { BoardViewModel(api, r.owner, r.number) },
323
                    canWrite = session.canWrite,
324
                    onBack = { nav.popBackStack() },
325
                    onOpenCard = { card ->
326
                        card.target?.let { (owner, name, n) ->
327
                            nav.navigate(if (card.kind == "proposal") ProposalRoute(owner, name, n) else IssueRoute(owner, name, n))
328
                        }
329
                    },
330
                )
331
            }
332
            composable<RunRoute> { e ->
333
                val r = e.toRoute<RunRoute>()
334
                RunScreen(
335
                    rememberViewModel { RunViewModel(api, r.owner, r.name, r.number) },
336
                    canWrite = session.canWrite,
337
                    onBack = { nav.popBackStack() },
338
                    onOpenJob = { j -> nav.navigate(JobLogRoute(r.owner, r.name, r.number, j.name)) },
339
                )
340
            }
341
            composable<JobLogRoute> { e ->
342
                val r = e.toRoute<JobLogRoute>()
343
                JobLogScreen(rememberViewModel { JobLogViewModel(api, r.owner, r.name, r.number, r.job) }, onBack = { nav.popBackStack() })
344
            }
345
            composable<IssueRoute> { e ->
346
                val r = e.toRoute<IssueRoute>()
347
                IssueScreen(rememberViewModel { IssueViewModel(api, r.owner, r.name, r.number) }, canWrite = session.canWrite, onBack = { nav.popBackStack() }, me = session.username)
348
            }
349
            composable<FlowRoute> {
350
                FlowMonitorScreen(rememberViewModel { FlowMonitorViewModel(api) }) { owner, name, number -> nav.navigate(RunRoute(owner, name, number)) }
351
            }
352
            composable<NewRepositoryRoute> {
353
                NewRepositoryScreen(
354
                    rememberViewModel { NewRepositoryViewModel(api, session.username) },
355
                    canCreate = session.canCreate,
356
                    onBack = { nav.popBackStack() },
357
                    onOpen = { r -> nav.navigate(IssuesRoute(r.owner, r.name)) { popUpTo<NewRepositoryRoute> { inclusive = true } } },
358
                )
359
            }
360
            composable<NewIssueRoute> { e ->
361
                val r = e.toRoute<NewIssueRoute>()
362
                NewIssueScreen(
363
                    rememberViewModel { NewIssueViewModel(api, r.owner, r.name) },
364
                    onBack = { nav.popBackStack() },
365
                    onOpened = { n -> nav.navigate(IssueRoute(r.owner, r.name, n)) { popUpTo<NewIssueRoute> { inclusive = true } } },
366
                )
367
            }
368
            composable<FileRoute> { e ->
369
                val r = e.toRoute<FileRoute>()
370
                FileScreen(
371
                    rememberViewModel { FileViewModel(api, r.owner, r.name, r.ref, r.path) },
372
                    onBack = { nav.popBackStack() },
373
                    onOpenHistory = { nav.navigate(HistoryRoute(r.owner, r.name, r.ref, r.path)) },
374
                )
375
            }
376
            composable<HistoryRoute> { e ->
377
                val r = e.toRoute<HistoryRoute>()
378
                HistoryScreen(
379
                    rememberViewModel { HistoryViewModel(api, r.owner, r.name, r.ref, r.path) },
380
                    onBack = { nav.popBackStack() },
381
                    onOpenChange = { nav.navigate(ChangeRoute(r.owner, r.name, it.hash)) },
382
                )
383
            }
384
            composable<ChangeRoute> { e ->
385
                val r = e.toRoute<ChangeRoute>()
386
                ChangeScreen(
387
                    rememberViewModel { ChangeViewModel(api, r.owner, r.name, r.hash) },
388
                    onBack = { nav.popBackStack() },
389
                    onOpenFile = { path -> nav.navigate(FileRoute(r.owner, r.name, r.hash, path)) },
390
                )
391
            }
392
            composable<ProposalRoute> { e ->
393
                val r = e.toRoute<ProposalRoute>()
394
                val scope = rememberCoroutineScope()
395
                ProposalScreen(
396
                    rememberViewModel { ProposalViewModel(api, r.owner, r.name, r.number) },
397
                    canWrite = session.canWrite,
398
                    onBack = { nav.popBackStack() },
399
                    // Signing out revokes this token; the new sign-in asks for repo:write.
400
                    onSignInAgain = { scope.launch { onSignOut() } },
401
                    onOpenFiles = { nav.navigate(ProposalFilesRoute(r.owner, r.name, r.number)) },
402
                )
403
            }
404
            composable<ProposalFilesRoute> { e ->
405
                val r = e.toRoute<ProposalFilesRoute>()
406
                ProposalFilesScreen(rememberViewModel { ProposalFilesViewModel(api, r.owner, r.name, r.number) }, onBack = { nav.popBackStack() })
407
            }
408
            composable<InboxRoute> { InboxScreen(inbox, onOpen = openLink) }
409
            composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) }
410
        }
411
        // Approvals for this phone, when it is a second factor (#32).
412
        LaunchedEffect(Unit) { PhoneKey.refresh() }
413
        val approves by PhoneKey.present.collectAsStateWithLifecycle()
414
        if (approves) ApprovalSheet(rememberViewModel { ApprovalsViewModel(api) })
415
        LaunchedEffect(open) {
416
            open?.let {
417
                nav.navigate(it) { launchSingleTop = true }
418
                onOpened()
419
            }
420
        }
421
    }
422
}
423
424
/** To a top-level destination, keeping one copy of each and its state, as the bar's own taps do. */
425
private fun NavHostController.goTo(top: TopLevel) {
426
    navigate(top.route) {
427
        popUpTo(graph.findStartDestination().id) { saveState = true }
428
        launchSingleTop = true
429
        restoreState = true
430
    }
431
}
432
433
/** How long a screen takes to come in (#23). */
434
private const val NAV_MS = 300