chiark / gitweb /
Make the server tools an installable package (with distutils) - wip
[fdroidserver.git] / fdroidserver / common.py
1 # -*- coding: utf-8 -*-
2 #
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-12, Ciaran Gultnieks, ciaran@ciarang.com
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU Affero General Public License for more details.
15 #
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19 import glob, os, sys, re
20 import shutil
21 import subprocess
22 import time
23 import operator
24
25 def getvcs(vcstype, remote, local):
26     if vcstype == 'git':
27         return vcs_git(remote, local)
28     elif vcstype == 'svn':
29         return vcs_svn(remote, local)
30     elif vcstype == 'git-svn':
31         return vcs_gitsvn(remote, local)
32     elif vcstype == 'hg':
33         return vcs_hg(remote, local)
34     elif vcstype == 'bzr':
35         return vcs_bzr(remote, local)
36     elif vcstype == 'srclib':
37         return vcs_srclib(remote, local)
38     raise VCSException("Invalid vcs type " + vcstype)
39
40 class vcs:
41     def __init__(self, remote, local):
42
43         # It's possible to sneak a username and password in with
44         # the remote address... (this really only applies to svn
45         # and we should probably be more specific!)
46         index = remote.find('@')
47         if index != -1:
48             self.username = remote[:index]
49             remote = remote[index+1:]
50             index = self.username.find(':')
51             if index == -1:
52                 raise VCSException("Password required with username")
53             self.password = self.username[index+1:]
54             self.username = self.username[:index]
55         else:
56             self.username = None
57
58         self.remote = remote
59         self.local = local
60         self.refreshed = False
61         self.srclib = None
62
63     # Take the local repository to a clean version of the given revision, which
64     # is specificed in the VCS's native format. Beforehand, the repository can
65     # be dirty, or even non-existent. If the repository does already exist
66     # locally, it will be updated from the origin, but only once in the
67     # lifetime of the vcs object.
68     # None is acceptable for 'rev' if you know you are cloning a clean copy of
69     # the repo - otherwise it must specify a valid revision.
70     def gotorevision(self, rev):
71         raise VCSException("This VCS type doesn't define gotorevision")
72
73     # Initialise and update submodules
74     def initsubmodules(self):
75         raise VCSException('Submodules not supported for this vcs type')
76
77     # Returns the srclib (name, path) used in setting up the current
78     # revision, or None.
79     def getsrclib(self):
80         return self.srclib
81
82 class vcs_git(vcs):
83
84     # If the local directory exists, but is somehow not a git repository, git
85     # will traverse up the directory tree until it finds one that is (i.e.
86     # fdroidserver) and then we'll proceed to destory it! This is called as
87     # a safety check.
88     def checkrepo(self):
89         p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
90                 stdout=subprocess.PIPE, cwd=self.local)
91         result = p.communicate()[0].rstrip()
92         if not result.endswith(self.local):
93             raise VCSException('Repository mismatch')
94
95     def gotorevision(self, rev):
96         if not os.path.exists(self.local):
97             # Brand new checkout...
98             if subprocess.call(['git', 'clone', self.remote, self.local]) != 0:
99                 raise VCSException("Git clone failed")
100             self.checkrepo()
101         else:
102             self.checkrepo()
103             # Discard any working tree changes...
104             if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
105                 raise VCSException("Git reset failed")
106             # Remove untracked files now, in case they're tracked in the target
107             # revision (it happens!)...
108             if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
109                 raise VCSException("Git clean failed")
110             if not self.refreshed:
111                 # Get latest commits and tags from remote...
112                 if subprocess.call(['git', 'fetch', 'origin'],
113                         cwd=self.local) != 0:
114                     raise VCSException("Git fetch failed")
115                 if subprocess.call(['git', 'fetch', '--tags', 'origin'],
116                         cwd=self.local) != 0:
117                     raise VCSException("Git fetch failed")
118                 self.refreshed = True
119         # Check out the appropriate revision...
120         if rev:
121             if subprocess.call(['git', 'checkout', rev], cwd=self.local) != 0:
122                 raise VCSException("Git checkout failed")
123         # Get rid of any uncontrolled files left behind...
124         if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
125             raise VCSException("Git clean failed")
126
127     def initsubmodules(self):
128         self.checkrepo()
129         if subprocess.call(['git', 'submodule', 'init'],
130                 cwd=self.local) != 0:
131             raise VCSException("Git submodule init failed")
132         if subprocess.call(['git', 'submodule', 'update'],
133                 cwd=self.local) != 0:
134             raise VCSException("Git submodule update failed")
135
136
137 class vcs_gitsvn(vcs):
138
139     # If the local directory exists, but is somehow not a git repository, git
140     # will traverse up the directory tree until it finds one that is (i.e.
141     # fdroidserver) and then we'll proceed to destory it! This is called as
142     # a safety check.
143     def checkrepo(self):
144         p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
145                 stdout=subprocess.PIPE, cwd=self.local)
146         result = p.communicate()[0].rstrip()
147         if not result.endswith(self.local):
148             raise VCSException('Repository mismatch')
149
150     def gotorevision(self, rev):
151         if not os.path.exists(self.local):
152             # Brand new checkout...
153             if subprocess.call(['git', 'svn', 'clone', self.remote, self.local]) != 0:
154                 raise VCSException("Git clone failed")
155             self.checkrepo()
156         else:
157             self.checkrepo()
158             # Discard any working tree changes...
159             if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
160                 raise VCSException("Git reset failed")
161             # Remove untracked files now, in case they're tracked in the target
162             # revision (it happens!)...
163             if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
164                 raise VCSException("Git clean failed")
165             if not self.refreshed:
166                 # Get new commits and tags from repo...
167                 if subprocess.call(['git', 'svn', 'rebase'],
168                         cwd=self.local) != 0:
169                     raise VCSException("Git svn rebase failed")
170                 self.refreshed = True
171         if rev:
172             # Figure out the git commit id corresponding to the svn revision...
173             p = subprocess.Popen(['git', 'svn', 'find-rev', 'r' + rev],
174                 cwd=self.local, stdout=subprocess.PIPE)
175             rev = p.communicate()[0].rstrip()
176             if p.returncode != 0:
177                 raise VCSException("Failed to get git treeish from svn rev")
178             # Check out the appropriate revision...
179             if subprocess.call(['git', 'checkout', rev], cwd=self.local) != 0:
180                 raise VCSException("Git checkout failed")
181         # Get rid of any uncontrolled files left behind...
182         if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
183             raise VCSException("Git clean failed")
184
185 class vcs_svn(vcs):
186
187     def userargs(self):
188         if self.username is None:
189             return ['--non-interactive']
190         return ['--username', self.username, 
191                 '--password', self.password,
192                 '--non-interactive']
193
194     def gotorevision(self, rev):
195         if not os.path.exists(self.local):
196             if subprocess.call(['svn', 'checkout', self.remote, self.local] +
197                     self.userargs()) != 0:
198                 raise VCSException("Svn checkout failed")
199         else:
200             for svncommand in (
201                     'svn revert -R .',
202                     r"svn status | awk '/\?/ {print $2}' | xargs rm -rf"):
203                 if subprocess.call(svncommand, cwd=self.local,
204                         shell=True) != 0:
205                     raise VCSException("Svn reset failed")
206             if not self.refreshed:
207                 if subprocess.call(['svn', 'update'] +
208                         self.userargs(), cwd=self.local) != 0:
209                     raise VCSException("Svn update failed")
210                 self.refreshed = True
211         if rev:
212             revargs = ['-r', rev]
213             if subprocess.call(['svn', 'update', '--force'] + revargs +
214                     self.userargs(), cwd=self.local) != 0:
215                 raise VCSException("Svn update failed")
216
217
218 class vcs_hg(vcs):
219
220     def gotorevision(self, rev):
221         if not os.path.exists(self.local):
222             if subprocess.call(['hg', 'clone', self.remote, self.local]) !=0:
223                 raise VCSException("Hg clone failed")
224         else:
225             if subprocess.call('hg status -u | xargs rm -rf',
226                     cwd=self.local, shell=True) != 0:
227                 raise VCSException("Hg clean failed")
228             if not self.refreshed:
229                 if subprocess.call(['hg', 'pull'],
230                         cwd=self.local) != 0:
231                     raise VCSException("Hg pull failed")
232                 self.refreshed = True
233         if rev:
234             revargs = [rev]
235             if subprocess.call(['hg', 'checkout', '-C'] + revargs,
236                     cwd=self.local) != 0:
237                 raise VCSException("Hg checkout failed")
238
239
240 class vcs_bzr(vcs):
241
242     def gotorevision(self, rev):
243         if not os.path.exists(self.local):
244             if subprocess.call(['bzr', 'branch', self.remote, self.local]) != 0:
245                 raise VCSException("Bzr branch failed")
246         else:
247             if subprocess.call(['bzr', 'clean-tree', '--force',
248                     '--unknown', '--ignored'], cwd=self.local) != 0:
249                 raise VCSException("Bzr revert failed")
250             if not self.refreshed:
251                 if subprocess.call(['bzr', 'pull'],
252                         cwd=self.local) != 0:
253                     raise VCSException("Bzr update failed")
254                 self.refreshed = True
255         if rev:
256             revargs = ['-r', rev]
257             if subprocess.call(['bzr', 'revert'] + revargs,
258                     cwd=self.local) != 0:
259                 raise VCSException("Bzr revert failed")
260
261 class vcs_srclib(vcs):
262
263     def gotorevision(self, rev):
264
265         # Yuk...
266         extlib_dir = 'build/extlib'
267
268         if os.path.exists(self.local):
269             shutil.rmtree(self.local)
270
271         if self.remote.find(':') != -1:
272             srclib, path = self.remote.split(':')
273         else:
274             srclib = self.remote
275             path = None
276         libdir = getsrclib(srclib + '@' + rev, extlib_dir)
277         self.srclib = (srclib, libdir)
278         if path:
279             libdir = os.path.join(libdir, path)
280         shutil.copytree(libdir, self.local)
281         return self.local
282
283
284 # Get the type expected for a given metadata field.
285 def metafieldtype(name):
286     if name == 'Description':
287         return 'multiline'
288     if name == 'Requires Root':
289         return 'flag'
290     if name == 'Build Version':
291         return 'build'
292     if name == 'Use Built':
293         return 'obsolete'
294     return 'string'
295
296
297 # Parse metadata for a single application.
298 #
299 #  'metafile' - the filename to read. The package id for the application comes
300 #               from this filename. Pass None to get a blank entry.
301 #
302 # Returns a dictionary containing all the details of the application. There are
303 # two major kinds of information in the dictionary. Keys beginning with capital
304 # letters correspond directory to identically named keys in the metadata file.
305 # Keys beginning with lower case letters are generated in one way or another,
306 # and are not found verbatim in the metadata.
307 #
308 # Known keys not originating from the metadata are:
309 #
310 #  'id'               - the application's package ID
311 #  'builds'           - a list of dictionaries containing build information
312 #                       for each defined build
313 #  'comments'         - a list of comments from the metadata file. Each is
314 #                       a tuple of the form (field, comment) where field is
315 #                       the name of the field it preceded in the metadata
316 #                       file. Where field is None, the comment goes at the
317 #                       end of the file. Alternatively, 'build:version' is
318 #                       for a comment before a particular build version.
319 #  'descriptionlines' - original lines of description as formatted in the
320 #                       metadata file.
321 #
322 def parse_metadata(metafile, **kw):
323
324     def parse_buildline(lines):
325         value = "".join(lines)
326         parts = [p.replace("\\,", ",")
327                  for p in re.split(r"(?<!\\),", value)]
328         if len(parts) < 3:
329             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
330         thisbuild = {}
331         thisbuild['origlines'] = lines
332         thisbuild['version'] = parts[0]
333         thisbuild['vercode'] = parts[1]
334         thisbuild['commit'] = parts[2]
335         for p in parts[3:]:
336             pk, pv = p.split('=', 1)
337             thisbuild[pk] = pv
338         return thisbuild
339
340     def add_comments(key):
341         for comment in curcomments:
342             thisinfo['comments'].append((key, comment))
343         del curcomments[:]
344
345     thisinfo = {}
346     if metafile:
347         if not isinstance(metafile, file):
348             metafile = open(metafile, "r")
349         thisinfo['id'] = metafile.name[9:-4]
350         if kw.get("verbose", False):
351             print "Reading metadata for " + thisinfo['id']
352     else:
353         thisinfo['id'] = None
354
355     # Defaults for fields that come from metadata...
356     thisinfo['Name'] = None
357     thisinfo['Category'] = 'None'
358     thisinfo['Description'] = []
359     thisinfo['Summary'] = ''
360     thisinfo['License'] = 'Unknown'
361     thisinfo['Web Site'] = ''
362     thisinfo['Source Code'] = ''
363     thisinfo['Issue Tracker'] = ''
364     thisinfo['Donate'] = None
365     thisinfo['Disabled'] = None
366     thisinfo['AntiFeatures'] = None
367     thisinfo['Update Check Mode'] = 'Market'
368     thisinfo['Current Version'] = ''
369     thisinfo['Current Version Code'] = '0'
370     thisinfo['Repo Type'] = ''
371     thisinfo['Repo'] = ''
372     thisinfo['Requires Root'] = False
373
374     # General defaults...
375     thisinfo['builds'] = []
376     thisinfo['comments'] = []
377
378     if metafile is None:
379         return thisinfo
380
381     mode = 0
382     buildlines = []
383     curcomments = []
384
385     for line in metafile:
386         line = line.rstrip('\r\n')
387         if mode == 0:
388             if len(line) == 0:
389                 continue
390             if line.startswith("#"):
391                 curcomments.append(line)
392                 continue
393             index = line.find(':')
394             if index == -1:
395                 raise MetaDataException("Invalid metadata in " + metafile.name + " at: " + line)
396             field = line[:index]
397             value = line[index+1:]
398
399             # Translate obsolete fields...
400             if field == 'Market Version':
401                 field = 'Current Version'
402             if field == 'Market Version Code':
403                 field = 'Current Version Code'
404
405             fieldtype = metafieldtype(field)
406             if fieldtype != 'build':
407                 add_comments(field)
408             if fieldtype == 'multiline':
409                 mode = 1
410                 thisinfo[field] = []
411                 if len(value) > 0:
412                     raise MetaDataException("Unexpected text on same line as " + field + " in " + metafile.name)
413             elif fieldtype == 'string':
414                 thisinfo[field] = value
415             elif fieldtype == 'flag':
416                 if value == 'Yes':
417                     thisinfo[field] = True
418                 elif value == 'No':
419                     thisinfo[field] = False
420                 else:
421                     raise MetaDataException("Expected Yes or No for " + field + " in " + metafile.name)
422             elif fieldtype == 'build':
423                 if value.endswith("\\"):
424                     mode = 2
425                     buildlines = [value[:-1]]
426                 else:
427                     thisinfo['builds'].append(parse_buildline([value]))
428                     add_comments('build:' + thisinfo['builds'][-1]['version'])
429             elif fieldtype == 'obsolete':
430                 pass        # Just throw it away!
431             else:
432                 raise MetaDataException("Unrecognised field type for " + field + " in " + metafile.name)
433         elif mode == 1:     # Multiline field
434             if line == '.':
435                 mode = 0
436             else:
437                 thisinfo[field].append(line)
438         elif mode == 2:     # Line continuation mode in Build Version
439             if line.endswith("\\"):
440                 buildlines.append(line[:-1])
441             else:
442                 buildlines.append(line)
443                 thisinfo['builds'].append(
444                     parse_buildline(buildlines))
445                 add_comments('build:' + thisinfo['builds'][-1]['version'])
446                 mode = 0
447     add_comments(None)
448
449     # Mode at end of file should always be 0...
450     if mode == 1:
451         raise MetaDataException(field + " not terminated in " + metafile.name)
452     elif mode == 2:
453         raise MetaDataException("Unterminated continuation in " + metafile.name)
454
455     if len(thisinfo['Description']) == 0:
456         thisinfo['Description'].append('No description available')
457
458     # Ensure all AntiFeatures are recognised...
459     if thisinfo['AntiFeatures']:
460         parts = thisinfo['AntiFeatures'].split(",")
461         for part in parts:
462             if (part != "Ads" and
463                 part != "Tracking" and
464                 part != "NonFreeNet" and
465                 part != "NonFreeDep" and
466                 part != "NonFreeAdd"):
467                 raise MetaDataException("Unrecognised antifeature '" + part + "' in " \
468                             + metafile.name)
469
470     return thisinfo
471
472 # Write a metadata file.
473 #
474 # 'dest'    - The path to the output file
475 # 'app'     - The app data
476 def write_metadata(dest, app):
477
478     def writecomments(key):
479         for pf, comment in app['comments']:
480             if pf == key:
481                 mf.write(comment + '\n')
482
483     def writefield(field, value=None):
484         writecomments(field)
485         if value is None:
486             value = app[field]
487         mf.write(field + ':' + value + '\n')
488
489     mf = open(dest, 'w')
490     if app['Disabled']:
491         writefield('Disabled')
492     if app['AntiFeatures']:
493         writefield('AntiFeatures')
494     writefield('Category')
495     writefield('License')
496     writefield('Web Site')
497     writefield('Source Code')
498     writefield('Issue Tracker')
499     if app['Donate']:
500         writefield('Donate')
501     mf.write('\n')
502     if app['Name']:
503         writefield('Name')
504     writefield('Summary')
505     writefield('Description', '')
506     for line in app['Description']:
507         mf.write(line + '\n')
508     mf.write('.\n')
509     mf.write('\n')
510     if app['Requires Root']:
511         writefield('Requires Root', 'Yes')
512         mf.write('\n')
513     if len(app['Repo Type']) > 0:
514         writefield('Repo Type')
515         writefield('Repo')
516         mf.write('\n')
517     for build in app['builds']:
518         writecomments('build:' + build['version'])
519         mf.write('Build Version:')
520         if build.has_key('origlines'):
521             # Keeping the original formatting if we loaded it from a file...
522             mf.write('\\\n'.join(build['origlines']) + '\n')
523         else:
524             mf.write(build['version'] + ',' + build['vercode'] + ',' + 
525                     build['commit'])
526             for key,value in build.iteritems():
527                 if key not in ['version', 'vercode', 'commit']:
528                     mf.write(',' + key + '=' + value)
529             mf.write('\n')
530     if len(app['builds']) > 0:
531         mf.write('\n')
532     writefield('Update Check Mode')
533     if len(app['Current Version']) > 0:
534         writefield('Current Version')
535         writefield('Current Version Code')
536     mf.write('\n')
537     writecomments(None)
538     mf.close()
539
540
541 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
542 # returned by the parse_metadata function.
543 def read_metadata(verbose=False):
544     apps = []
545     for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
546         if verbose:
547             print "Reading " + metafile
548         apps.append(parse_metadata(metafile, verbose=verbose))
549     return apps
550
551
552 # Parse multiple lines of description as written in a metadata file, returning
553 # a single string.
554 def parse_description(lines):
555     text = ''
556     for line in lines:
557         if len(line) == 0:
558             text += '\n\n'
559         else:
560             if not text.endswith('\n') and len(text) > 0:
561                 text += ' '
562             text += line
563     return text
564
565
566 # Extract some information from the AndroidManifest.xml at the given path.
567 # Returns (version, vercode, package), any or all of which might be None.
568 def parse_androidmanifest(manifest):
569
570     vcsearch = re.compile(r'.*android:versionCode="([^"]+)".*').search
571     vnsearch = re.compile(r'.*android:versionName="([^"]+)".*').search
572     psearch = re.compile(r'.*package="([^"]+)".*').search
573     version = None
574     vercode = None
575     package = None
576     for line in file(manifest):
577         if not package:
578             matches = psearch(line)
579             if matches:
580                 package = matches.group(1)
581         if not version:
582             matches = vnsearch(line)
583             if matches:
584                 version = matches.group(1)
585         if not vercode:
586             matches = vcsearch(line)
587             if matches:
588                 vercode = matches.group(1)
589     return (version, vercode, package)
590
591
592 class BuildException(Exception):
593     def __init__(self, value, stdout = None, stderr = None):
594         self.value = value
595         self.stdout = stdout
596         self.stderr = stderr
597
598     def __str__(self):
599         ret = repr(self.value)
600         if self.stdout:
601             ret = ret + "\n==== stdout begin ====\n" + str(self.stdout) + "\n==== stdout end ===="
602         if self.stderr:
603             ret = ret + "\n==== stderr begin ====\n" + str(self.stderr) + "\n==== stderr end ===="
604         return ret
605
606 class VCSException(Exception):
607     def __init__(self, value):
608         self.value = value
609
610     def __str__(self):
611         return repr(self.value)
612
613 class MetaDataException(Exception):
614     def __init__(self, value):
615         self.value = value
616
617     def __str__(self):
618         return repr(self.value)
619
620
621 # Get the specified source library.
622 # Returns the path to it.
623 # TODO: These are currently just hard-coded in this method. It will be a
624 # metadata-driven system eventually, but not yet.
625 def getsrclib(spec, extlib_dir):
626     name, ref = spec.split('@')
627
628     if name == 'GreenDroid':
629         sdir = os.path.join(extlib_dir, 'GreenDroid')
630         vcs = getvcs('git',
631             'https://github.com/cyrilmottier/GreenDroid.git', sdir)
632         vcs.gotorevision(ref)
633         return os.path.join(sdir, 'GreenDroid')
634
635     if name == 'ActionBarSherlock':
636         sdir = os.path.join(extlib_dir, 'ActionBarSherlock')
637         vcs = getvcs('git',
638             'https://github.com/JakeWharton/ActionBarSherlock.git', sdir)
639         vcs.gotorevision(ref)
640         libdir = os.path.join(sdir, 'library')
641         if subprocess.call(['android', 'update', 'project', '-p',
642             libdir]) != 0:
643             raise BuildException('Error updating ActionBarSherlock project')
644         return libdir
645
646     if name == 'FacebookSDK':
647         sdir = os.path.join(extlib_dir, 'FacebookSDK')
648         vcs = getvcs('git',
649                 'git://github.com/facebook/facebook-android-sdk.git', sdir)
650         vcs.gotorevision(ref)
651         libdir = os.path.join(sdir, 'facebook')
652         if subprocess.call(['android', 'update', 'project', '-p',
653             libdir]) != 0:
654             raise BuildException('Error updating FacebookSDK project')
655         return libdir
656
657     if name == 'OI':
658         sdir = os.path.join(extlib_dir, 'OI')
659         vcs = getvcs('git-svn',
660                 'http://openintents.googlecode.com/svn/trunk/', sdir)
661         vcs.gotorevision(ref)
662         return sdir
663
664     if name == 'JOpenDocument':
665         sdir = os.path.join(extlib_dir, 'JOpenDocument')
666         vcs = getvcs('git',
667                 'https://github.com/andiwand/JOpenDocument.git', sdir)
668         vcs.gotorevision(ref)
669         shutil.rmtree(os.path.join(sdir, 'bin'))
670         return sdir
671
672     raise BuildException('Unknown srclib ' + name)
673
674
675 # Prepare the source code for a particular build
676 #  'vcs'         - the appropriate vcs object for the application
677 #  'app'         - the application details from the metadata
678 #  'build'       - the build details from the metadata
679 #  'build_dir'   - the path to the build directory, usually
680 #                   'build/app.id'
681 #  'extlib_dir'  - the path to the external libraries directory, usually
682 #                   'build/extlib'
683 #  'sdk_path'    - the path to the Android SDK
684 #  'ndk_path'    - the path to the Android NDK
685 #  'javacc_path' - the path to javacc
686 # Returns the root directory, which may be the same as 'build_dir' or may
687 # be a subdirectory of it.
688 def prepare_source(vcs, app, build, build_dir, extlib_dir, sdk_path, ndk_path, javacc_path):
689
690     # Optionally, the actual app source can be in a subdirectory...
691     if build.has_key('subdir'):
692         root_dir = os.path.join(build_dir, build['subdir'])
693     else:
694         root_dir = build_dir
695
696     # Get a working copy of the right revision...
697     print "Getting source for revision " + build['commit']
698     vcs.gotorevision(build['commit'])
699
700     # Check that a subdir (if we're using one) exists. This has to happen
701     # after the checkout, since it might not exist elsewhere...
702     if not os.path.exists(root_dir):
703         raise BuildException('Missing subdir ' + root_dir)
704
705     # Initialise submodules if requred...
706     if build.get('submodules', 'no')  == 'yes':
707         vcs.initsubmodules()
708
709     # Run an init command if one is required...
710     if build.has_key('init'):
711         init = build['init']
712         if subprocess.call(init, cwd=root_dir, shell=True) != 0:
713             raise BuildException("Error running init command")
714
715     # Generate (or update) the ant build file, build.xml...
716     if (build.get('update', '.') != 'no' and
717         not build.has_key('maven')):
718         parms = [os.path.join(sdk_path, 'tools', 'android'),
719                 'update', 'project', '-p', '.']
720         parms.append('--subprojects')
721         if build.has_key('target'):
722             parms.append('-t')
723             parms.append(build['target'])
724         update_dirs = build.get('update', '.').split(';')
725         # Force build.xml update if necessary...
726         if build.get('update', '.') == 'force' or build.has_key('target'):
727             update_dirs = ['.']
728             buildxml = os.path.join(root_dir, 'build.xml')
729             if os.path.exists(buildxml):
730                 print 'Force-removing old build.xml'
731                 os.remove(buildxml)
732         for d in update_dirs:
733             if subprocess.call(parms, cwd=root_dir + '/' + d) != 0:
734                 raise BuildException("Failed to update project")
735
736     # If the app has ant set up to sign the release, we need to switch
737     # that off, because we want the unsigned apk...
738     for propfile in ('build.properties', 'default.properties', 'ant.properties'):
739         if os.path.exists(os.path.join(root_dir, propfile)):
740             if subprocess.call(['sed','-i','s/^key.store/#/',
741                                 propfile], cwd=root_dir) !=0:
742                 raise BuildException("Failed to amend %s" % propfile)
743
744     # Update the local.properties file...
745     locprops = os.path.join(root_dir, 'local.properties')
746     if os.path.exists(locprops):
747         f = open(locprops, 'r')
748         props = f.read()
749         f.close()
750         # Fix old-fashioned 'sdk-location' by copying
751         # from sdk.dir, if necessary...
752         if build.get('oldsdkloc', 'no') == "yes":
753             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
754                 re.S|re.M).group(1)
755             props += "\nsdk-location=" + sdkloc + "\n"
756         # Add ndk location...
757         props+= "\nndk.dir=" + ndk_path + "\n"
758         # Add java.encoding if necessary...
759         if build.has_key('encoding'):
760             props += "\njava.encoding=" + build['encoding'] + "\n"
761         f = open(locprops, 'w')
762         f.write(props)
763         f.close()
764
765     # Insert version code and number into the manifest if necessary...
766     if build.has_key('forceversion'):
767         if subprocess.call(['sed','-r','-i',
768             's/android:versionName="[^"]+"/android:versionName="' + build['version'] + '"/g',
769             'AndroidManifest.xml'], cwd=root_dir) !=0:
770             raise BuildException("Failed to amend manifest")
771     if build.has_key('forcevercode'):
772         if subprocess.call(['sed','-r','-i',
773             's/android:versionCode="[^"]+"/android:versionCode="' + build['vercode'] + '"/g',
774             'AndroidManifest.xml'], cwd=root_dir) !=0:
775             raise BuildException("Failed to amend manifest")
776
777     # Delete unwanted file...
778     if build.has_key('rm'):
779         dest = os.path.join(build_dir, build['rm'])
780         if os.path.exists(dest):
781             os.remove(dest)
782
783     # Fix apostrophes translation files if necessary...
784     if build.get('fixapos', 'no') == 'yes':
785         for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
786             for filename in files:
787                 if filename.endswith('.xml'):
788                     if subprocess.call(['sed','-i','s@' +
789                         r"\([^\\]\)'@\1\\'" +
790                         '@g',
791                         os.path.join(root, filename)]) != 0:
792                         raise BuildException("Failed to amend " + filename)
793
794     # Fix translation files if necessary...
795     if build.get('fixtrans', 'no') == 'yes':
796         for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
797             for filename in files:
798                 if filename.endswith('.xml'):
799                     f = open(os.path.join(root, filename))
800                     changed = False
801                     outlines = []
802                     for line in f:
803                         num = 1
804                         index = 0
805                         oldline = line
806                         while True:
807                             index = line.find("%", index)
808                             if index == -1:
809                                 break
810                             next = line[index+1:index+2]
811                             if next == "s" or next == "d":
812                                 line = (line[:index+1] +
813                                         str(num) + "$" +
814                                         line[index+1:])
815                                 num += 1
816                                 index += 3
817                             else:
818                                 index += 1
819                         # We only want to insert the positional arguments
820                         # when there is more than one argument...
821                         if oldline != line:
822                             if num > 2:
823                                 changed = True
824                             else:
825                                 line = oldline
826                         outlines.append(line)
827                     f.close()
828                     if changed:
829                         f = open(os.path.join(root, filename), 'w')
830                         f.writelines(outlines)
831                         f.close()
832
833     # Add required external libraries...
834     if build.has_key('extlibs'):
835         libsdir = os.path.join(root_dir, 'libs')
836         if not os.path.exists(libsdir):
837             os.mkdir(libsdir)
838         for lib in build['extlibs'].split(';'):
839             libf = os.path.basename(lib)
840             shutil.copyfile(os.path.join(extlib_dir, lib),
841                     os.path.join(libsdir, libf))
842
843     # Get required source libraries...
844     srclibpaths = []
845     if build.has_key('srclibs'):
846         for lib in build['srclibs'].split(';'):
847             name, _ = lib.split('@')
848             srclibpaths.append((name, getsrclib(lib, extlib_dir)))
849     basesrclib = vcs.getsrclib()
850     # If one was used for the main source, add that too.
851     if basesrclib:
852         srclibpaths.append(basesrclib)
853
854     # There should never be gen or bin directories in the source, so just get
855     # rid of them...
856     for baddir in ['gen', 'bin']:
857         badpath = os.path.join(root_dir, baddir)
858         if os.path.exists(badpath):
859             shutil.rmtree(badpath)
860
861     # Apply patches if any
862     if 'patch' in build:
863         for patch in build['patch'].split(';'):
864             print "Applying " + patch
865             patch_path = os.path.join('metadata', app['id'], patch)
866             if subprocess.call(['patch', '-p1',
867                             '-i', os.path.abspath(patch_path)], cwd=build_dir) != 0:
868                 raise BuildException("Failed to apply patch %s" % patch_path)
869
870     # Run a pre-build command if one is required...
871     if build.has_key('prebuild'):
872         prebuild = build['prebuild']
873         # Substitute source library paths into prebuild commands...
874         for name, libpath in srclibpaths:
875             libpath = os.path.relpath(libpath, root_dir)
876             prebuild = prebuild.replace('$$' + name + '$$', libpath)
877         if subprocess.call(prebuild, cwd=root_dir, shell=True) != 0:
878             raise BuildException("Error running pre-build command")
879
880     # Special case init functions for funambol...
881     if build.get('initfun', 'no')  == "yes":
882
883         if subprocess.call(['sed','-i','s@' +
884             '<taskdef resource="net/sf/antcontrib/antcontrib.properties" />' +
885             '@' +
886             '<taskdef resource="net/sf/antcontrib/antcontrib.properties">' +
887             '<classpath>' +
888             '<pathelement location="/usr/share/java/ant-contrib.jar"/>' +
889             '</classpath>' +
890             '</taskdef>' +
891             '@g',
892             'build.xml'], cwd=root_dir) !=0:
893             raise BuildException("Failed to amend build.xml")
894
895         if subprocess.call(['sed','-i','s@' +
896             '\${user.home}/funambol/build/android/build.properties' +
897             '@' +
898             'build.properties' +
899             '@g',
900             'build.xml'], cwd=root_dir) !=0:
901             raise BuildException("Failed to amend build.xml")
902
903         buildxml = os.path.join(root_dir, 'build.xml')
904         f = open(buildxml, 'r')
905         xml = f.read()
906         f.close()
907         xmlout = ""
908         mode = 0
909         for line in xml.splitlines():
910             if mode == 0:
911                 if line.find("jarsigner") != -1:
912                     mode = 1
913                 else:
914                     xmlout += line + "\n"
915             else:
916                 if line.find("/exec") != -1:
917                     mode += 1
918                     if mode == 3:
919                         mode =0
920         f = open(buildxml, 'w')
921         f.write(xmlout)
922         f.close()
923
924         if subprocess.call(['sed','-i','s@' +
925             'platforms/android-2.0' +
926             '@' +
927             'platforms/android-8' +
928             '@g',
929             'build.xml'], cwd=root_dir) !=0:
930             raise BuildException("Failed to amend build.xml")
931
932         shutil.copyfile(
933                 os.path.join(root_dir, "build.properties.example"),
934                 os.path.join(root_dir, "build.properties"))
935
936         if subprocess.call(['sed','-i','s@' +
937             'javacchome=.*'+
938             '@' +
939             'javacchome=' + javacc_path +
940             '@g',
941             'build.properties'], cwd=root_dir) !=0:
942             raise BuildException("Failed to amend build.properties")
943
944         if subprocess.call(['sed','-i','s@' +
945             'sdk-folder=.*'+
946             '@' +
947             'sdk-folder=' + sdk_path +
948             '@g',
949             'build.properties'], cwd=root_dir) !=0:
950             raise BuildException("Failed to amend build.properties")
951
952         if subprocess.call(['sed','-i','s@' +
953             'android.sdk.version.*'+
954             '@' +
955             'android.sdk.version=2.0' +
956             '@g',
957             'build.properties'], cwd=root_dir) !=0:
958             raise BuildException("Failed to amend build.properties")
959
960     return root_dir
961
962
963 # Scan the source code in the given directory (and all subdirectories)
964 # and return a list of potential problems.
965 def scan_source(build_dir, root_dir, thisbuild):
966
967     problems = []
968
969     # Common known non-free blobs:
970     usual_suspects = ['flurryagent',
971                       'paypal_mpl',
972                       'libgoogleanalytics',
973                       'admob-sdk-android',
974                       'googleadview',
975                       'googleadmobadssdk',
976                       'heyzap']
977
978     # Iterate through all files in the source code...
979     for r,d,f in os.walk(build_dir):
980         for curfile in f:
981
982             if r.find('/.hg/') == -1:
983
984                 # Path (relative) to the file...
985                 fp = os.path.join(r, curfile)
986
987                 for suspect in usual_suspects:
988                     if curfile.lower().find(suspect) != -1:
989                         msg = 'Found probable non-free blob ' + fp
990                         problems.append(msg)
991
992                 if curfile.endswith('.java'):
993                     for line in file(fp):
994
995                         if line.find('DexClassLoader') != -1:
996                             msg = 'Found DexClassLoader in ' + fp
997                             problems.append(msg)
998
999 #                        if line.lower().find('all rights reserved') != -1:
1000 #                            msg = 'All rights reserved in ' + fp
1001 #                            problems.append(msg)
1002
1003     # Presence of a jni directory without buildjni=yes might
1004     # indicate a problem...
1005     if (os.path.exists(os.path.join(root_dir, 'jni')) and 
1006             thisbuild.get('buildjni') is None):
1007         msg = 'Found jni directory, but buildjni is not enabled'
1008         problems.append(msg)
1009
1010     return problems
1011
1012
1013 class KnownApks:
1014
1015     def __init__(self):
1016         self.path = os.path.join('stats', 'known_apks.txt')
1017         self.apks = {}
1018         if os.path.exists(self.path):
1019             for line in file( self.path):
1020                 t = line.rstrip().split(' ')
1021                 if len(t) == 2:
1022                     self.apks[t[0]] = (t[1], None)
1023                 else:
1024                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1025         self.changed = False
1026
1027     def writeifchanged(self):
1028         if self.changed:
1029             if not os.path.exists('stats'):
1030                 os.mkdir('stats')
1031             f = open(self.path, 'w')
1032             lst = []
1033             for apk, app in self.apks.iteritems():
1034                 appid, added = app
1035                 line = apk + ' ' + appid
1036                 if added:
1037                     line += ' ' + time.strftime('%Y-%m-%d', added)
1038                 lst.append(line)
1039             for line in sorted(lst):
1040                 f.write(line + '\n')
1041             f.close()
1042
1043     def recordapk(self, apk, app):
1044         if not apk in self.apks:
1045             self.apks[apk] = (app, time.gmtime(time.time()))
1046             self.changed = True
1047
1048     def getapp(self, apkname):
1049         if apkname in self.apks:
1050             return self.apks[apkname]
1051         return None
1052
1053     def getlatest(self, num):
1054         apps = {}
1055         for apk, app in self.apks.iteritems():
1056             appid, added = app
1057             if added:
1058                 if appid in apps:
1059                     if apps[appid] > added:
1060                         apps[appid] = added
1061                 else:
1062                     apps[appid] = added
1063         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1064         lst = []
1065         for app, added in sortedapps:
1066             lst.append(app)
1067         lst.reverse()
1068         return lst
1069