diff --git a/android-resources/config.xml b/android-resources/config.xml new file mode 100644 index 000000000..652479092 --- /dev/null +++ b/android-resources/config.xml @@ -0,0 +1,27 @@ + + + CodeWorld App + + CodeWorld App + + + CodeWorld + + + + + + + + + + + + + + + + + + + diff --git a/android-resources/res/icon/android/mipmap-hdpi/codeworld.png b/android-resources/res/icon/android/mipmap-hdpi/codeworld.png new file mode 100644 index 000000000..7c5398238 Binary files /dev/null and b/android-resources/res/icon/android/mipmap-hdpi/codeworld.png differ diff --git a/android-resources/res/icon/android/mipmap-mdpi/codeworld.png b/android-resources/res/icon/android/mipmap-mdpi/codeworld.png new file mode 100644 index 000000000..fb37ce204 Binary files /dev/null and b/android-resources/res/icon/android/mipmap-mdpi/codeworld.png differ diff --git a/android-resources/res/icon/android/mipmap-xhdpi/codeworld.png b/android-resources/res/icon/android/mipmap-xhdpi/codeworld.png new file mode 100644 index 000000000..4ce28182a Binary files /dev/null and b/android-resources/res/icon/android/mipmap-xhdpi/codeworld.png differ diff --git a/android-resources/res/icon/android/mipmap-xxhdpi/codeworld.png b/android-resources/res/icon/android/mipmap-xxhdpi/codeworld.png new file mode 100644 index 000000000..d9ef186b7 Binary files /dev/null and b/android-resources/res/icon/android/mipmap-xxhdpi/codeworld.png differ diff --git a/android-resources/res/icon/android/mipmap-xxxhdpi/codeworld.png b/android-resources/res/icon/android/mipmap-xxxhdpi/codeworld.png new file mode 100644 index 000000000..2a95e0d42 Binary files /dev/null and b/android-resources/res/icon/android/mipmap-xxxhdpi/codeworld.png differ diff --git a/android-resources/www/css/index.css b/android-resources/www/css/index.css new file mode 100644 index 000000000..5cbc9c603 --- /dev/null +++ b/android-resources/www/css/index.css @@ -0,0 +1,105 @@ +* { + -webkit-tap-highlight-color: rgba(0,0,0,0); /* make transparent link selection, adjust last value opacity 0 to 1.0 */ +} + +body { + -webkit-touch-callout: none; /* prevent callout to copy image, etc when tap to hold */ + -webkit-text-size-adjust: none; /* prevent webkit from resizing text to fit */ + -webkit-user-select: none; /* prevent copy paste, to allow, change 'none' to 'text' */ + background-color:#E4E4E4; + background-image:linear-gradient(top, #A7A7A7 0%, #E4E4E4 51%); + background-image:-webkit-linear-gradient(top, #A7A7A7 0%, #E4E4E4 51%); + background-image:-ms-linear-gradient(top, #A7A7A7 0%, #E4E4E4 51%); + background-image:-webkit-gradient( + linear, + left top, + left bottom, + color-stop(0, #A7A7A7), + color-stop(0.51, #E4E4E4) + ); + background-attachment:fixed; + font-family:'HelveticaNeue-Light', 'HelveticaNeue', Helvetica, Arial, sans-serif; + font-size:12px; + height:100%; + margin:0px; + padding:0px; + text-transform:uppercase; + width:100%; +} + +/* Portrait layout (default) */ +.app { + position:absolute; /* position in the center of the screen */ + left:50%; + top:30%; + height:50px; /* text area height */ + width:225px; /* text area width */ + text-align:center; +} + +canvas { + position: relative; + left: -50%; + top: -50%; +} + +.app-inner { + position: relative; + left: -50%; + top: -50%; +} + +/* Landscape layout (with min-width) */ +@media screen and (min-aspect-ratio: 1/1) and (min-width:400px) { + .app { + background-position:left center; + padding:75px 0px 75px 170px; /* padding-top + padding-bottom + text area = image height */ + margin:-90px 0px 0px -198px; /* offset vertical: half of image height */ + /* offset horizontal: half of image width and text area width */ + } +} + +h1 { + font-size:24px; + font-weight:normal; + margin:0px; + overflow:visible; + padding:0px; + text-align:center; +} + +.event { + border-radius:4px; + -webkit-border-radius:4px; + color:#FFFFFF; + font-size:12px; + margin:0px 30px; + padding:2px 0px; +} + +.event.listening { + background-color:#333333; + display:block; +} + +.event.received { + background-color:#4B946A; + display:none; +} + +@keyframes fade { + from { opacity: 1.0; } + 50% { opacity: 0.4; } + to { opacity: 1.0; } +} + +@-webkit-keyframes fade { + from { opacity: 1.0; } + 50% { opacity: 0.4; } + to { opacity: 1.0; } +} + +.blink { + animation:fade 3000ms infinite; + -webkit-animation:fade 3000ms infinite; +} diff --git a/android-resources/www/index.html b/android-resources/www/index.html new file mode 100644 index 000000000..4b7425104 --- /dev/null +++ b/android-resources/www/index.html @@ -0,0 +1,99 @@ + + + + + CodeWorld + + + + + + + + + + diff --git a/base.sh b/base.sh index 0ffd8d49c..21f31a586 100755 --- a/base.sh +++ b/base.sh @@ -19,6 +19,14 @@ export PATH=$BUILD/bin:$PATH export LANG=${LANG:-C.UTF-8} export PREFIX=$BUILD +export ANDROID_HOME=$BUILD/Android/Sdk +export PATH=$BUILD/Android/Sdk/tools:$PATH +export PATH=$BUILD/Android/Sdk/platform-tools:$PATH +export PATH=$BUILD/Android/Sdk/tools/bin:$PATH +export PATH=$BUILD/Android/Sdk/tools/lib:$PATH + +export PATH=$BUILD/Cordova/node_modules/cordova/bin:$PATH + function run { OLD_PWD=$PWD cd $1 @@ -38,3 +46,4 @@ function run { function cabal_install { cabal install --force-reinstalls --global --prefix=$BUILD $@ } + diff --git a/build.sh b/build.sh index 526742615..aeafe99c7 100755 --- a/build.sh +++ b/build.sh @@ -18,6 +18,9 @@ cwd=$(pwd) source base.sh +# Create Cordova template +run ./android-template cordova build android + run . cabal update # Install the codeworld-base and codeworld-api packages @@ -53,3 +56,4 @@ run . cabal_install ./codeworld-server \ # Build the JavaScript client code for FunBlocks, the block-based UI. run . cabal_install --ghcjs ./funblocks-client + diff --git a/codeworld-server/codeworld-server.cabal b/codeworld-server/codeworld-server.cabal index 65acbc532..815d8d237 100644 --- a/codeworld-server/codeworld-server.cabal +++ b/codeworld-server/codeworld-server.cabal @@ -21,6 +21,7 @@ Executable codeworld-server base64-bytestring, bytestring, codeworld-compiler, + containers, cryptonite, data-default, directory, @@ -35,6 +36,8 @@ Executable codeworld-server regex-tdfa, snap-core, snap-server, + strict, + tagsoup, temporary, text, unix diff --git a/codeworld-server/src/AndroidExport.hs b/codeworld-server/src/AndroidExport.hs new file mode 100644 index 000000000..a4cd3da29 --- /dev/null +++ b/codeworld-server/src/AndroidExport.hs @@ -0,0 +1,83 @@ +{-# LANGUAGE OverloadedStrings #-} + +{- + Copyright 2017 The CodeWorld Authors. All rights reserved. + + 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. +-} + +module AndroidExport where + +import Data.Maybe +import qualified Data.Map as M +import System.Process +import System.Directory +import System.FilePath +import qualified System.IO.Strict as ST +import Text.HTML.TagSoup + +import Util + +buildAndroid :: BuildMode -> ProgramId -> AppProps -> IO () +buildAndroid mode programId appProps = do + let appName = fromJust $ M.lookup "appName" appProps + initCordovaProject mode programId + copySource mode programId + setAppName mode programId appName + buildApk mode programId + return () + +initCordovaProject :: BuildMode -> ProgramId -> IO () +initCordovaProject mode programId = do + let rootDir = androidRootDir mode + checkIfRootExists <- doesDirectoryExist rootDir + if not checkIfRootExists + then do + createDirectory $ androidRootDir mode + else return () + let buildDir = androidBuildDir mode programId + checkIfBuildExists <- doesDirectoryExist buildDir + if not checkIfBuildExists + then do + checkIfParentExists <- doesDirectoryExist $ androidRootDir mode sourceParent programId + if not checkIfParentExists + then do + createDirectory $ androidRootDir mode sourceParent programId + copyDirIfExists "android-template" (androidRootDir mode sourceBase programId) + else return () + else return () + +copySource :: BuildMode -> ProgramId -> IO () +copySource mode programId = + copyFile + (buildRootDir mode targetFile programId) + (androidBuildDir mode programId "www" "js" "runjs.js") + +setAppName :: BuildMode -> ProgramId -> String -> IO () +setAppName mode programId appName = do + let configFileName = androidBuildDir mode programId "config.xml" + configContents <- ST.readFile configFileName + let tagSoup = parseTags configContents + let newNameTag = [TagOpen "name" [], TagText appName] + writeFile configFileName (renderTags $ newSoup tagSoup newNameTag) + where newSoup soup insertTag = takeWhile nameId soup + ++ insertTag + ++ drop 2 (dropWhile nameId soup) + nameId = (~/= (""::String)) + +buildApk :: BuildMode -> ProgramId -> IO () +buildApk mode programId = do + currwd <- getCurrentDirectory + setCurrentDirectory $ androidBuildDir mode programId + readProcess "cordova" ["build", "android"] "" + setCurrentDirectory currwd diff --git a/codeworld-server/src/Main.hs b/codeworld-server/src/Main.hs index 9f03ad557..9632c3ebf 100644 --- a/codeworld-server/src/Main.hs +++ b/codeworld-server/src/Main.hs @@ -22,6 +22,7 @@ module Main where import Compile +import AndroidExport import Control.Applicative import Control.Monad import Control.Monad.Trans @@ -33,6 +34,7 @@ import qualified Data.ByteString.Lazy as LB import Data.Char (isSpace) import Data.List import Data.Maybe +import qualified Data.Map.Strict as M import Data.Monoid import qualified Data.Text as T import qualified Data.Text.IO as T @@ -120,6 +122,8 @@ site clientId = ("shareContent", shareContentHandler clientId), ("moveProject", moveProjectHandler clientId), ("compile", compileHandler), + ("exportAndroid", exportAndroidHandler), + ("getAndroid", getAndroidHandler), ("saveXMLhash", saveXMLHashHandler), ("loadXML", loadXMLHandler), ("loadSource", loadSourceHandler), @@ -319,7 +323,7 @@ compileHandler = do writeDeployLink mode deployId programId compileIfNeeded mode programId unless success $ modifyResponse $ setResponseCode 500 - modifyResponse $ setContentType "text/plain" + modifyResponse $ setContentType "application/json" let result = CompileResult (unProgramId programId) (unDeployId deployId) writeLBS (encode result) @@ -354,6 +358,31 @@ runHandler = do modifyResponse $ setContentType "text/javascript" serveFile (buildRootDir mode targetFile programId) +exportAndroidHandler :: Snap() +exportAndroidHandler = do + mode <- getBuildMode + Just source <- getParam "source" + maybeAppName <- getParam "appName" + let appName = BC.unpack $ fromMaybe (BC.pack "CodeWorld App") maybeAppName + let programId = sourceToProgramId source + deployId = sourceToDeployId source + success <- liftIO $ do + ensureProgramDir mode programId + B.writeFile (buildRootDir mode sourceFile programId) source + writeDeployLink mode deployId programId + compileIfNeeded mode programId + unless success $ modifyResponse $ setResponseCode 500 + let appProps = M.fromList [("appName", appName)] + liftIO $ buildAndroid mode programId appProps + let result = CompileResult (unProgramId programId) (unDeployId deployId) + writeLBS (encode result) + +getAndroidHandler :: Snap() +getAndroidHandler = do + mode <- getBuildMode + programId <- getHashParam True mode + serveFileAs "application/vnd.android.package-archive" (apkFile mode programId) + runMessageHandler :: Snap () runMessageHandler = do mode <- getBuildMode diff --git a/codeworld-server/src/Util.hs b/codeworld-server/src/Util.hs index 69c3a706c..5b5674f57 100644 --- a/codeworld-server/src/Util.hs +++ b/codeworld-server/src/Util.hs @@ -30,6 +30,7 @@ import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy as LB import Data.Maybe import Data.Monoid +import qualified Data.Map.Strict as M import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as T @@ -41,13 +42,15 @@ import System.Posix.Files import Model -newtype BuildMode = BuildMode String deriving Eq newtype ProgramId = ProgramId { unProgramId :: Text } deriving Eq +newtype BuildMode = BuildMode String deriving Eq newtype ProjectId = ProjectId { unProjectId :: Text } deriving Eq newtype DeployId = DeployId { unDeployId :: Text } deriving Eq newtype DirId = DirId { unDirId :: Text} deriving Eq newtype ShareId = ShareId { unShareId :: Text } deriving Eq +type AppProps = M.Map String String + autocompletePath :: FilePath autocompletePath = "web/codeworld-base.txt" @@ -57,6 +60,19 @@ clientIdPath = "web/clientId.txt" buildRootDir :: BuildMode -> FilePath buildRootDir (BuildMode m) = "data" m "user" +androidRootDir :: BuildMode -> FilePath +androidRootDir (BuildMode m) = "data" m "android" + +androidBuildDir :: BuildMode -> ProgramId -> FilePath +androidBuildDir mode programId = androidRootDir mode sourceBase programId + +apkFile :: BuildMode -> ProgramId -> FilePath +apkFile mode programId = + androidRootDir mode + sourceBase programId + "platforms" "android" "build" "outputs" "apk" + "android-debug" <.> "apk" + shareRootDir :: BuildMode -> FilePath shareRootDir (BuildMode m) = "data" m "share" @@ -66,6 +82,9 @@ projectRootDir (BuildMode m) = "data" m "projects" deployRootDir :: BuildMode -> FilePath deployRootDir (BuildMode m) = "data" m "deploy" +sourceParent :: ProgramId -> FilePath +sourceParent (ProgramId p) = let s = T.unpack p in take 3 s + sourceBase :: ProgramId -> FilePath sourceBase (ProgramId p) = let s = T.unpack p in take 3 s s diff --git a/install.sh b/install.sh index 87e0cefe4..d2392dd1e 100755 --- a/install.sh +++ b/install.sh @@ -52,6 +52,11 @@ then run . sudo yum install -y patch run . sudo yum install -y autoconf run . sudo yum install -y automake + + # Needed for Cordova + run . sudo yum install -y java-1.8.0-openjdk + # TODO: Gradle for yum + elif type apt-get > /dev/null 2> /dev/null then echo Detected 'apt-get': Installing packages from there. @@ -84,6 +89,11 @@ then run . sudo apt-get install -y autoconf run . sudo apt-get install -y automake run . sudo apt-get install -y libtinfo-dev + + # Needed for Cordova + run . sudo apt-get install -y default-jdk + run . sudo apt-get install -y gradle + elif type zypper > /dev/null 2> /dev/null then echo Detected 'zypper': Installing packages from there. @@ -113,9 +123,13 @@ then run . sudo zypper -n install patch run . sudo zypper -n install autoconf run . sudo zypper -n install automake + + # Needed for Cordova + # TODO: OpenJDK, JRE and Gradle for Zypper else echo "WARNING: Could not find package manager." echo "Make sure necessary packages are installed." + fi # Choose the right GHC download @@ -169,7 +183,6 @@ run $BUILD cabal_install ./ghcjs run $BUILD rm -rf ghcjs run . ghcjs-boot --dev --ghcjs-boot-dev-branch ghc-8.0 --shims-dev-branch ghc-8.0 --no-prof --no-haddock -run $BUILD rm -rf downloads # Install and build CodeMirror editor. @@ -187,5 +200,30 @@ function build_codemirror { run $BUILD/CodeMirror build_codemirror +# Install Android SDK + +run $DOWNLOADS wget https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip +run $BUILD unzip $DOWNLOADS/sdk-tools-linux-3859397.zip +run $BUILD mkdir -p Android/Sdk +run $BUILD mv tools Android/Sdk/ +run $BUILD yes | ./Android/Sdk/tools/android update + +# Install Apache Cordova +run $BUILD mkdir Cordova +run $BUILD/Cordova npm install -s cordova + +# Create a fresh template project and copy in files +run . cordova create android-template +run android-template cordova platform add android +run . rm -rf android-template/www android-template/res +run . cp android-resources/* android-template/ -rf + +# Install the Android SDK +run $BUILD sdkmanager "build-tools;26.0.0" + +## Remove downloads directory +run $BUILD rm -rf downloads + # Go ahead and run a first build, which installs more local packages. ./build.sh + diff --git a/web/css/codeworld.css b/web/css/codeworld.css index ccf232820..c03dded6d 100644 --- a/web/css/codeworld.css +++ b/web/css/codeworld.css @@ -157,6 +157,15 @@ body { cursor: pointer; } +button { + border: none; +} + +button:disabled, +button[disabled] { + background-color: #cccccc !important; +} + .cw-button { border-radius: 4px; cursor: pointer; diff --git a/web/env.html b/web/env.html index 8c2f4502f..0134b08b8 100644 --- a/web/env.html +++ b/web/env.html @@ -97,6 +97,7 @@   Stop +   Run diff --git a/web/js/codeworld.js b/web/js/codeworld.js index 634e712b0..d43e890e0 100644 --- a/web/js/codeworld.js +++ b/web/js/codeworld.js @@ -690,11 +690,13 @@ function run(hash, dhash, msg, error) { runner.contentWindow.location.replace(loc); if (!!navigator.mediaDevices && !!navigator.mediaDevices.getUserMedia) { document.getElementById('startRecButton').style.display = ''; + document.getElementById('exportAndroidButton').style.display = ''; } } else { runner.contentWindow.location.replace('about:blank'); document.getElementById('runner').style.display = 'none'; document.getElementById('startRecButton').style.display = 'none'; + document.getElementById('exportAndroidButton').style.display = 'none'; } if (hash || msg) { @@ -731,6 +733,94 @@ function goto(line, col) { codeworldEditor.focus(); } +function exportAndroid() { + sweetAlert({ + title: "App Information", + text: "App Name", + type: "input", + showCancelButton: true, + confirmButtonText: "Build App", + closeOnConfirm: false, + inputPlaceholder: "CodeWorld App" + }, + function(inputValue){ + if (inputValue === false) { + return false; + } + if (inputValue === "") { + swal.showInputError("Please enter a name for your app"); + return false; + } + compileAndExportAndroid({ + appName: inputValue, + }); + sweetAlert({ + title: "Please Wait", + text: "Your app is being built", + imageUrl: "https://upload.wikimedia.org/wikipedia/commons/b/b1/Loading_icon.gif", + showConfirmButton: false, + allowOutsideClick: false, + allowEscapeKey: false + }); + }); +} + +function compileAndExportAndroid(appProps) { + var src = window.codeworldEditor.getValue(); + var data = new FormData(); + data.append('source', src); + data.append('mode', window.buildMode); + for(var prop in appProps) { + data.append(prop, appProps[prop]); + } + document.getElementById('exportAndroidButton').disabled = true; + + sendHttp('POST', 'exportAndroid', data, function(request) { + if(request.status != 200) { + sweetAlert("Android Build Failed", "Something went wrong!", "error"); + document.getElementById('exportAndroidButton').disabled = false; + return; + } + var response = JSON.parse(request.response); + var hash = response.hash; + + var data = new FormData(); + data.append('hash', hash); + data.append('mode', window.buildMode); + var props = {}; + props.responseType = "blob"; + + sendHttpWithProps('POST', 'getAndroid', data, props, function(request) { + if(request.status != 200) { + sweetAlert("Android Fetch Failed", "Something went wrong!", "error"); + document.getElementById('exportAndroidButton').disabled = false; + return; + } + swal("App Built!", "Your CodeWorld app will now be downloaded", "success"); + + var blob = request.response; + var d = new Date(); + var filename = 'codeworld_app_' + + d.toDateString().split(' ').join('_') + '_' + + d.getHours() +':'+ d.getMinutes() +':'+ d.getSeconds() + +'.apk'; + var a = document.createElement("a"); + document.body.appendChild(a); + a.style = "display: none"; + + var url = window.URL.createObjectURL(blob); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); + + a.remove(); + document.getElementById('exportAndroidButton').disabled = false; + }); + + }); +} + function compile() { run('', '', 'Compiling...', false); diff --git a/web/js/codeworld_shared.js b/web/js/codeworld_shared.js index 66911fe87..de7b1ca22 100644 --- a/web/js/codeworld_shared.js +++ b/web/js/codeworld_shared.js @@ -39,6 +39,23 @@ function sendHttp(method, url, body, callback) { request.send(body); } +function sendHttpWithProps(method, url, body, properties, callback) { + var request = new XMLHttpRequest(); + + if (callback) { + request.onreadystatechange = function() { + if (request.readyState == 4) callback(request); + }; + } + + request.open(method, url, true); + for(var prop in properties) { + request[prop] = properties[prop]; + } + + request.send(body); +} + function registerStandardHints(successFunc) { function createHint(line, wordStart, wordEnd, cname) {