OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / android / app / App.jsx

221 lines · jsx ref:
// Cthuloid Lights -- the app shell. THE UI IS the web UI: the same page cthuloid-web
// serves on stortron, served here by the same controller running in this app's
// worklet, shown in a WebView. Nothing about lamps lives in this file.
import React, { useEffect, useState, useCallback, useRef } from 'react'
import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable, AppState, DeviceEventEmitter, NativeModules } from 'react-native'
import { WebView } from 'react-native-webview'
import { startWorklet } from './worklet-host'
import * as updater from './updater'

// The lamp finder (LampFinderModule.kt, NsdManager) -> the worklet's discovery.
// Browses only while the app is on screen: in the background the worklet is
// suspended anyway, and on resume a fresh browse re-reports every lamp.
let finding = false
function startFinder (host) {
  const finder = NativeModules.CthuloidLampFinder
  if (!finder || finding) return
  finding = true
  DeviceEventEmitter.addListener('cthuloid-unit', (m) => host.send(m))
  finder.start()
  AppState.addEventListener('change', (state) => {
    if (state === 'active') finder.start()
    else finder.stop()
  })
}

function Center ({ children }) {
  return <View style={s.center}>{children}</View>
}

export default function App () {
  return (
    <SafeAreaView style={s.fill}>
      <UpdateBanner />
      <Main />
    </SafeAreaView>
  )
}

// Self-update (DESIGN-android.md §6): checks the forge on start, every 6 h and
// on tap. A slim line always shows the installed version and the last check;
// it turns into a blue Install strip when a newer complete release exists.
// Above every screen, so a build whose controller will not start can still
// update itself.
const CHECK_EVERY = 6 * 60 * 60 * 1000
const hhmm = (d) => String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')

function UpdateBanner () {
  const [u, setU] = useState(null)          // { version, apkUrl, shaUrl }
  const [phase, setPhase] = useState('available')
  const [note, setNote] = useState('')
  const [checked, setChecked] = useState('checking for updates…')
  const busy = useRef(false)
  const path = useRef(null)                 // checked APK on disk, for u.version only

  const run = useCallback(() => {
    if (busy.current) return
    setChecked('checking for updates…')
    updater.check()
      .then((next) => {
        setChecked('up to date · checked ' + hhmm(new Date()))
        if (busy.current) return
        setU((prev) => {
          if (!prev || !next || prev.version !== next.version) { path.current = null; setPhase('available'); setNote('') }
          return next
        })
      })
      // A failed check is said as such, never shown as 'up to date'.
      .catch((e) => setChecked('update check failed: ' + e.message))
  }, [])

  useEffect(() => {
    run()
    const t = setInterval(run, CHECK_EVERY)
    return () => clearInterval(t)
  }, [run])

  const go = useCallback(async () => {
    if (busy.current) return
    busy.current = true
    try {
      if (!path.current) {
        setPhase('downloading')
        path.current = await updater.download(u, (f) => setNote(Math.round(f * 100) + ' %'))
      }
      const opened = await updater.install(path.current)
      setPhase(opened ? 'installing' : 'permission')
      setNote('')
    } catch (e) {
      path.current = null
      // Cut off mid-download (screen locked, WiFi dropped): the bytes so far are
      // kept, and the download continues when the app is back on screen.
      if (e.paused) setPhase('paused')
      else { setPhase('error'); setNote(e.message) }
    } finally {
      busy.current = false
    }
  }, [u])

  const resumeRef = useRef(null)
  resumeRef.current = phase === 'paused' ? go : null
  useEffect(() => {
    const sub = AppState.addEventListener('change', (state) => {
      if (state === 'active' && resumeRef.current) resumeRef.current()
    })
    return () => sub.remove()
  }, [])

  if (!u) {
    return (
      <Pressable style={s.version} onPress={run}>
        <Text style={s.versionText}>Cthuloid Lights {updater.installedVersion || '?'} · {checked}</Text>
      </Pressable>
    )
  }
  const label = {
    available: 'Update ' + u.version + ' available — Install',
    downloading: 'Downloading ' + u.version + '… ' + note,
    paused: 'Download paused at ' + (note || '0 %') + ' — tap to continue',
    permission: 'Allow "Install unknown apps" for Cthuloid Lights, then tap here',
    installing: 'Installing ' + u.version + ' — tap if the installer closed',
    error: 'Update failed: ' + note + ' — tap to retry'
  }[phase]
  return (
    <Pressable style={s.banner} onPress={go} disabled={phase === 'downloading'}>
      <Text style={s.bannerText}>{label}</Text>
      <Text style={s.bannerSub}>installed {updater.installedVersion}</Text>
    </Pressable>
  )
}

function Main () {
  const [url, setUrl] = useState(null)
  const [error, setError] = useState(null)
  const [stage, setStage] = useState('starting…')

  const boot = useCallback(async () => {
    setError(null)
    const host = await startWorklet()   // memoised: a retry reuses the running worklet
    let answered = false
    host.onMessage((m) => {
      if (m.t === 'log') setStage(m.msg)
      else if (m.t === 'ready') { answered = true; setUrl(m.url); startFinder(host) }
      else if (m.t === 'fatal') { answered = true; setError(m.error) }
    })
    // A worklet has no error or exit event: if it never speaks, say so rather
    // than spin for ever (0.1.0 did exactly that).
    setTimeout(() => {
      if (!answered) setError('The light controller did not answer within 20 s.')
    }, 20000)
  }, [])

  useEffect(() => { boot().catch((e) => setError(e.message)) }, [boot])

  if (error) {
    return (
      <Center>
        <Text style={s.err}>{error}</Text>
        <Text style={s.dim}>{stage}</Text>
        <Pressable onPress={() => boot().catch((e) => setError(e.message))}><Text style={s.retry}>try again</Text></Pressable>
      </Center>
    )
  }
  if (!url) return <Center><ActivityIndicator /><Text style={s.dim}>{stage}</Text></Center>
  return <Page url={url} />
}

function Page ({ url }) {
  const ref = useRef(null)
  const [canBack, setCanBack] = useState(false)
  const [failed, setFailed] = useState(null)

  // Hardware BACK follows page history before leaving the app.
  useEffect(() => {
    const sub = BackHandler.addEventListener('hardwareBackPress', () => {
      if (canBack && ref.current) { ref.current.goBack(); return true }
      return false
    })
    return () => sub.remove()
  }, [canBack])

  if (failed) {
    return (
      <Center>
        <Text style={s.err}>could not load the page</Text>
        <Text style={s.dim}>{failed}</Text>
        <Pressable onPress={() => setFailed(null)}><Text style={s.retry}>retry</Text></Pressable>
      </Center>
    )
  }

  return (
    <View style={s.fill}>
      <WebView
        ref={ref}
        source={{ uri: url }}
        onNavigationStateChange={(nav) => setCanBack(nav.canGoBack)}
        // The page comes from this device only; navigating off loopback is a bug.
        originWhitelist={['http://127.0.0.1:*']}
        onError={(e) => setFailed(String((e.nativeEvent && e.nativeEvent.description) || 'load failed'))}
        javaScriptEnabled
        // localStorage keeps this phone's session id, name and swatches.
        domStorageEnabled
        style={s.fill}
      />
    </View>
  )
}

const s = StyleSheet.create({
  fill: { flex: 1 },
  center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
  dim: { opacity: 0.6, marginTop: 12, textAlign: 'center' },
  err: { color: '#c33', textAlign: 'center' },
  retry: { marginTop: 16, textDecorationLine: 'underline' },
  banner: { backgroundColor: '#2d5fd0', paddingVertical: 10, paddingHorizontal: 16 },
  bannerText: { color: '#fff', textAlign: 'center' },
  bannerSub: { color: '#fff', opacity: 0.75, fontSize: 11, textAlign: 'center' },
  version: { paddingVertical: 4, paddingHorizontal: 12 },
  versionText: { fontSize: 11, opacity: 0.55, textAlign: 'center' }
})