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 @@
Share
Inspect
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) {