Fixes to gotorevision(None)
[f-droid:fdroidserver.git] / fdroidserver / common.py
1 # -*- coding: utf-8 -*-
2 #
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, 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 import cgi
25
26 def getvcs(vcstype, remote, local, sdk_path):
27     if vcstype == 'git':
28         return vcs_git(remote, local, sdk_path)
29     if vcstype == 'svn':
30         return vcs_svn(remote, local, sdk_path)
31     if vcstype == 'git-svn':
32         return vcs_gitsvn(remote, local, sdk_path)
33     if vcstype == 'hg':
34         return vcs_hg(remote, local, sdk_path)
35     if vcstype == 'bzr':
36         return vcs_bzr(remote, local, sdk_path)
37     if vcstype == 'srclib':
38         if local != 'build/srclib/' + remote:
39             raise VCSException("Error: srclib paths are hard-coded!")
40         return getsrclib(remote, 'build/srclib', sdk_path, raw=True)
41     raise VCSException("Invalid vcs type " + vcstype)
42
43 def getsrclibvcs(name):
44     srclib_path = os.path.join('srclibs', name + ".txt")
45     if not os.path.exists(srclib_path):
46         raise VCSException("Missing srclib " + name)
47     return parse_srclib(srclib_path)['Repo Type']
48
49 class vcs:
50     def __init__(self, remote, local, sdk_path):
51
52         self.sdk_path = sdk_path
53
54         # It's possible to sneak a username and password in with
55         # the remote address... (this really only applies to svn
56         # and we should probably be more specific!)
57         index = remote.find('@')
58         if index != -1:
59             self.username = remote[:index]
60             remote = remote[index+1:]
61             index = self.username.find(':')
62             if index == -1:
63                 raise VCSException("Password required with username")
64             self.password = self.username[index+1:]
65             self.username = self.username[:index]
66         else:
67             self.username = None
68
69         self.remote = remote
70         self.local = local
71         self.refreshed = False
72         self.srclib = None
73
74     # Take the local repository to a clean version of the given revision, which
75     # is specificed in the VCS's native format. Beforehand, the repository can
76     # be dirty, or even non-existent. If the repository does already exist
77     # locally, it will be updated from the origin, but only once in the
78     # lifetime of the vcs object.
79     # None is acceptable for 'rev' if you know you are cloning a clean copy of
80     # the repo - otherwise it must specify a valid revision.
81     def gotorevision(self, rev):
82
83         # The .fdroidvcs-id file for a repo tells us what VCS type
84         # and remote that directory was created from, allowing us to drop it
85         # automatically if either of those things changes.
86         fdpath = os.path.join(self.local, '..',
87                 '.fdroidvcs-' + os.path.basename(self.local))
88         cdata = self.repotype() + ' ' + self.remote
89         writeback = True
90         deleterepo = False
91         if os.path.exists(self.local):
92             if os.path.exists(fdpath):
93                 with open(fdpath, 'r') as f:
94                     fsdata = f.read()
95                 if fsdata == cdata:
96                     writeback = False
97                 else:
98                     deleterepo = True
99                     print "*** Repository details changed - deleting ***"
100             else:
101                 deleterepo = True
102                 print "*** Repository details missing - deleting ***"
103         if deleterepo:
104             shutil.rmtree(self.local)
105
106         self.gotorevisionx(rev)
107
108         # If necessary, write the .fdroidvcs file.
109         if writeback:
110             with open(fdpath, 'w') as f:
111                 f.write(cdata)
112
113     # Derived classes need to implement this. It's called once basic checking
114     # has been performend.
115     def gotorevisionx(self, rev):
116         raise VCSException("This VCS type doesn't define gotorevisionx")
117
118     # Initialise and update submodules
119     def initsubmodules(self):
120         raise VCSException('Submodules not supported for this vcs type')
121
122     # Get a list of all known tags
123     def gettags(self):
124         raise VCSException('gettags not supported for this vcs type')
125
126     # Returns the srclib (name, path) used in setting up the current
127     # revision, or None.
128     def getsrclib(self):
129         return self.srclib
130
131 class vcs_git(vcs):
132
133     def repotype(self):
134         return 'git'
135
136     # If the local directory exists, but is somehow not a git repository, git
137     # will traverse up the directory tree until it finds one that is (i.e.
138     # fdroidserver) and then we'll proceed to destroy it! This is called as
139     # a safety check.
140     def checkrepo(self):
141         p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
142                 stdout=subprocess.PIPE, cwd=self.local)
143         result = p.communicate()[0].rstrip()
144         if not result.endswith(self.local):
145             raise VCSException('Repository mismatch')
146
147     def gotorevisionx(self, rev):
148         if not os.path.exists(self.local):
149             # Brand new checkout...
150             if subprocess.call(['git', 'clone', self.remote, self.local]) != 0:
151                 raise VCSException("Git clone failed")
152             self.checkrepo()
153         else:
154             self.checkrepo()
155             # Discard any working tree changes...
156             if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
157                 raise VCSException("Git reset failed")
158             # Remove untracked files now, in case they're tracked in the target
159             # revision (it happens!)...
160             if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
161                 raise VCSException("Git clean failed")
162             if not self.refreshed:
163                 # Get latest commits and tags from remote...
164                 if subprocess.call(['git', 'fetch', 'origin'],
165                         cwd=self.local) != 0:
166                     raise VCSException("Git fetch failed")
167                 if subprocess.call(['git', 'fetch', '--tags', 'origin'],
168                         cwd=self.local) != 0:
169                     raise VCSException("Git fetch failed")
170                 self.refreshed = True
171         # Check out the appropriate revision...
172         rev = str(rev if rev else 'origin/master')
173         if subprocess.call(['git', 'checkout', rev], cwd=self.local) != 0:
174             raise VCSException("Git checkout failed")
175         # Get rid of any uncontrolled files left behind...
176         if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
177             raise VCSException("Git clean failed")
178
179     def initsubmodules(self):
180         self.checkrepo()
181         if subprocess.call(['git', 'submodule', 'init'],
182                 cwd=self.local) != 0:
183             raise VCSException("Git submodule init failed")
184         if subprocess.call(['git', 'submodule', 'update'],
185                 cwd=self.local) != 0:
186             raise VCSException("Git submodule update failed")
187
188     def gettags(self):
189         self.checkrepo()
190         p = subprocess.Popen(['git', 'tag'],
191                 stdout=subprocess.PIPE, cwd=self.local)
192         return p.communicate()[0].splitlines()
193
194
195 class vcs_gitsvn(vcs):
196
197     def repotype(self):
198         return 'git-svn'
199
200     # If the local directory exists, but is somehow not a git repository, git
201     # will traverse up the directory tree until it finds one that is (i.e.
202     # fdroidserver) and then we'll proceed to destory it! This is called as
203     # a safety check.
204     def checkrepo(self):
205         p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
206                 stdout=subprocess.PIPE, cwd=self.local)
207         result = p.communicate()[0].rstrip()
208         if not result.endswith(self.local):
209             raise VCSException('Repository mismatch')
210
211     def gotorevisionx(self, rev):
212         if not os.path.exists(self.local):
213             # Brand new checkout...
214             gitsvn_cmd = ['git', 'svn', 'clone']
215             remote_split = self.remote.split(';')
216             if len(remote_split) > 1:
217                 for i in remote_split[1:]:
218                     if i.startswith('trunk='):
219                         gitsvn_cmd += ['-T', i[6:]]
220                     elif i.startswith('tags='):
221                         gitsvn_cmd += ['-t', i[5:]]
222                     elif i.startswith('branches='):
223                         gitsvn_cmd += ['-b', i[9:]]
224                 if subprocess.call(gitsvn_cmd + [remote_split[0], self.local]) != 0:
225                     raise VCSException("Git clone failed")
226             else:
227                 if subprocess.call(gitsvn_cmd + [self.remote, self.local]) != 0:
228                     raise VCSException("Git clone failed")
229             self.checkrepo()
230         else:
231             self.checkrepo()
232             # Discard any working tree changes...
233             if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
234                 raise VCSException("Git reset failed")
235             # Remove untracked files now, in case they're tracked in the target
236             # revision (it happens!)...
237             if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
238                 raise VCSException("Git clean failed")
239             if not self.refreshed:
240                 # Get new commits and tags from repo...
241                 if subprocess.call(['git', 'svn', 'rebase'],
242                         cwd=self.local) != 0:
243                     raise VCSException("Git svn rebase failed")
244                 self.refreshed = True
245
246         rev = str(rev if rev else 'master')
247         if rev:
248             nospaces_rev = rev.replace(' ', '%20')
249             # Try finding a svn tag
250             p = subprocess.Popen(['git', 'checkout', 'tags/' + nospaces_rev],
251                     cwd=self.local, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
252             out, err = p.communicate()
253             if p.returncode == 0:
254                 print out
255             else:
256                 # No tag found, normal svn rev translation
257                 # Translate svn rev into git format
258                 p = subprocess.Popen(['git', 'svn', 'find-rev', 'r' + rev],
259                     cwd=self.local, stdout=subprocess.PIPE)
260                 git_rev = p.communicate()[0].rstrip()
261                 if p.returncode != 0 or len(git_rev) == 0:
262                     # Try a plain git checkout as a last resort
263                     p = subprocess.Popen(['git', 'checkout', rev], cwd=self.local,
264                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
265                     out, err = p.communicate()
266                     if p.returncode == 0:
267                         print out
268                     else:
269                         raise VCSException("No git treeish found and direct git checkout failed")
270                 else:
271                     # Check out the git rev equivalent to the svn rev
272                     p = subprocess.Popen(['git', 'checkout', git_rev], cwd=self.local,
273                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
274                     out, err = p.communicate()
275                     if p.returncode == 0:
276                         print out
277                     else:
278                         raise VCSException("Git svn checkout failed")
279         # Get rid of any uncontrolled files left behind...
280         if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
281             raise VCSException("Git clean failed")
282
283     def gettags(self):
284         self.checkrepo()
285         return os.listdir(os.path.join(self.local, '.git/svn/refs/remotes/tags'))
286
287 class vcs_svn(vcs):
288
289     def repotype(self):
290         return 'svn'
291
292     def userargs(self):
293         if self.username is None:
294             return ['--non-interactive']
295         return ['--username', self.username, 
296                 '--password', self.password,
297                 '--non-interactive']
298
299     def gotorevisionx(self, rev):
300         if not os.path.exists(self.local):
301             if subprocess.call(['svn', 'checkout', self.remote, self.local] +
302                     self.userargs()) != 0:
303                 raise VCSException("Svn checkout failed")
304         else:
305             for svncommand in (
306                     'svn revert -R .',
307                     r"svn status | awk '/\?/ {print $2}' | xargs rm -rf"):
308                 if subprocess.call(svncommand, cwd=self.local,
309                         shell=True) != 0:
310                     raise VCSException("Svn reset ({0}) failed in {1}".format(svncommand, self.local))
311             if not self.refreshed:
312                 if subprocess.call(['svn', 'update'] +
313                         self.userargs(), cwd=self.local) != 0:
314                     raise VCSException("Svn update failed")
315                 self.refreshed = True
316
317         revargs = list(['-r', rev] if rev else [])
318         if subprocess.call(['svn', 'update', '--force'] + revargs +
319                 self.userargs(), cwd=self.local) != 0:
320             raise VCSException("Svn update failed")
321
322
323 class vcs_hg(vcs):
324
325     def repotype(self):
326         return 'hg'
327
328     def gotorevisionx(self, rev):
329         if not os.path.exists(self.local):
330             if subprocess.call(['hg', 'clone', self.remote, self.local]) !=0:
331                 raise VCSException("Hg clone failed")
332         else:
333             if subprocess.call('hg status -u | xargs rm -rf',
334                     cwd=self.local, shell=True) != 0:
335                 raise VCSException("Hg clean failed")
336             if not self.refreshed:
337                 if subprocess.call(['hg', 'pull'],
338                         cwd=self.local) != 0:
339                     raise VCSException("Hg pull failed")
340                 self.refreshed = True
341
342         rev = str(rev if rev else 'default')
343         if rev:
344             revargs = [rev]
345             if subprocess.call(['hg', 'checkout', '-C'] + revargs,
346                     cwd=self.local) != 0:
347                 raise VCSException("Hg checkout failed")
348
349     def gettags(self):
350         p = subprocess.Popen(['hg', 'tags', '-q'],
351                 stdout=subprocess.PIPE, cwd=self.local)
352         return p.communicate()[0].splitlines()[1:]
353
354
355 class vcs_bzr(vcs):
356
357     def repotype(self):
358         return 'bzr'
359
360     def gotorevisionx(self, rev):
361         if not os.path.exists(self.local):
362             if subprocess.call(['bzr', 'branch', self.remote, self.local]) != 0:
363                 raise VCSException("Bzr branch failed")
364         else:
365             if subprocess.call(['bzr', 'clean-tree', '--force',
366                     '--unknown', '--ignored'], cwd=self.local) != 0:
367                 raise VCSException("Bzr revert failed")
368             if not self.refreshed:
369                 if subprocess.call(['bzr', 'pull'],
370                         cwd=self.local) != 0:
371                     raise VCSException("Bzr update failed")
372                 self.refreshed = True
373
374         revargs = list(['-r', rev] if rev else [])
375         if subprocess.call(['bzr', 'revert'] + revargs,
376                 cwd=self.local) != 0:
377             raise VCSException("Bzr revert failed")
378
379     def __init__(self, remote, local, sdk_path):
380
381         self.sdk_path = sdk_path
382
383         index = remote.find('@')
384         if index != -1:
385             self.username = remote[:index]
386             remote = remote[index+1:]
387             index = self.username.find(':')
388             if index == -1:
389                 raise VCSException("Password required with username")
390             self.password = self.username[index+1:]
391             self.username = self.username[:index]
392         else:
393             self.username = None
394
395         self.remote = remote
396         self.local = local
397         self.refreshed = False
398         self.srclib = None
399
400
401 # Get the type expected for a given metadata field.
402 def metafieldtype(name):
403     if name == 'Description':
404         return 'multiline'
405     if name == 'Requires Root':
406         return 'flag'
407     if name == 'Build Version':
408         return 'build'
409     if name == 'Use Built':
410         return 'obsolete'
411     return 'string'
412
413
414 # Parse metadata for a single application.
415 #
416 #  'metafile' - the filename to read. The package id for the application comes
417 #               from this filename. Pass None to get a blank entry.
418 #
419 # Returns a dictionary containing all the details of the application. There are
420 # two major kinds of information in the dictionary. Keys beginning with capital
421 # letters correspond directory to identically named keys in the metadata file.
422 # Keys beginning with lower case letters are generated in one way or another,
423 # and are not found verbatim in the metadata.
424 #
425 # Known keys not originating from the metadata are:
426 #
427 #  'id'               - the application's package ID
428 #  'builds'           - a list of dictionaries containing build information
429 #                       for each defined build
430 #  'comments'         - a list of comments from the metadata file. Each is
431 #                       a tuple of the form (field, comment) where field is
432 #                       the name of the field it preceded in the metadata
433 #                       file. Where field is None, the comment goes at the
434 #                       end of the file. Alternatively, 'build:version' is
435 #                       for a comment before a particular build version.
436 #  'descriptionlines' - original lines of description as formatted in the
437 #                       metadata file.
438 #
439 def parse_metadata(metafile, **kw):
440
441     def parse_buildline(lines):
442         value = "".join(lines)
443         parts = [p.replace("\\,", ",")
444                  for p in re.split(r"(?<!\\),", value)]
445         if len(parts) < 3:
446             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
447         thisbuild = {}
448         thisbuild['origlines'] = lines
449         thisbuild['version'] = parts[0]
450         thisbuild['vercode'] = parts[1]
451         try:
452             testvercode = int(thisbuild['vercode'])
453         except:
454             raise MetaDataException("Invalid version code for build in " + metafile.name)
455         thisbuild['commit'] = parts[2]
456         for p in parts[3:]:
457             pk, pv = p.split('=', 1)
458             thisbuild[pk.strip()] = pv
459         return thisbuild
460
461     def add_comments(key):
462         for comment in curcomments:
463             thisinfo['comments'].append((key, comment))
464         del curcomments[:]
465
466     thisinfo = {}
467     if metafile:
468         if not isinstance(metafile, file):
469             metafile = open(metafile, "r")
470         thisinfo['id'] = metafile.name[9:-4]
471     else:
472         thisinfo['id'] = None
473
474     # Defaults for fields that come from metadata...
475     thisinfo['Name'] = None
476     thisinfo['Auto Name'] = ''
477     thisinfo['Category'] = 'None'
478     thisinfo['Description'] = []
479     thisinfo['Summary'] = ''
480     thisinfo['License'] = 'Unknown'
481     thisinfo['Web Site'] = ''
482     thisinfo['Source Code'] = ''
483     thisinfo['Issue Tracker'] = ''
484     thisinfo['Donate'] = None
485     thisinfo['FlattrID'] = None
486     thisinfo['Bitcoin'] = None
487     thisinfo['Disabled'] = None
488     thisinfo['AntiFeatures'] = None
489     thisinfo['Update Check Mode'] = 'None'
490     thisinfo['Auto Update Mode'] = 'None'
491     thisinfo['Current Version'] = ''
492     thisinfo['Current Version Code'] = '0'
493     thisinfo['Repo Type'] = ''
494     thisinfo['Repo'] = ''
495     thisinfo['Requires Root'] = False
496     thisinfo['No Source Since'] = ''
497
498     # General defaults...
499     thisinfo['builds'] = []
500     thisinfo['comments'] = []
501
502     if metafile is None:
503         return thisinfo
504
505     mode = 0
506     buildlines = []
507     curcomments = []
508
509     for line in metafile:
510         line = line.rstrip('\r\n')
511         if mode == 0:
512             if len(line) == 0:
513                 continue
514             if line.startswith("#"):
515                 curcomments.append(line)
516                 continue
517             index = line.find(':')
518             if index == -1:
519                 raise MetaDataException("Invalid metadata in " + metafile.name + " at: " + line)
520             field = line[:index]
521             value = line[index+1:]
522
523             # Translate obsolete fields...
524             if field == 'Market Version':
525                 field = 'Current Version'
526             if field == 'Market Version Code':
527                 field = 'Current Version Code'
528
529             fieldtype = metafieldtype(field)
530             if fieldtype != 'build':
531                 add_comments(field)
532             if fieldtype == 'multiline':
533                 mode = 1
534                 thisinfo[field] = []
535                 if len(value) > 0:
536                     raise MetaDataException("Unexpected text on same line as " + field + " in " + metafile.name)
537             elif fieldtype == 'string':
538                 thisinfo[field] = value
539             elif fieldtype == 'flag':
540                 if value == 'Yes':
541                     thisinfo[field] = True
542                 elif value == 'No':
543                     thisinfo[field] = False
544                 else:
545                     raise MetaDataException("Expected Yes or No for " + field + " in " + metafile.name)
546             elif fieldtype == 'build':
547                 if value.endswith("\\"):
548                     mode = 2
549                     buildlines = [value[:-1]]
550                 else:
551                     thisinfo['builds'].append(parse_buildline([value]))
552                     add_comments('build:' + thisinfo['builds'][-1]['version'])
553             elif fieldtype == 'obsolete':
554                 pass        # Just throw it away!
555             else:
556                 raise MetaDataException("Unrecognised field type for " + field + " in " + metafile.name)
557         elif mode == 1:     # Multiline field
558             if line == '.':
559                 mode = 0
560             else:
561                 thisinfo[field].append(line)
562         elif mode == 2:     # Line continuation mode in Build Version
563             if line.endswith("\\"):
564                 buildlines.append(line[:-1])
565             else:
566                 buildlines.append(line)
567                 thisinfo['builds'].append(
568                     parse_buildline(buildlines))
569                 add_comments('build:' + thisinfo['builds'][-1]['version'])
570                 mode = 0
571     add_comments(None)
572
573     # Mode at end of file should always be 0...
574     if mode == 1:
575         raise MetaDataException(field + " not terminated in " + metafile.name)
576     elif mode == 2:
577         raise MetaDataException("Unterminated continuation in " + metafile.name)
578
579     if len(thisinfo['Description']) == 0:
580         thisinfo['Description'].append('No description available')
581
582     # Ensure all AntiFeatures are recognised...
583     if thisinfo['AntiFeatures']:
584         parts = thisinfo['AntiFeatures'].split(",")
585         for part in parts:
586             if (part != "Ads" and
587                 part != "Tracking" and
588                 part != "NonFreeNet" and
589                 part != "NonFreeDep" and
590                 part != "NonFreeAdd"):
591                 raise MetaDataException("Unrecognised antifeature '" + part + "' in " \
592                             + metafile.name)
593
594     return thisinfo
595
596 # Write a metadata file.
597 #
598 # 'dest'    - The path to the output file
599 # 'app'     - The app data
600 def write_metadata(dest, app):
601
602     def writecomments(key):
603         for pf, comment in app['comments']:
604             if pf == key:
605                 mf.write(comment + '\n')
606
607     def writefield(field, value=None):
608         writecomments(field)
609         if value is None:
610             value = app[field]
611         mf.write(field + ':' + value + '\n')
612
613     mf = open(dest, 'w')
614     if app['Disabled']:
615         writefield('Disabled')
616     if app['AntiFeatures']:
617         writefield('AntiFeatures')
618     writefield('Category')
619     writefield('License')
620     writefield('Web Site')
621     writefield('Source Code')
622     writefield('Issue Tracker')
623     if app['Donate']:
624         writefield('Donate')
625     if app['FlattrID']:
626         writefield('FlattrID')
627     if app['Bitcoin']:
628         writefield('Bitcoin')
629     mf.write('\n')
630     if app['Name']:
631         writefield('Name')
632     writefield('Auto Name')
633     writefield('Summary')
634     writefield('Description', '')
635     for line in app['Description']:
636         mf.write(line + '\n')
637     mf.write('.\n')
638     mf.write('\n')
639     if app['Requires Root']:
640         writefield('Requires Root', 'Yes')
641         mf.write('\n')
642     if len(app['Repo Type']) > 0:
643         writefield('Repo Type')
644         writefield('Repo')
645         mf.write('\n')
646     for build in app['builds']:
647         writecomments('build:' + build['version'])
648         mf.write('Build Version:')
649         if 'origlines' in build:
650             # Keeping the original formatting if we loaded it from a file...
651             mf.write('\\\n'.join(build['origlines']) + '\n')
652         else:
653             mf.write(build['version'] + ',' + build['vercode'] + ',' + 
654                     build['commit'])
655             for key,value in build.iteritems():
656                 if key not in ['version', 'vercode', 'commit']:
657                     mf.write(',' + key + '=' + value)
658             mf.write('\n')
659     if len(app['builds']) > 0:
660         mf.write('\n')
661     writefield('Auto Update Mode')
662     writefield('Update Check Mode')
663     if len(app['Current Version']) > 0:
664         writefield('Current Version')
665         writefield('Current Version Code')
666     mf.write('\n')
667     if len(app['No Source Since']) > 0:
668         writefield('No Source Since')
669         mf.write('\n')
670     writecomments(None)
671     mf.close()
672
673
674 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
675 # returned by the parse_metadata function.
676 def read_metadata(verbose=False, xref=True):
677     apps = []
678     for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
679         try:
680             appinfo = parse_metadata(metafile, verbose=verbose)
681         except Exception, e:
682             raise MetaDataException("Problem reading metadata file %s: - %s" % (metafile, str(e)))
683         apps.append(appinfo)
684
685     if xref:
686         # Parse all descriptions at load time, just to ensure cross-referencing
687         # errors are caught early rather than when they hit the build server.
688         def linkres(link):
689             for app in apps:
690                 if app['id'] == link:
691                     return ("fdroid.app:" + link, "Dummy name - don't know yet")
692             raise MetaDataException("Cannot resolve app id " + link)
693         for app in apps:
694             try:
695                 description_html(app['Description'], linkres)
696             except Exception, e:
697                 raise MetaDataException("Problem with description of " + app['id'] +
698                         " - " + str(e))
699
700     return apps
701
702 # Formatter for descriptions. Create an instance, and call parseline() with
703 # each line of the description source from the metadata. At the end, call
704 # end() and then text_plain, text_wiki and text_html will contain the result.
705 class DescriptionFormatter:
706     stNONE = 0
707     stPARA = 1
708     stUL = 2
709     stOL = 3
710     bold = False
711     ital = False
712     state = stNONE
713     text_plain = ''
714     text_wiki = ''
715     text_html = ''
716     linkResolver = None
717     def __init__(self, linkres):
718         self.linkResolver = linkres
719     def endcur(self, notstates=None):
720         if notstates and self.state in notstates:
721             return
722         if self.state == self.stPARA:
723             self.endpara()
724         elif self.state == self.stUL:
725             self.endul()
726         elif self.state == self.stOL:
727             self.endol()
728     def endpara(self):
729         self.text_plain += '\n'
730         self.text_html += '</p>'
731         self.state = self.stNONE
732     def endul(self):
733         self.text_html += '</ul>'
734         self.state = self.stNONE
735     def endol(self):
736         self.text_html += '</ol>'
737         self.state = self.stNONE
738
739     def formatted(self, txt, html):
740         formatted = ''
741         if html:
742             txt = cgi.escape(txt)
743         while True:
744             index = txt.find("''")
745             if index == -1:
746                 return formatted + txt
747             formatted += txt[:index]
748             txt = txt[index:]
749             if txt.startswith("'''"):
750                 if html:
751                     if self.bold:
752                         formatted += '</b>'
753                     else:
754                         formatted += '<b>'
755                 self.bold = not self.bold
756                 txt = txt[3:]
757             else:
758                 if html:
759                     if self.ital:
760                         formatted += '</i>'
761                     else:
762                         formatted += '<i>'
763                 self.ital = not self.ital
764                 txt = txt[2:]
765
766
767     def linkify(self, txt):
768         linkified_plain = ''
769         linkified_html = ''
770         while True:
771             index = txt.find("[")
772             if index == -1:
773                 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
774             linkified_plain += self.formatted(txt[:index], False)
775             linkified_html += self.formatted(txt[:index], True)
776             txt = txt[index:]
777             if txt.startswith("[["):
778                 index = txt.find("]]")
779                 if index == -1:
780                     raise MetaDataException("Unterminated ]]")
781                 url = txt[2:index]
782                 if self.linkResolver:
783                     url, urltext = self.linkResolver(url)
784                 else:
785                     urltext = url
786                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
787                 linkified_plain += urltext
788                 txt = txt[index+2:]
789             else:
790                 index = txt.find("]")
791                 if index == -1:
792                     raise MetaDataException("Unterminated ]")
793                 url = txt[1:index]
794                 index2 = url.find(' ')
795                 if index2 == -1:
796                     urltxt = url
797                 else:
798                     urltxt = url[index2 + 1:]
799                     url = url[:index2]
800                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
801                 linkified_plain += urltxt
802                 if urltxt != url:
803                     linkified_plain += ' (' + url + ')'
804                 txt = txt[index+1:]
805
806     def addtext(self, txt):
807         p, h = self.linkify(txt)
808         self.text_plain += p
809         self.text_html += h
810
811     def parseline(self, line):
812         self.text_wiki += line + '\n'
813         if len(line) == 0:
814             self.endcur()
815         elif line.startswith('*'):
816             self.endcur([self.stUL])
817             if self.state != self.stUL:
818                 self.text_html += '<ul>'
819                 self.state = self.stUL
820             self.text_html += '<li>'
821             self.text_plain += '*'
822             self.addtext(line[1:])
823             self.text_html += '</li>'
824         elif line.startswith('#'):
825             self.endcur([self.stOL])
826             if self.state != self.stOL:
827                 self.text_html += '<ol>'
828                 self.state = self.stOL
829             self.text_html += '<li>'
830             self.text_plain += '*' #TODO: lazy - put the numbers in!
831             self.addtext(line[1:])
832             self.text_html += '</li>'
833         else:
834             self.endcur([self.stPARA])
835             if self.state == self.stNONE:
836                 self.text_html += '<p>'
837                 self.state = self.stPARA
838             elif self.state == self.stPARA:
839                 self.text_html += ' '
840                 self.text_plain += ' '
841             self.addtext(line)
842
843     def end(self):
844         self.endcur()
845
846 # Parse multiple lines of description as written in a metadata file, returning
847 # a single string in plain text format.
848 def description_plain(lines, linkres):
849     ps = DescriptionFormatter(linkres)
850     for line in lines:
851         ps.parseline(line)
852     ps.end()
853     return ps.text_plain
854
855 # Parse multiple lines of description as written in a metadata file, returning
856 # a single string in wiki format.
857 def description_wiki(lines):
858     ps = DescriptionFormatter(None)
859     for line in lines:
860         ps.parseline(line)
861     ps.end()
862     return ps.text_wiki
863
864 # Parse multiple lines of description as written in a metadata file, returning
865 # a single string in HTML format.
866 def description_html(lines,linkres):
867     ps = DescriptionFormatter(linkres)
868     for line in lines:
869         ps.parseline(line)
870     ps.end()
871     return ps.text_html
872
873 def retrieve_string(xml_dir, string):
874     if not string.startswith('@string/'):
875         return string.replace("\\'","'")
876     string_search = re.compile(r'.*"'+string[8:]+'".*>([^<]+?)<.*').search
877     for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
878         for line in file(xmlfile):
879             matches = string_search(line)
880             if matches:
881                 return retrieve_string(xml_dir, matches.group(1))
882     return ''
883
884 # Return list of existing files that will be used to find the highest vercode
885 def manifest_paths(app_dir, flavour):
886
887     possible_manifests = [ os.path.join(app_dir, 'AndroidManifest.xml'),
888             os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
889             os.path.join(app_dir, 'build.gradle') ]
890
891     if flavour is not None:
892         possible_manifests.append(
893                 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
894     
895     return [path for path in possible_manifests if os.path.isfile(path)]
896
897 # Retrieve the package name
898 def fetch_real_name(app_dir, flavour):
899     app_search = re.compile(r'.*<application.*').search
900     name_search = re.compile(r'.*android:label="([^"]+)".*').search
901     app_found = False
902     name = None
903     for f in manifest_paths(app_dir, flavour):
904         if not f.endswith(".xml"):
905             continue
906         xml_dir = os.path.join(f[:-19], 'res', 'values')
907         for line in file(f):
908             if not app_found:
909                 if app_search(line):
910                     app_found = True
911             if app_found:
912                 matches = name_search(line)
913                 if matches:
914                     return retrieve_string(xml_dir, matches.group(1))
915     return ''
916
917 # Retrieve the version name
918 def version_name(original, app_dir, flavour):
919     for f in manifest_paths(app_dir, flavour):
920         if not f.endswith(".xml"):
921             continue
922         xml_dir = os.path.join(f[:-19], 'res', 'values')
923         string = retrieve_string(xml_dir, original)
924         if len(string) > 0:
925             return string
926     return original
927
928 # Extract some information from the AndroidManifest.xml at the given path.
929 # Returns (version, vercode, package), any or all of which might be None.
930 # All values returned are strings.
931 def parse_androidmanifests(paths):
932
933     if not paths:
934         return (None, None, None)
935
936     vcsearch = re.compile(r'.*android:versionCode="([0-9]+?)".*').search
937     vnsearch = re.compile(r'.*android:versionName="([^"]+?)".*').search
938     psearch = re.compile(r'.*package="([^"]+)".*').search
939
940     vcsearch_g = re.compile(r'.*versionCode[ ]+?([0-9]+?).*').search
941     vnsearch_g = re.compile(r'.*versionName[ ]+?"([^"]+?)".*').search
942     psearch_g = re.compile(r'.*packageName[ ]+?"([^"]+)".*').search
943
944     max_version = None
945     max_vercode = None
946     max_package = None
947
948     for path in paths:
949
950         gradle = path.endswith("gradle")
951         version = None
952         vercode = None
953         # Remember package name, may be defined separately from version+vercode
954         package = max_package
955
956         for line in file(path):
957             if not package:
958                 if gradle:
959                     matches = psearch_g(line)
960                 else:
961                     matches = psearch(line)
962                 if matches:
963                     package = matches.group(1)
964             if not version:
965                 if gradle:
966                     matches = vnsearch_g(line)
967                 else:
968                     matches = vnsearch(line)
969                 if matches:
970                     version = matches.group(1)
971             if not vercode:
972                 if gradle:
973                     matches = vcsearch_g(line)
974                 else:
975                     matches = vcsearch(line)
976                 if matches:
977                     vercode = matches.group(1)
978
979         # Better some package name than nothing
980         if max_package is None:
981             max_package = package
982
983         if max_vercode is None or (vercode is not None and vercode > max_vercode):
984             max_version = version
985             max_vercode = vercode
986             max_package = package
987
988     if max_version is None:
989         max_version = "Unknown"
990
991     return (max_version, max_vercode, max_package)
992
993 class BuildException(Exception):
994     def __init__(self, value, stdout = None, stderr = None):
995         self.value = value
996         self.stdout = stdout
997         self.stderr = stderr
998
999     def get_wikitext(self):
1000         ret = repr(self.value) + "\n"
1001         if self.stdout:
1002             ret += "=stdout=\n"
1003             ret += "<pre>\n"
1004             ret += str(self.stdout)
1005             ret += "</pre>\n"
1006         if self.stderr:
1007             ret += "=stderr=\n"
1008             ret += "<pre>\n"
1009             ret += str(self.stderr)
1010             ret += "</pre>\n"
1011         return ret
1012
1013     def __str__(self):
1014         ret = repr(self.value)
1015         if self.stdout:
1016             ret = ret + "\n==== stdout begin ====\n" + str(self.stdout) + "\n==== stdout end ===="
1017         if self.stderr:
1018             ret = ret + "\n==== stderr begin ====\n" + str(self.stderr) + "\n==== stderr end ===="
1019         return ret
1020
1021 class VCSException(Exception):
1022     def __init__(self, value):
1023         self.value = value
1024
1025     def __str__(self):
1026         return repr(self.value)
1027
1028 class MetaDataException(Exception):
1029     def __init__(self, value):
1030         self.value = value
1031
1032     def __str__(self):
1033         return repr(self.value)
1034
1035 def parse_srclib(metafile, **kw):
1036
1037     thisinfo = {}
1038     if metafile and not isinstance(metafile, file):
1039         metafile = open(metafile, "r")
1040
1041     # Defaults for fields that come from metadata
1042     thisinfo['Repo Type'] = ''
1043     thisinfo['Repo'] = ''
1044     thisinfo['Subdir'] = None
1045     thisinfo['Prepare'] = None
1046     thisinfo['Update Project'] = None
1047
1048     if metafile is None:
1049         return thisinfo
1050
1051     mode = 0
1052     buildlines = []
1053
1054     for line in metafile:
1055         line = line.rstrip('\r\n')
1056         if len(line) == 0:
1057             continue
1058         if line.startswith("#"):
1059             continue
1060         index = line.find(':')
1061         if index == -1:
1062             raise MetaDataException("Invalid metadata in " + metafile.name + " at: " + line)
1063         field = line[:index]
1064         value = line[index+1:]
1065
1066         if field == "Subdir":
1067             thisinfo[field] = value.split(',')
1068         else:
1069             thisinfo[field] = value
1070
1071     return thisinfo
1072
1073 # Get the specified source library.
1074 # Returns the path to it. Normally this is the path to be used when referencing
1075 # it, which may be a subdirectory of the actual project. If you want the base
1076 # directory of the project, pass 'basepath=True'.
1077 def getsrclib(spec, srclib_dir, sdk_path, ndk_path="", mvn3="", basepath=False, raw=False, prepare=True, preponly=False):
1078
1079     if raw:
1080         name = spec
1081         ref = None
1082     else:
1083         name, ref = spec.split('@')
1084
1085     srclib_path = os.path.join('srclibs', name + ".txt")
1086
1087     if not os.path.exists(srclib_path):
1088         raise BuildException('srclib ' + name + ' not found.')
1089
1090     srclib = parse_srclib(srclib_path)
1091
1092     sdir = os.path.join(srclib_dir, name)
1093
1094     if not preponly:
1095         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir, sdk_path)
1096         vcs.srclib = (name, sdir)
1097         vcs.gotorevision(ref)
1098
1099         if raw:
1100             return vcs
1101
1102     libdir = None
1103
1104     if srclib["Subdir"] is not None:
1105         for subdir in srclib["Subdir"]:
1106             libdir_candidate = os.path.join(sdir, subdir)
1107             if os.path.exists(libdir_candidate):
1108                 libdir = libdir_candidate
1109                 break
1110
1111     if libdir is None:
1112         libdir = sdir
1113
1114     if prepare:
1115
1116         if srclib["Prepare"] is not None:
1117             cmd = srclib["Prepare"].replace('$$SDK$$', sdk_path)
1118             cmd = cmd.replace('$$NDK$$', ndk_path).replace('$$MVN$$', mvn3)
1119
1120             print "******************************* PREPARE " + cmd + " **************"
1121
1122             p = subprocess.Popen(['bash', '-c', cmd], cwd=libdir,
1123                     stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1124             out, err = p.communicate()
1125             if p.returncode != 0:
1126                 raise BuildException("Error running prepare command for srclib "
1127                         + name, out, err)
1128         
1129         if srclib["Update Project"] == "Yes":
1130             print "Updating srclib %s at path %s" % (name, libdir)
1131             if subprocess.call([os.path.join(sdk_path, 'tools', 'android'),
1132                 'update', 'project', '-p', libdir]) != 0:
1133                     raise BuildException( 'Error updating ' + name + ' project')
1134
1135     if basepath:
1136         return sdir
1137     return libdir
1138
1139
1140 # Prepare the source code for a particular build
1141 #  'vcs'         - the appropriate vcs object for the application
1142 #  'app'         - the application details from the metadata
1143 #  'build'       - the build details from the metadata
1144 #  'build_dir'   - the path to the build directory, usually
1145 #                   'build/app.id'
1146 #  'srclib_dir'  - the path to the source libraries directory, usually
1147 #                   'build/srclib'
1148 #  'extlib_dir'  - the path to the external libraries directory, usually
1149 #                   'build/extlib'
1150 #  'sdk_path'    - the path to the Android SDK
1151 #  'ndk_path'    - the path to the Android NDK
1152 #  'javacc_path' - the path to javacc
1153 #  'mvn3'        - the path to the maven 3 executable
1154 #  'verbose'     - optional: verbose or not (default=False)
1155 # Returns the (root, srclibpaths) where:
1156 #   'root' is the root directory, which may be the same as 'build_dir' or may
1157 #          be a subdirectory of it.
1158 #   'srclibpaths' is information on the srclibs being used
1159 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, sdk_path, ndk_path, javacc_path, mvn3, verbose=False, onserver=False):
1160
1161     # Optionally, the actual app source can be in a subdirectory...
1162     if 'subdir' in build:
1163         root_dir = os.path.join(build_dir, build['subdir'])
1164     else:
1165         root_dir = build_dir
1166
1167     # Get a working copy of the right revision...
1168     print "Getting source for revision " + build['commit']
1169     vcs.gotorevision(build['commit'])
1170
1171     # Check that a subdir (if we're using one) exists. This has to happen
1172     # after the checkout, since it might not exist elsewhere...
1173     if not os.path.exists(root_dir):
1174         raise BuildException('Missing subdir ' + root_dir)
1175
1176     # Initialise submodules if requred...
1177     if build.get('submodules', 'no')  == 'yes':
1178         if verbose: print "Initialising submodules..."
1179         vcs.initsubmodules()
1180
1181     # Run an init command if one is required...
1182     if 'init' in build:
1183         init = build['init']
1184         init = init.replace('$$SDK$$', sdk_path)
1185         init = init.replace('$$NDK$$', ndk_path)
1186         init = init.replace('$$MVN$$', mvn3)
1187         if verbose: print "Doing init: exec '%s' in '%s'"%(init,root_dir)
1188         if subprocess.call(['bash', '-c', init], cwd=root_dir) != 0:
1189             raise BuildException("Error running init command")
1190
1191     # Generate (or update) the ant build file, build.xml...
1192     updatemode = build.get('update', '.')
1193     if (updatemode != 'no' and
1194         'maven' not in build and 'gradle' not in build):
1195         parms = [os.path.join(sdk_path, 'tools', 'android'),
1196                 'update', 'project', '-p', '.']
1197         parms.append('--subprojects')
1198         if 'target' in build:
1199             parms.append('-t')
1200             parms.append(build['target'])
1201         update_dirs = [d.strip() for d in updatemode.split(';')]
1202         # Force build.xml update if necessary...
1203         if updatemode == 'force' or 'target' in build:
1204             if updatemode == 'force':
1205                 update_dirs = ['.']
1206             buildxml = os.path.join(root_dir, 'build.xml')
1207             if os.path.exists(buildxml):
1208                 print 'Force-removing old build.xml'
1209                 os.remove(buildxml)
1210         for d in update_dirs:
1211             cwd = os.path.join(root_dir, d)
1212             # Remove gen and bin dirs in libraries
1213             # rid of them...
1214             for baddir in ['gen', 'bin']:
1215                 badpath = os.path.join(cwd, baddir)
1216                 if os.path.exists(badpath):
1217                     shutil.rmtree(badpath)
1218             if verbose:
1219                 print "Update of '%s': exec '%s' in '%s'"%\
1220                     (d," ".join(parms),cwd)
1221             p = subprocess.Popen(parms, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1222             (out, err) = p.communicate()
1223             if p.returncode != 0:
1224                 raise BuildException("Failed to update project with stdout '%s' and stderr '%s'"%(out,err))
1225             # check to see whether an error was returned without a proper exit code (this is the case for the 'no target set or target invalid' error)
1226             if err != "" and err.startswith("Error: "):
1227                 raise BuildException("Failed to update project with stdout '%s' and stderr '%s'"%(out,err))
1228
1229     # If the app has ant set up to sign the release, we need to switch
1230     # that off, because we want the unsigned apk...
1231     for propfile in ('build.properties', 'default.properties', 'ant.properties'):
1232         if os.path.exists(os.path.join(root_dir, propfile)):
1233             if subprocess.call(['sed','-i','s/^key.store/#/',
1234                                 propfile], cwd=root_dir) !=0:
1235                 raise BuildException("Failed to amend %s" % propfile)
1236
1237     # Update the local.properties file...
1238     locprops = os.path.join(root_dir, 'local.properties')
1239     if os.path.exists(locprops):
1240         f = open(locprops, 'r')
1241         props = f.read()
1242         f.close()
1243         # Fix old-fashioned 'sdk-location' by copying
1244         # from sdk.dir, if necessary...
1245         if build.get('oldsdkloc', 'no') == "yes":
1246             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1247                 re.S|re.M).group(1)
1248             props += "\nsdk-location=" + sdkloc + "\n"
1249         # Add ndk location...
1250         props+= "\nndk.dir=" + ndk_path + "\n"
1251         # Add java.encoding if necessary...
1252         if 'encoding' in build:
1253             props += "\njava.encoding=" + build['encoding'] + "\n"
1254         f = open(locprops, 'w')
1255         f.write(props)
1256         f.close()
1257
1258     # Insert version code and number into the manifest if necessary...
1259     if 'forceversion' in build:
1260         if subprocess.call(['sed','-r','-i',
1261             's/android:versionName="[^"]+"/android:versionName="' + build['version'] + '"/g',
1262             'AndroidManifest.xml'], cwd=root_dir) !=0:
1263             raise BuildException("Failed to amend manifest")
1264     if 'forcevercode' in build:
1265         if subprocess.call(['sed','-r','-i',
1266             's/android:versionCode="[^"]+"/android:versionCode="' + build['vercode'] + '"/g',
1267             'AndroidManifest.xml'], cwd=root_dir) !=0:
1268             raise BuildException("Failed to amend manifest")
1269
1270     # Delete unwanted file...
1271     if 'rm' in build:
1272         for part in build['rm'].split(';'):
1273             dest = os.path.join(build_dir, part.strip())
1274             if os.path.exists(dest):
1275                 os.remove(dest)
1276
1277     # Fix apostrophes translation files if necessary...
1278     if build.get('fixapos', 'no') == 'yes':
1279         for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1280             for filename in files:
1281                 if filename.endswith('.xml'):
1282                     if subprocess.call(['sed','-i','s@' +
1283                         r"\([^\\]\)'@\1\\'" +
1284                         '@g',
1285                         os.path.join(root, filename)]) != 0:
1286                         raise BuildException("Failed to amend " + filename)
1287
1288     # Fix translation files if necessary...
1289     if build.get('fixtrans', 'no') == 'yes':
1290         for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1291             for filename in files:
1292                 if filename.endswith('.xml'):
1293                     f = open(os.path.join(root, filename))
1294                     changed = False
1295                     outlines = []
1296                     for line in f:
1297                         num = 1
1298                         index = 0
1299                         oldline = line
1300                         while True:
1301                             index = line.find("%", index)
1302                             if index == -1:
1303                                 break
1304                             next = line[index+1:index+2]
1305                             if next == "s" or next == "d":
1306                                 line = (line[:index+1] +
1307                                         str(num) + "$" +
1308                                         line[index+1:])
1309                                 num += 1
1310                                 index += 3
1311                             else:
1312                                 index += 1
1313                         # We only want to insert the positional arguments
1314                         # when there is more than one argument...
1315                         if oldline != line:
1316                             if num > 2:
1317                                 changed = True
1318                             else:
1319                                 line = oldline
1320                         outlines.append(line)
1321                     f.close()
1322                     if changed:
1323                         f = open(os.path.join(root, filename), 'w')
1324                         f.writelines(outlines)
1325                         f.close()
1326
1327     # Add required external libraries...
1328     if 'extlibs' in build:
1329         print "Collecting prebuilt libraries..."
1330         libsdir = os.path.join(root_dir, 'libs')
1331         if not os.path.exists(libsdir):
1332             os.mkdir(libsdir)
1333         for lib in build['extlibs'].split(';'):
1334             lib = lib.strip()
1335             libf = os.path.basename(lib)
1336             shutil.copyfile(os.path.join(extlib_dir, lib),
1337                     os.path.join(libsdir, libf))
1338
1339     # Get required source libraries...
1340     srclibpaths = []
1341     if 'srclibs' in build:
1342         print "Collecting source libraries..."
1343         for lib in build['srclibs'].split(';'):
1344             lib = lib.strip()
1345             name, _ = lib.split('@')
1346             srclibpaths.append((name, getsrclib(lib, srclib_dir, sdk_path, ndk_path, mvn3, preponly=onserver)))
1347     basesrclib = vcs.getsrclib()
1348     # If one was used for the main source, add that too.
1349     if basesrclib:
1350         srclibpaths.append(basesrclib)
1351
1352     # There should never be bin, gen or native libs directories in the source, so just get
1353     # rid of them...
1354     for baddir in ['gen', 'bin', 'obj', 'libs/armeabi-v7a', 'libs/armeabi', 'libs/mips', 'libs/x86']:
1355         badpath = os.path.join(root_dir, baddir)
1356         if os.path.exists(badpath):
1357             shutil.rmtree(badpath)
1358
1359     # Apply patches if any
1360     if 'patch' in build:
1361         for patch in build['patch'].split(';'):
1362             patch = patch.strip()
1363             print "Applying " + patch
1364             patch_path = os.path.join('metadata', app['id'], patch)
1365             if subprocess.call(['patch', '-p1',
1366                             '-i', os.path.abspath(patch_path)], cwd=build_dir) != 0:
1367                 raise BuildException("Failed to apply patch %s" % patch_path)
1368
1369     # Run a pre-build command if one is required...
1370     if 'prebuild' in build:
1371         prebuild = build['prebuild']
1372         if verbose:
1373             print "Running source init (prebuild) commands:" + prebuild
1374         else:
1375             print "Running source init (prebuild) commands..."
1376
1377         # Substitute source library paths into prebuild commands...
1378         for name, libpath in srclibpaths:
1379             libpath = os.path.relpath(libpath, root_dir)
1380             prebuild = prebuild.replace('$$' + name + '$$', libpath)
1381         prebuild = prebuild.replace('$$SDK$$', sdk_path)
1382         prebuild = prebuild.replace('$$NDK$$', ndk_path)
1383         prebuild = prebuild.replace('$$MVN3$$', mvn3)
1384         p = subprocess.Popen(['bash', '-c', prebuild], cwd=root_dir,
1385                 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1386         out, err = p.communicate()
1387         if p.returncode != 0:
1388             raise BuildException("Error running pre-build command", out, err)
1389
1390     print "Applying generic clean-ups..."
1391
1392     if build.get('anal-tics', 'no') == 'yes':
1393         fp = os.path.join(root_dir, 'src', 'com', 'google', 'android', 'apps', 'analytics')
1394         os.makedirs(fp)
1395         with open(os.path.join(fp, 'GoogleAnalyticsTracker.java'), 'w') as f:
1396             f.write("""
1397             package com.google.android.apps.analytics;
1398             public class GoogleAnalyticsTracker {
1399                 private static GoogleAnalyticsTracker instance;
1400                 private GoogleAnalyticsTracker() {
1401                 }
1402                 public static GoogleAnalyticsTracker getInstance() {
1403                     if(instance == null)
1404                         instance = new GoogleAnalyticsTracker();
1405                     return instance;
1406                 }
1407                 public void start(String i,int think ,Object not) {
1408                 }
1409                 public void dispatch() {
1410                 }
1411                 public void stop() {
1412                 }
1413                 public void setProductVersion(String uh, String hu) {
1414                 }
1415                 public void trackEvent(String that,String just,String aint,int happening) {
1416                 }
1417                 public void trackPageView(String nope) {
1418                 }
1419                 public void setCustomVar(int mind,String your,String own,int business) {
1420                 }
1421             }
1422             """)
1423
1424     # Special case init functions for funambol...
1425     if build.get('initfun', 'no')  == "yes":
1426
1427         if subprocess.call(['sed','-i','s@' +
1428             '<taskdef resource="net/sf/antcontrib/antcontrib.properties" />' +
1429             '@' +
1430             '<taskdef resource="net/sf/antcontrib/antcontrib.properties">' +
1431             '<classpath>' +
1432             '<pathelement location="/usr/share/java/ant-contrib.jar"/>' +
1433             '</classpath>' +
1434             '</taskdef>' +
1435             '@g',
1436             'build.xml'], cwd=root_dir) !=0:
1437             raise BuildException("Failed to amend build.xml")
1438
1439         if subprocess.call(['sed','-i','s@' +
1440             '\${user.home}/funambol/build/android/build.properties' +
1441             '@' +
1442             'build.properties' +
1443             '@g',
1444             'build.xml'], cwd=root_dir) !=0:
1445             raise BuildException("Failed to amend build.xml")
1446
1447         buildxml = os.path.join(root_dir, 'build.xml')
1448         f = open(buildxml, 'r')
1449         xml = f.read()
1450         f.close()
1451         xmlout = ""
1452         mode = 0
1453         for line in xml.splitlines():
1454             if mode == 0:
1455                 if line.find("jarsigner") != -1:
1456                     mode = 1
1457                 else:
1458                     xmlout += line + "\n"
1459             else:
1460                 if line.find("/exec") != -1:
1461                     mode += 1
1462                     if mode == 3:
1463                         mode =0
1464         f = open(buildxml, 'w')
1465         f.write(xmlout)
1466         f.close()
1467
1468         if subprocess.call(['sed','-i','s@' +
1469             'platforms/android-2.0' +
1470             '@' +
1471             'platforms/android-8' +
1472             '@g',
1473             'build.xml'], cwd=root_dir) !=0:
1474             raise BuildException("Failed to amend build.xml")
1475
1476         shutil.copyfile(
1477                 os.path.join(root_dir, "build.properties.example"),
1478                 os.path.join(root_dir, "build.properties"))
1479
1480         if subprocess.call(['sed','-i','s@' +
1481             'javacchome=.*'+
1482             '@' +
1483             'javacchome=' + javacc_path +
1484             '@g',
1485             'build.properties'], cwd=root_dir) !=0:
1486             raise BuildException("Failed to amend build.properties")
1487
1488         if subprocess.call(['sed','-i','s@' +
1489             'sdk-folder=.*'+
1490             '@' +
1491             'sdk-folder=' + sdk_path +
1492             '@g',
1493             'build.properties'], cwd=root_dir) !=0:
1494             raise BuildException("Failed to amend build.properties")
1495
1496         if subprocess.call(['sed','-i','s@' +
1497             'android.sdk.version.*'+
1498             '@' +
1499             'android.sdk.version=2.0' +
1500             '@g',
1501             'build.properties'], cwd=root_dir) !=0:
1502             raise BuildException("Failed to amend build.properties")
1503
1504     return (root_dir, srclibpaths)
1505
1506
1507 # Scan the source code in the given directory (and all subdirectories)
1508 # and return a list of potential problems.
1509 def scan_source(build_dir, root_dir, thisbuild):
1510
1511     problems = []
1512
1513     # Common known non-free blobs:
1514     usual_suspects = ['flurryagent',
1515                       'paypal_mpl',
1516                       'libgoogleanalytics',
1517                       'admob-sdk-android',
1518                       'googleadview',
1519                       'googleadmobadssdk',
1520                       'google-play-services',
1521                       'crittercism',
1522                       'heyzap',
1523                       'jpct-ae']
1524
1525     if 'scanignore' in thisbuild:
1526         ignore = [p.strip() for p in thisbuild['scanignore'].split(';')]
1527     else:
1528         ignore = []
1529
1530     # Iterate through all files in the source code...
1531     for r,d,f in os.walk(build_dir):
1532         for curfile in f:
1533
1534             if '/.hg' in r or '/.git' in r or '/.svn' in r:
1535                 continue
1536
1537             # Path (relative) to the file...
1538             fp = os.path.join(r, curfile)
1539
1540             # Check if this file has been explicitly excluded from scanning...
1541             ignorethis = False
1542             for i in ignore:
1543                 if fp.startswith(i):
1544                     ignorethis = True
1545                     break
1546             if ignorethis:
1547                 continue
1548
1549             for suspect in usual_suspects:
1550                 if suspect in curfile.lower():
1551                     msg = 'Found probable non-free blob ' + fp
1552                     problems.append(msg)
1553
1554             if curfile.endswith('.apk'):
1555                 msg = 'Found apk file, which should not be in the source - ' + fp
1556                 problems.append(msg)
1557
1558             elif curfile.endswith('.elf'):
1559                 msg = 'Found .elf at ' + fp
1560                 problems.append(msg)
1561
1562             elif curfile.endswith('.so'):
1563                 msg = 'Found .so at ' + fp
1564                 problems.append(msg)
1565
1566             elif curfile.endswith('.java'):
1567                 for line in file(fp):
1568                     if 'DexClassLoader' in line:
1569                         msg = 'Found DexClassLoader in ' + fp
1570                         problems.append(msg)
1571
1572     # Presence of a jni directory without buildjni=yes might
1573     # indicate a problem... (if it's not a problem, explicitly use
1574     # buildjni=no to bypass this check)
1575     if (os.path.exists(os.path.join(root_dir, 'jni')) and 
1576             thisbuild.get('buildjni') is None):
1577         msg = 'Found jni directory, but buildjni is not enabled'
1578         problems.append(msg)
1579
1580     return problems
1581
1582
1583 class KnownApks:
1584
1585     def __init__(self):
1586         self.path = os.path.join('stats', 'known_apks.txt')
1587         self.apks = {}
1588         if os.path.exists(self.path):
1589             for line in file( self.path):
1590                 t = line.rstrip().split(' ')
1591                 if len(t) == 2:
1592                     self.apks[t[0]] = (t[1], None)
1593                 else:
1594                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1595         self.changed = False
1596
1597     def writeifchanged(self):
1598         if self.changed:
1599             if not os.path.exists('stats'):
1600                 os.mkdir('stats')
1601             f = open(self.path, 'w')
1602             lst = []
1603             for apk, app in self.apks.iteritems():
1604                 appid, added = app
1605                 line = apk + ' ' + appid
1606                 if added:
1607                     line += ' ' + time.strftime('%Y-%m-%d', added)
1608                 lst.append(line)
1609             for line in sorted(lst):
1610                 f.write(line + '\n')
1611             f.close()
1612
1613     # Record an apk (if it's new, otherwise does nothing)
1614     # Returns the date it was added.
1615     def recordapk(self, apk, app):
1616         if not apk in self.apks:
1617             self.apks[apk] = (app, time.gmtime(time.time()))
1618             self.changed = True
1619         _, added = self.apks[apk]
1620         return added
1621
1622     # Look up information - given the 'apkname', returns (app id, date added/None).
1623     # Or returns None for an unknown apk.
1624     def getapp(self, apkname):
1625         if apkname in self.apks:
1626             return self.apks[apkname]
1627         return None
1628
1629     # Get the most recent 'num' apps added to the repo, as a list of package ids
1630     # with the most recent first.
1631     def getlatest(self, num):
1632         apps = {}
1633         for apk, app in self.apks.iteritems():
1634             appid, added = app
1635             if added:
1636                 if appid in apps:
1637                     if apps[appid] > added:
1638                         apps[appid] = added
1639                 else:
1640                     apps[appid] = added
1641         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1642         lst = []
1643         for app, added in sortedapps:
1644             lst.append(app)
1645         lst.reverse()
1646         return lst
1647
1648 def isApkDebuggable(apkfile):
1649     """Returns True if the given apk file is debuggable
1650
1651     :param apkfile: full path to the apk to check"""
1652
1653     execfile('config.py', globals())
1654
1655     p = subprocess.Popen([os.path.join(sdk_path, 'build-tools', build_tools, 'aapt'),
1656                   'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1657                  stdout=subprocess.PIPE)
1658     output = p.communicate()[0]
1659     if p.returncode != 0:
1660         print "ERROR: Failed to get apk manifest information"
1661         sys.exit(1)
1662     for line in output.splitlines():
1663         if line.find('android:debuggable') != -1 and not line.endswith('0x0'):
1664             return True
1665     return False
1666
1667