Merge "A demo of OTAGUI, still in its very early version."

This commit is contained in:
Kelvin Zhang
2021-06-07 23:09:20 +00:00
committed by Gerrit Code Review
24 changed files with 27720 additions and 2 deletions

View File

@@ -1,4 +1,22 @@
# README
This is a GUI for generating OTA package, using OTA_from_target_files command.
# OTAGUI
## Introduction
OTAGUI is a web interface for ota_from_target_files. Currently, it can only run locally.
OTAGUI use VUE.js as a frontend and python as a backend interface to ota_from_target_files.
## Usage
First, download the AOSP codebase and set up the environment variable in the root directory:
```
source build/envsetup.sh
lunch 17
```
In this case we use `lunch 17` as an example (aosp-x86_64-cf), you can choose whatever suitable for you.
Then, in this directory, please use `npm build` to install the dependencies.
Finally, run the python http-server and vue.js server:
```
python3 web_server.py &
npm run serve
```

View File

@@ -0,0 +1,3 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"]
};

26586
tools/otagui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
tools/otagui/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "OTA_GUI",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.6.5",
"eslint-config-airbnb-base": "^14.2.1",
"vue": "^3.0.0-0",
"vue-router": "^4.0.0-0",
"vuex": "^4.0.0-0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0-0",
"@vue/eslint-config-prettier": "^6.0.0",
"axios": "^0.20.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-vue": "^7.0.0-0",
"prettier": "^1.19.1"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/prettier"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

350
tools/otagui/src/App.vue Normal file
View File

@@ -0,0 +1,350 @@
<template>
<div id="app">
<div id="nav">
<router-link :to="{ name: 'Create' }">
Create Jobs
</router-link> |
<router-link :to="{ name: 'JobList' }">
Jobs Status
</router-link>|
<router-link :to="{ name: 'About' }">
About
</router-link>
</div>
<router-view />
</div>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
#nav {
padding: 30px;
}
#nav a {
font-weight: bold;
color: #2c3e50;
}
#nav a.router-link-exact-active {
color: #42b983;
}
h4 {
font-size: 20px;
text-align: center;
}
html {
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
font-family: "Open Sans", sans-serif;
font-size: 16px;
line-height: 1.5;
}
#app {
box-sizing: border-box;
width: 500px;
padding: 0 20px 20px;
margin: 0 auto;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
a {
color: #39b982;
font-weight: 600;
background-color: transparent;
}
img {
border-style: none;
width: 100%;
}
h1,
h2,
h3,
h4,
h5,
h6 {
display: flex;
align-items: center;
font-family: "Montserrat", sans-serif;
}
h1 {
font-size: 50px;
font-weight: 700;
}
h2 {
font-size: 38px;
font-weight: 700;
}
h3 {
font-size: 28px;
font-weight: 700;
}
h4 {
font-size: 21px;
font-weight: 700;
}
h5 {
font-size: 16px;
font-weight: 700;
}
h6 {
font-size: 15px;
font-weight: 700;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 80%;
}
.eyebrow {
font-size: 20px;
}
.-text-primary {
color: #39b982;
}
.-text-base {
color: #000;
}
.-text-error {
color: tomato;
}
.-text-gray {
color: rgba(0, 0, 0, 0.5);
}
.-shadow {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2), 0 1px 5px 0 rgba(0, 0, 0, 0.13);
}
.badge {
display: inline-flex;
height: 26px;
width: auto;
padding: 0 7px;
margin: 0 5px;
background: transparent;
border-radius: 13px;
font-size: 13px;
font-weight: 400;
line-height: 26px;
}
.badge.-fill-gradient {
background: linear-gradient(to right, #16c0b0, #84cf6a);
color: #fff;
}
button,
label,
input,
optgroup,
select,
textarea {
display: inline-flex;
font-family: "Open sans", sans-serif;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: none;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 2px solid #39b982;
}
label {
color: rgba(0, 0, 0, 0.5);
font-weight: 700;
}
input,
textarea {
box-sizing: border-box;
border: solid 1px rgba(0, 0, 0, 0.4);
}
input.error,
select.error {
margin-bottom: 0;
}
input + p.errorMessage {
margin-bottom: 24px;
}
textarea {
width: 100%;
overflow: auto;
font-size: 20px;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
margin-right: 0.5rem;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
[type="text"],
[type="number"],
[type="search"],
[type="password"] {
height: 52px;
width: 100%;
padding: 0 10px;
font-size: 20px;
}
[type="text"]:focus,
[type="number"]:focus,
[type="search"]:focus,
[type="password"]:focus {
border-color: #39b982;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
[hidden] {
display: none;
}
.error {
border: 1px solid red;
}
select {
width: 100%;
height: 52px;
padding: 0 24px 0 10px;
vertical-align: middle;
background: #fff
url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E")
no-repeat right 12px center;
background-size: 8px 10px;
border: solid 1px rgba(0, 0, 0, 0.4);
border-radius: 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
select:focus {
border-color: #39b982;
outline: 0;
}
select:focus::ms-value {
color: #000;
background: #fff;
}
select::ms-expand {
opacity: 0;
}
.field {
margin-bottom: 24px;
}
.error {
border: 1px solid red;
}
.errorMessage {
color: red;
}
.button {
display: inline-flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 40px;
background: transparent;
border: none;
border-radius: 6px;
text-align: center;
font-weight: 600;
white-space: nowrap;
transition: all 0.2s linear;
}
.button:hover {
-webkit-transform: scale(1.02);
transform: scale(1.02);
box-shadow: 0 7px 17px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.button:active {
-webkit-transform: scale(1);
transform: scale(1);
box-shadow: none;
}
.button:focus {
outline: 0;
}
.button:disabled {
-webkit-transform: scale(1);
transform: scale(1);
box-shadow: none;
}
.button + .button {
margin-left: 1em;
}
.button.-fill-gradient {
background: linear-gradient(to right, #16c0b0, #84cf6a);
color: #ffffff;
}
.button.-fill-gray {
background: rgba(0, 0, 0, 0.5);
color: #ffffff;
}
.button.-size-small {
height: 32px;
}
.button.-icon-right {
text-align: left;
padding: 0 20px;
}
.button.-icon-right > .icon {
margin-left: 10px;
}
.button.-icon-left {
text-align: right;
padding: 0 20px;
}
.button.-icon-left > .icon {
margin-right: 10px;
}
.button.-icon-center {
padding: 0 20px;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,24 @@
<template>
<input
type="checkbox"
:checked="modelValue"
class="field"
@change="$emit('update:modelValue', $event.target.checked)"
>
<label v-if="label"> {{ label }} </label>
</template>
<script>
export default {
props: {
label: {
type: String,
default: ''
},
modelValue: {
type: Boolean,
default: false
}
}
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<label class="file-select">
<div class="select-button">
<span v-if="value">Selected File: {{ value.name }}</span>
<span v-else>Select File</span>
</div>
<input
type="file"
@change="handleFileChange"
>
</label>
</template>
<script>
export default {
props: {
value: File
},
methods: {
handleFileChange(e) {
this.$emit('input', e.target.files[0])
}
}
}
</script>
<style scoped>
.file-select > .select-button {
padding: 1rem;
color: white;
background-color: #2EA169;
border-radius: .3rem;
text-align: center;
font-weight: bold;
}
.file-select > input[type="file"] {
display: none;
}
</style>

View File

@@ -0,0 +1,25 @@
<template>
<label> {{ label }} </label>
<input
v-bind="$attrs"
:placeholder="label"
class="field"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
>
</template>
<script>
export default{
props:{
label:{
type: String,
default: ''
},
modelValue:{
type: [String, Number],
default: ''
}
}
}
</script>

View File

@@ -0,0 +1,32 @@
<template>
<input
type="radio"
:checked="modelValue === value"
:value="value"
@change="$emit('update:modelValue', value)"
>
<label>{{ label }}</label>
</template>
<script>
export default {
props: {
label: {
type: String,
default: ''
},
modelValue: {
type: [String, Number],
default: ''
},
value: {
type: [String, Number],
required: true
},
name: {
type: String,
required: true
}
},
}
</script>

View File

@@ -0,0 +1,33 @@
<template>
<BaseRadio
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
:name="name"
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
/>
</template>
<script>
import BaseRadio from "./BaseRadio.vue"
export default {
components: { BaseRadio },
props: {
options: {
type: Array,
required: true
},
name: {
type: String,
required: true
},
modelValue: {
type: [String, Number],
required: true
}
},
}
</script>

View File

@@ -0,0 +1,39 @@
<template>
<label v-if="label"> {{ label }} </label>
<select
:value="modelValue"
class="field"
v-bind="$attrs"
@change="$emit('update:modelValue', $event.target.value)"
>
<option
v-for="option in options"
:key="option"
:value="option"
:selected="option === modelValue"
>
{{ option }}
</option>
</select>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
modelValue: {
type: [String, Number],
default: ''
},
options: {
type: Array,
required: true
}
},
}
</script>

View File

@@ -0,0 +1,32 @@
<template>
<div class="job-display">
<span>Status of Job.{{ job.id }}</span>
<h4>{{ job.status }}</h4>
</div>
</template>
<script>
export default {
props: {
job: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
.job-display {
padding: 20px;
width: 250px;
cursor: pointer;
border: 1px solid #39495c;
margin-bottom: 18px;
}
.job-display:hover {
transform: scale(1.01);
box-shadow: 0 3px 12px 0 rgba(0, 0, 0, 0.2);
}
</style>

9
tools/otagui/src/main.js Normal file
View File

@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
createApp(App)
.use(store)
.use(router)
.mount('#app')

View File

@@ -0,0 +1,36 @@
import { createRouter, createWebHistory } from 'vue-router'
import JobList from '@/views/JobList.vue'
import JobDetails from '@/views/JobDetails.vue'
import About from '@/views/About.vue'
import SimpleForm from '@/views/SimpleForm.vue'
const routes = [
{
path: '/',
name: 'JobList',
component: JobList
},
{
path: '/check/:id',
name: 'JobDetails',
props: true,
component: JobDetails
},
{
path: '/about',
name: 'About',
component: About
},
{
path: '/create',
name: 'Create',
component: SimpleForm
}
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router

View File

@@ -0,0 +1,27 @@
import axios from 'axios'
const apiClient = axios.create({
baseURL: 'http://localhost:8000',
withCredentials: false,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
})
export default {
getJobs() {
return apiClient.get("/check")
},
async postInput(input, id) {
try {
const response = await apiClient.post(
'/run/' + id, input)
console.log('Response:', response)
return response
} catch (err) {
console.log('err:', err)
return
}
}
}

View File

@@ -0,0 +1,8 @@
import { createStore } from 'vuex'
export default createStore({
state: {},
mutations: {},
actions: {},
modules: {}
})

View File

@@ -0,0 +1,5 @@
<template>
<div class="about">
<p>A GUI for the Android OTA generating.</p>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<template>
<div v-if="job">
<h3> Job. {{ job.id }} {{ job.status }}</h3>
<div>
<h4> STDERR </h4>
<div class="stderr">
{{ job.stderr }}
</div>
<h4> STDOUT </h4>
<div class="stdout">
{{ job.stdout }}
</div>
</div>
<br>
<a
v-if="job.status=='Finished'"
:href="download"
>
Download
</a>
</div>
</template>
<script>
import ApiService from '../services/ApiService.js'
export default {
props: ['id'],
data() {
return {
job: null,
}
},
computed: {
download() {
return "http://localhost:8000/download/" + this.job.path
}
},
created() {
this.updateStatus()
},
methods: {
async updateStatus() {
// fetch job (by id) and set local job data
try {
let response = await ApiService.getJobById(this.id)
this.job = response.data
}
catch (err) {
console.log(error)
}
if (this.job.status=='Running') {
setTimeout(this.updateStatus, 1000)
}
}
}
}
</script>
<style scoped>
.stderr, .stdout {
overflow: scroll;
height: 200px;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="jobs">
<JobDisplay
v-for="job in jobs"
:key="job.id"
:job="job"
/>
<button @click="updateStatus">
Update
</button>
</div>
</template>
<script>
import JobDisplay from '@/components/JobDisplay.vue'
import ApiService from '../services/ApiService.js'
export default {
name: 'JobList',
components: {
JobDisplay,
},
data() {
return {
jobs: null,
}
},
created (){
this.updateStatus()
},
methods:{
async updateStatus() {
try {
let response = await ApiService.getJobs()
this.jobs = response.data;
} catch (err) {
console.log(err);
}
}
}
}
</script>
<style scoped>
.jobs {
display: flex;
flex-direction: column;
align-items: center;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div>
<form @submit.prevent="sendForm">
<BaseInput
v-model="input.incremental"
:disabled="!input.incrementalStatus"
:label="'Source Package Path'"
type="text"
/>
<BaseInput
v-model="input.target"
label="Target File path"
type="text"
/>
<BaseCheckbox
v-model="input.verbose"
:label="'Verbose'"
/>
<BaseCheckbox
v-model="input.incrementalStatus"
:label="'Incremental'"
/>
<BaseInput
v-model="input.output"
label="Output File path"
type="text"
/>
<button type="submit">
Submit
</button>
</form>
<pre> {{ input }} </pre>
<h3> Response from the server </h3>
<div> {{ response_message }}</div>
</div>
</template>
<script>
import BaseInput from '@/components/BaseInput.vue'
import BaseCheckbox from '@/components/BaseCheckbox.vue'
import ApiService from '../services/ApiService.js'
export default {
components: {
BaseInput,
BaseCheckbox
},
data() {
return {
id: 0,
input: {
verbose: false,
target: '',
output: '',
incremental: '',
incrementalStatus: false
},
inputs: [],
response_message : ''
}
},
methods:{
sendForm(e) {
ApiService.postInput(this.input, this.id)
.then(Response => {
this.response_message = Response.data
})
.catch(err => {
this.response_message = 'Error! ' + err
})
this.input = {
verbose: false,
target: '',
output: '',
incremental: '',
incrementalStatus: false
}
}
},
}
</script>

173
tools/otagui/web_server.py Normal file
View File

@@ -0,0 +1,173 @@
"""
A simple local HTTP server for Android OTA package generation.
Based on OTA_from_target_files.
Usage::
python ./web_server.py [<port>]
API::
GET /check : check the status of all jobs
[TODO] GET /check/id : check the status of the job with <id>
POST /run/id : submit a job with <id>,
arguments set in a json uploaded together
[TODO] POST /cancel/id : cancel a job with <id>
"""
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from threading import Lock
import logging
import json
import pipes
import cgi
import subprocess
import os
LOCAL_ADDRESS = '0.0.0.0'
class ThreadSafeContainer:
def __init__(self):
self.__container = {}
self.__lock = Lock()
def set(self, name, value):
with self.__lock:
self.__container[name] = value
def get(self, name):
with self.__lock:
return self.__container[name]
def get_keys(self):
with self.__lock:
return self.__container.keys()
class RequestHandler(BaseHTTPRequestHandler):
def get_status(self):
statusList = []
for id in PROCESSES.get_keys():
status = {}
status['id'] = id
if PROCESSES.get(id).poll() == None:
status['status'] = 'Running'
elif PROCESSES.get(id).poll() == 0:
status['status'] = 'Finished'
else:
status['status'] = 'Error'
statusList.append(json.dumps(status))
return '['+','.join(statusList)+']'
def ota_generate(self, args, id=0):
command = ['ota_from_target_files']
# Check essential configuration is properly set
if not os.path.isfile(args['target']):
raise FileNotFoundError
if not args['output']:
raise SyntaxError
if args['verbose']:
command.append('-v')
command.append('-k')
command.append(
'../../../build/make/target/product/security/testkey')
if args['incremental']:
command.append('-i')
command.append(args['incremental'])
command.append(args['target'])
command.append(args['output'])
stderr_pipes = pipes.Template()
stdout_pipes = pipes.Template()
ferr = stderr_pipes.open('stderr', 'w')
fout = stdout_pipes.open('stdout', 'w')
PROCESSES.set(id, subprocess.Popen(
command, stderr=ferr, stdout=fout))
logging.info(
'Starting generating OTA package with id {}: \n {}'
.format(id, command))
def _set_response(self, type='text/html'):
self.send_response(200)
self.send_header('Content-type', type)
try:
origin_address, _ = cgi.parse_header(self.headers['Origin'])
self.send_header('Access-Control-Allow-Credentials', 'true')
self.send_header('Access-Control-Allow-Origin', origin_address)
except TypeError:
pass
self.end_headers()
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
self.send_header("Access-Control-Allow-Headers", "X-Requested-With")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
if str(self.path) == '/check':
status = self.get_status()
self._set_response('application/json')
self.wfile.write(
status.encode()
)
else:
self.send_error(404)
return
logging.info(
"GET request:\nPath:%s\nHeaders:\n%s\nBody:\n%s\n",
str(self.path), str(self.headers), status
)
def do_POST(self):
content_type, _ = cgi.parse_header(self.headers['content-type'])
if content_type != 'application/json':
self.send_response(400)
self.end_headers()
return
content_length = int(self.headers['Content-Length'])
post_data = json.loads(self.rfile.read(content_length))
if str(self.path)[:4] == '/run':
try:
self.ota_generate(post_data, id=str(self.path[5:]))
self._set_response()
self.wfile.write(
"ota generator start running".encode('utf-8'))
except SyntaxError:
self.send_error(400)
else:
self.send_error(400)
logging.info(
"POST request:\nPath:%s\nHeaders:\n%s\nBody:\n%s\n",
str(self.path), str(self.headers),
json.dumps(post_data)
)
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
pass
def run_server(SeverClass=ThreadedHTTPServer, HandlerClass=RequestHandler, port=8000):
logging.basicConfig(level=logging.DEBUG)
server_address = (LOCAL_ADDRESS, port)
server_instance = SeverClass(server_address, HandlerClass)
try:
logging.info(
'Server is on, address:\n %s',
'http://' + str(server_address[0]) + ':' + str(port))
server_instance.serve_forever()
except KeyboardInterrupt:
pass
server_instance.server_close()
logging.info('Server has been turned off.')
if __name__ == '__main__':
from sys import argv
print(argv)
PROCESSES = ThreadSafeContainer()
if len(argv) == 2:
run_server(port=int(argv[1]))
else:
run_server()