Last active
August 29, 2015 14:13
-
-
Save jonnolen/1a20687650681d5ded30 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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. | |
* | |
* Author: Bruno Bowden | |
* | |
* | |
* Description: | |
* This is gradle script for j2objc (Java to Objective-C translator). This allows you to | |
* share code between Android and iOS plaforms. The plugin works well with Android Studio | |
* (which uses Gradle by default). The system works best if you have a large shared codebase | |
* with NO Android dependencies (built with gradle). Have your main Android application depend | |
* on that shared project. Within Xcode, you add the translated code and compile it. | |
* j2objc by design doesn't translate UI code and expects you to write that in the | |
* native language of each platform. | |
* | |
* Please submit gradle plugin patches here: | |
* https://gist.github.com/brunobowden/58d6e311ab96760fc371 | |
* | |
* j2objc: | |
* https://github.com/google/j2objc/wiki | |
* | |
* | |
* Basic Usage: | |
* 1) Download a j2objc release and unzip it to a directory: | |
* https://github.com/google/j2objc/releases | |
* 2) Copy this file (j2objc.gradle) next to the build.gradle file of the project to be translated | |
* 3) Copy and paste the following to your project's build.gradle file | |
* (it's currently best to run "gradlew clean" when changing this configuration) | |
apply from: 'j2objc.gradle' | |
j2objcConfig { | |
// MODIFY to where your unzipped j2objc directory is located | |
j2objcHome null // e.g. "${projectDir}/../../j2objc or absolute path | |
// MODIFY to where generated objc files should be put for Xcode project | |
// NOTE these files should be checked in to the repository and updated as needed | |
// NOTE this should contain ONLY j2objc generated files, other contents will be deleted | |
destDir null // e.g. "${projectDir}/../Xcode/j2objc-generated" | |
// FLAGS | |
// Uncomment each of the following to use. The flags are set to the plugin defaults. | |
// Translate flags for j2objc | |
// This is where to add "-classpath" for jar dependencies, note that the | |
// translated and compiled libraries also needs to be added to compileFlags as well | |
// translateFlags "--no-package-directories" | |
// Compile flags for j2objcc | |
// compileFlags "-ObjC -ljre_emul -ljunit -lmockito" | |
// Test flags for translated test | |
// testFlags "" | |
// J2objc libraries, must be available in $J2OBJC_HOME/lib/..., e.g. add guava here | |
// translateJ2objcLibs "junit-4.10.jar", "mockito-core-1.9.5.jar" | |
// FILTERS | |
// Filter on files to translate or test, ignored if null | |
// Matches on path, not filename or Class name | |
// Must match IncludeRegex and NOT match ExcludeRegex | |
// You can remove the "()" in "*()/" - it's a workaround to avoid closing the '/*' comment | |
// translateExcludeRegex ".*()/src/(main|test)/java/com/example/EXCLUDE_DIR/.*" | |
// translateIncludeRegex ".*()/TranslateOnlyMeAnd(|Test)\\.java" | |
// Test filter also applies the translate filter | |
// testExcludeRegex ".*()/(IgnoreOneTest|IgnoreTwoTest)\\.java" | |
// testIncludeRegex ".*()/OnlyThisTest\\.java" | |
// Helpful checks that you can disable | |
// boolean filenameCollisionCheck = true | |
// boolean testExecutedCheck = true | |
} | |
* 4) Run command to generate and copyfiles. This will only succeed if all steps succeed. | |
* | |
* $ gradlew <SHARED_PROJECT>:j2objcCopy | |
* | |
* Commands: | |
* Each one depends on the previous command | |
* j2objcTranslate - Translate all files to Objective-C, builds java or Android project if found | |
* j2objcCompile - Compile Objective-C files and build Objective-C binary (named 'runner') | |
* j2objcTest - Run all java tests against the Objective-C binary | |
* j2objcCopy - Copy generated Objective-C files to Xcode project | |
* | |
* Note that you can use the Gradle shorthand of "$ gradlew jCop" to do the j2objcCopy task. | |
* The other shorthand expressions are "jTr", "jCom" and "jTe" | |
* | |
* Thanks to Peter Niederwieser and 'bigguy' from Gradleware | |
*/ | |
// TODO: add plugin tests | |
// TODO: 'apply' should be done in build.gradle, move there as this becomes a proper plugin | |
apply plugin: j2objc | |
class J2objcPluginExtension { | |
// Where to copy generated files (excludes test code and executable) | |
String destDir = null | |
// Path to j2objc distribution | |
String j2objcHome = null | |
// Additional command line flags for each stage, last thing added to command: | |
String translateFlags = "--no-package-directories" | |
String compileFlags = "-ObjC -ljre_emul -ljunit -lmockito" | |
String testFlags = "" | |
// Additional libraries that are part of the j2objc release | |
// TODO: warn if different versions than testCompile from Java plugin | |
String[] translateJ2objcLibs = ["junit-4.10.jar", "mockito-core-1.9.5.jar"] | |
// See "FILTER" comment above | |
// TODO: consider moving to include(s) / exclude(s) structure as used for filetree | |
String translateExcludeRegex = null | |
String translateIncludeRegex = null | |
String testExcludeRegex = null | |
String testIncludeRegex = null | |
boolean filenameCollisionCheck = true | |
boolean testExecutedCheck = true | |
} | |
class J2objcUtils { | |
// TODO: ideally bundle j2objc binaries with plugin jar and load at runtime with | |
// TODO: ClassLoader.getResourceAsStream(), extract, chmod and then execute | |
static isWindows() { | |
return System.getProperty("os.name").toLowerCase().contains("windows") | |
} | |
static def j2objcHome(Project proj) { | |
def result = proj.j2objcConfig.j2objcHome | |
if (result == null) { | |
def message = | |
"j2objcHome not set, this should be configured in the parent gradle file with " + | |
"this syntax:\n" + | |
"\n" + | |
"j2objcConfig {\n" + | |
" j2objcHome null // e.g. \"\${projectDir}/../j2objc\"\n" + | |
" ..." | |
"}\n" + | |
"\n" + | |
"It must be the path of the unzipped j2objc release. Download releases here:\n" + | |
"https://github.com/google/j2objc/releases" | |
throw new InvalidUserDataException(message) | |
} | |
if (!proj.file(result).exists()) { | |
def message = "j2objc directory not found, expected location: ${result}" | |
throw new InvalidUserDataException(message) | |
} | |
return result | |
} | |
// Filters a FileCollection by path: | |
// must match includeRegex and NOT match excludeRegex, regex ignored if null | |
static def fileFilter(FileCollection files, String includeRegex, String excludeRegex) { | |
return files.filter { file -> | |
if (includeRegex == null) | |
return true | |
return file.path.matches(includeRegex) | |
}.filter { file -> | |
if (excludeRegex == null) | |
return true | |
return ! file.path.matches(excludeRegex) | |
} | |
} | |
// Reads both settings from translateFlags (last flag takes precedence) | |
// --prefixes dir/prefixes.properties --prefix com.ex.dir=Short --prefix com.ex.dir2=Short2 | |
static def prefixProperties(Project proj) { | |
Properties props = new Properties() | |
def matcher = proj.j2objcConfig.translateFlags =~ /--prefix(|es) +([^-]+)/ | |
def start = 0 | |
while (matcher.find(start)) { | |
start = matcher.end() | |
def newProps = new Properties() | |
if (matcher.group(1) == "es") { | |
// --prefixes | |
// remove trailing space that confuses FileInputStream | |
def prefixesPath = matcher.group(2).trim() | |
newProps.load(new FileInputStream(proj.file(prefixesPath).path)) | |
} else { | |
// --prefix (this will be a single property) | |
newProps.load(new StringReader(matcher.group(2))); | |
} | |
props.putAll(newProps) | |
} | |
// for (key in props.keys()) { | |
// println key + ": " + props.getProperty(key) | |
// } | |
return props | |
} | |
static def filenameCollisionCheck(FileCollection files) { | |
def nameMap = [:] | |
for (file in files) { | |
if (nameMap.containsKey(file.name)) { | |
def prevFile = nameMap.get(file.name) | |
def message = | |
"File name collision detected:\n" + | |
" " + prevFile.path + "\n" + | |
" " + file.path + "\n" + | |
"\n" + | |
"To disable this check (which may overwrite output files):\n" + | |
"j2objcConfig {\n" + | |
" filenameCollisionCheck false\n" + | |
" ...\n" + | |
"}\n" | |
throw new InvalidUserDataException(message) | |
} | |
nameMap.put(file.name, file) | |
} | |
} | |
} | |
// TODO: do translations and other tasks incrementally | |
class J2objcTranslateTask extends DefaultTask { | |
@InputFiles FileCollection srcFiles | |
@OutputDirectory File destDir | |
@TaskAction | |
def translate() { | |
def isWindows = J2objcUtils.isWindows() | |
def j2objcHome = J2objcUtils.j2objcHome(getProject()) | |
def j2objcExec = j2objcHome + "/j2objc" | |
def firstArgs = "" | |
if (isWindows) { | |
j2objcExec = "java" | |
// "-Xbootclasspath:${j2objcHome}/lib/jre_emul.jar" | |
firstArgs = "-jar ${j2objcHome}/lib/j2objc.jar" | |
} | |
// Clean build directory to delete files that are no longer generated | |
// Print command before operation to be helpful log if operation fails | |
println "Deleting j2objc build dir: " + destDir.path | |
project.delete destDir.path | |
srcFiles = J2objcUtils.fileFilter(srcFiles, | |
project.j2objcConfig.translateIncludeRegex, | |
project.j2objcConfig.translateExcludeRegex) | |
if (project.j2objcConfig.filenameCollisionCheck) { | |
J2objcUtils.filenameCollisionCheck(srcFiles) | |
} | |
try { | |
project.exec { | |
executable j2objcExec | |
args firstArgs.split() | |
args "-d", "${destDir}" | |
args "-sourcepath", project.file("src/main/java").path | |
project.j2objcConfig.translateJ2objcLibs.each { library -> | |
def libPath = J2objcUtils.j2objcHome(getProject()) + "/lib/" + library | |
args "-classpath", project.file(libPath).path | |
} | |
args "${project.j2objcConfig.translateFlags}".split() | |
srcFiles.each { file -> | |
args file.path | |
} | |
} | |
} catch (e) { | |
// Warn and explain typical case of Android application, then rethrow error | |
if (! project.plugins.findPlugin('java')) { | |
if (project.j2objcConfig.translateIncludeRegex == null && | |
project.j2objcConfig.translateIncludeRegex == null) { | |
def message = | |
"\n" + | |
"J2objc by design will not translate your entire Android application.\n" + | |
"It focuses on the business logic, the UI should use native code:\n" + | |
" https://github.com/google/j2objc/blob/master/README.md\n" + | |
"\n" + | |
"The best practice over time is to separate out the shared code to a\n" + | |
"distinct java project with NO android dependencies.\n" + | |
"\n" + | |
"As a step towards that, you can configure the j2objc plugin to only\n" + | |
"translate a subset of files that don't depend on Android. The settings\n" + | |
"are regular expressions on the file path. For example:\n" + | |
"\n" + | |
"j2objcConfig {\n" + | |
" translateIncludeRegex \".*/src/main/java/com/example/translateThisDir/.*\n" + | |
" translateExcludeRegex \".*/(CantTranslateYet|NotThisEither)\\.java\"\n" + | |
" ...\n" + | |
"}\n" | |
print message | |
} | |
} | |
throw e | |
} | |
} | |
} | |
class J2objcCompileTask extends DefaultTask { | |
@InputDirectory File srcDir | |
@OutputFile File destFile | |
@TaskAction | |
def compile() { | |
if (J2objcUtils.isWindows()) { | |
throw new InvalidUserDataException( | |
"Windows only supports j2objc translation. To compile and test code, " + | |
"please develop on a Mac."); | |
} | |
def binary = J2objcUtils.j2objcHome(getProject()) + "/j2objcc" | |
// TODO: copy / reference test resources | |
// No include / exclude regex as unlikely for compile to fail after successful translation | |
println "Compiling test binary: " + destFile.path | |
project.exec { | |
executable binary | |
args "-I${srcDir}" | |
args "-o", "${destFile.path}" | |
args "${project.j2objcConfig.compileFlags}".split() | |
def srcFiles = project.files(project.fileTree( | |
dir: srcDir, includes: ["**/*.h", "**/*.m"])) | |
srcFiles.each { file -> | |
args file.path | |
} | |
} | |
} | |
} | |
class J2objcTestTask extends DefaultTask { | |
@InputFile File srcFile // testrunner | |
@InputFiles FileCollection srcFiles // *Test.java | |
// TODO: is it possible for the task to declare an output for "up to date" logic (e.g. destDir) | |
@TaskAction | |
def test() { | |
// Generate list of tests from the source java files | |
// src/test/java/com/example/dir/ClassTest.java => "com.example.dir.ClassTest" | |
// Already filtered by ".*Test.java" before it arrives here | |
srcFiles = J2objcUtils.fileFilter(srcFiles, | |
project.j2objcConfig.translateIncludeRegex, | |
project.j2objcConfig.translateExcludeRegex) | |
srcFiles = J2objcUtils.fileFilter(srcFiles, | |
project.j2objcConfig.testIncludeRegex, | |
project.j2objcConfig.testExcludeRegex) | |
// Generate Test Names | |
def prefixesProperties = J2objcUtils.prefixProperties(project) | |
def testNames = srcFiles.collect { file -> | |
def testName = project.relativePath(file) | |
.replace('src/test/java/', '') | |
.replace('/', '.') | |
.replace('.java', '') | |
// Need to translate test name according to prefixes | |
def namespaceRegex = /^(([^.]+\.)+)[^.]+$/ | |
def matcher = testName =~ namespaceRegex | |
if (matcher.find()) { | |
def namespace = matcher[0][1] // com.example.dir. | |
def namespaceChopped = namespace[0..-2] // com.example.dir | |
if (prefixesProperties.containsKey(namespaceChopped)) { | |
def value = prefixesProperties.getProperty(namespaceChopped) | |
testName = testName.replace(namespace, value) | |
} | |
} | |
return testName | |
} | |
def binary = "${srcFile.path}" | |
println "Testing with: " + srcFile.path | |
def output = new ByteArrayOutputStream() | |
project.exec { | |
executable binary | |
args "org.junit.runner.JUnitCore" | |
args "${project.j2objcConfig.testFlags}".split() | |
testNames.each { testName -> | |
args testName | |
} | |
standardOutput = output; | |
} | |
// 0 tests => warn by default | |
def out = output.toString() | |
print out | |
if (project.j2objcConfig.testExecutedCheck) { | |
if (out.contains("OK (0 tests)")) { | |
def message = | |
"Zero unit tests were run. Tests are strongly encouraged with J2objc:\n" + | |
"\n" + | |
"To disable this check (which is against best practice):\n" + | |
"j2objcConfig {\n" + | |
" testExecutedCheck false\n" + | |
" ...\n" + | |
"}\n" | |
throw new InvalidUserDataException(message) | |
} | |
} | |
} | |
} | |
class j2objcCopyTask extends DefaultTask { | |
@InputDirectory File srcDir | |
// TODO: is it possible for the task to declare an output for "up to date" logic (e.g. destDir) | |
@TaskAction | |
def destCopy() { | |
if (project.j2objcConfig.destDir == null) { | |
def message = "You must configure the location where the generated files are " + | |
"copied for Xcode. This is done in your build.gradle, for example:\n" + | |
"\n" + | |
"j2objcConfig {\n" + | |
" destDir null // e.g. \"\${" + "projectDir}/../Xcode/j2objc-generated\"\n" + | |
" ..." | |
"}" | |
throw new InvalidUserDataException(message) | |
} | |
// Warn if deleting non-generated objc files from destDir | |
def destDir = project.file(project.j2objcConfig.destDir) | |
def destFiles = project.files(project.fileTree( | |
dir: destDir, excludes: ["**/*.h", "**/*.m"])) | |
destFiles.each { file -> | |
def message = | |
"Unexpected files in destDir - this folder should contain ONLY j2objc\n" + | |
"generated files Objective-C. The folder contents are deleted to remove\n" + | |
"files that are nolonger generated. Please check the directory and remove\n" + | |
"any files that don't end with Objective-C extensions '.h' and '.m'.\n" + | |
"destDir: ${project.j2objcConfig.destDir}\n" + | |
"Unexpected file for deletion: ${file.path}" | |
throw new InvalidUserDataException(message) | |
} | |
// TODO: better if this was a sync operation as it does deletes automatically | |
println "Deleting destDir to fill with generated objc files... " + destDir.path | |
project.delete destDir | |
// TODO: setting to control whether to copy test files | |
project.copy { | |
includeEmptyDirs = false | |
from srcDir | |
into destDir | |
// Don't copy the test code and executable | |
exclude "**/*Test.h" | |
exclude "**/*Test.m" | |
exclude "**/testrunner" | |
} | |
} | |
} | |
class j2objc implements Plugin<Project> { | |
void apply(Project project) { | |
project.with { | |
// TODO: dependency on project.j2objcConfig, so any setting change | |
// TODO: invalidates all tasks and causes a full rebuild | |
project.extensions.create("j2objcConfig", J2objcPluginExtension) | |
// Produces a modest amount of output | |
logging.captureStandardOutput LogLevel.INFO | |
// Dependency may be added in project.plugins.withType for Java or Android plugin | |
tasks.create(name: "j2objcTranslate", type: J2objcTranslateTask) { | |
description "Translates all the java source files in to Objective-C using j2objc" | |
srcFiles = files( | |
fileTree(dir: projectDir, | |
include: "**/*.java", | |
exclude: project.relativePath(buildDir))) | |
destDir = file("${buildDir}/j2objc") | |
println "destDir: " + destDir + ", " + destDir.path | |
} | |
project.tasks.create(name: "j2objcCompile", type: J2objcCompileTask, | |
dependsOn: 'j2objcTranslate') { | |
description "Compiles the j2objc generated Objective-C code to 'testrunner' binary" | |
srcDir = file("${buildDir}/j2objc") | |
destFile = file("${buildDir}/j2objc/testrunner") | |
} | |
project.tasks.create(name: "j2objcTest", type: J2objcTestTask, | |
dependsOn: 'j2objcCompile') { | |
description 'Runs all tests in the generated Objective-C code' | |
srcFile = file("${buildDir}/j2objc/testrunner") | |
// Doesn't use 'buildDir' as missing full path with --no-package-directories flag | |
srcFiles = files(fileTree(dir: projectDir, includes: ["**/*Test.java"])) | |
} | |
project.tasks.create(name: 'j2objcCopy', type: j2objcCopyTask, | |
dependsOn: 'j2objcTest') { | |
description 'Depends on j2objc translation and test, copies to destDir' | |
srcDir = file("${project.buildDir}/j2objc") | |
} | |
// Make sure the wider project builds successfully | |
if (project.plugins.findPlugin('java')) { | |
project.tasks.findByName('j2objcTranslate').dependsOn('test') | |
} else if (project.plugins.findPlugin('com.android.application')) { | |
project.tasks.findByName('j2objcTranslate').dependsOn('assemble') | |
} else { | |
def message = | |
"j2objc plugin didn't find either 'java' or 'com.android.application'\n" + | |
"plugin (which was expected). When this is found, the j2objc plugin\n" + | |
"will build and run that first to make sure the project builds correctly.\n" | |
"This will not be done here as it can't be found." | |
println message | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment