mirror of
https://github.com/f-droid/fdroidserver.git
synced 2025-09-16 16:02:33 +03:00
Merge branch 'gradle' (finally!)
Conflicts: fdroidserver/checkupdates.py
This commit is contained in:
commit
2fdd274580
6 changed files with 153 additions and 91 deletions
|
@ -5,9 +5,8 @@
|
||||||
sdk_path = "/path/to/android-sdk-linux_86"
|
sdk_path = "/path/to/android-sdk-linux_86"
|
||||||
ndk_path = "/path/to/android-ndk-r8e"
|
ndk_path = "/path/to/android-ndk-r8e"
|
||||||
|
|
||||||
# May be necessary for fdroid update; you may still need to make a symlink to
|
# Build tools version to be used
|
||||||
# aapt in platform-tools
|
build_tools = "18.0.1"
|
||||||
aapt_path = "/path/to/android-sdk-linux_x86/build-tools/17.0.0/aapt"
|
|
||||||
|
|
||||||
#You probably don't need to change this...
|
#You probably don't need to change this...
|
||||||
javacc_path = "/usr/share/java"
|
javacc_path = "/usr/share/java"
|
||||||
|
@ -15,6 +14,12 @@ javacc_path = "/usr/share/java"
|
||||||
#Command for running maven 3 (could be mvn, mvn3, or a full path)
|
#Command for running maven 3 (could be mvn, mvn3, or a full path)
|
||||||
mvn3 = "mvn3"
|
mvn3 = "mvn3"
|
||||||
|
|
||||||
|
# Gradle command
|
||||||
|
gradle = "gradle"
|
||||||
|
|
||||||
|
# Android gradle plugin version
|
||||||
|
gradle_plugin = "0.5.+"
|
||||||
|
|
||||||
repo_url = "https://f-droid.org/repo"
|
repo_url = "https://f-droid.org/repo"
|
||||||
repo_name = "F-Droid"
|
repo_name = "F-Droid"
|
||||||
repo_icon = "fdroid-icon.png"
|
repo_icon = "fdroid-icon.png"
|
||||||
|
|
|
@ -772,6 +772,14 @@ already exist, but not a good idea if it's heavily customised. If you get an
|
||||||
error about invalid target, first try @code{init=rm -rf bin/}; otherwise this
|
error about invalid target, first try @code{init=rm -rf bin/}; otherwise this
|
||||||
parameter should do the trick.
|
parameter should do the trick.
|
||||||
|
|
||||||
|
Please note that gradle builds should be using compilesdk=.
|
||||||
|
|
||||||
|
@item compilesdk=<level>
|
||||||
|
Practically accomplishes the same that target= does when used in ant and maven
|
||||||
|
projects. compilesdk= is used rather than target= so as to not cause any more
|
||||||
|
confusion. It only takes effect on gradle builds in the build.gradle file,
|
||||||
|
thus using it in any other case is not wise.
|
||||||
|
|
||||||
@item update=xxx
|
@item update=xxx
|
||||||
By default, 'android update project' is used to generate or update the
|
By default, 'android update project' is used to generate or update the
|
||||||
build.xml file. Specifying update=no bypasses that.
|
build.xml file. Specifying update=no bypasses that.
|
||||||
|
|
|
@ -415,6 +415,29 @@ def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_d
|
||||||
if 'mvnflags' in thisbuild:
|
if 'mvnflags' in thisbuild:
|
||||||
mvncmd += thisbuild['mvnflags']
|
mvncmd += thisbuild['mvnflags']
|
||||||
p = subprocess.Popen(mvncmd, cwd=root_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
p = subprocess.Popen(mvncmd, cwd=root_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
elif 'gradle' in thisbuild:
|
||||||
|
flavour = thisbuild['gradle']
|
||||||
|
|
||||||
|
if 'compilesdk' in thisbuild:
|
||||||
|
level = thisbuild["compilesdk"].split('-')[1]
|
||||||
|
subprocess.call(['sed', '-i',
|
||||||
|
's@compileSdkVersion[ ]*[0-9]*@compileSdkVersion '+level+'@g',
|
||||||
|
'build.gradle'], cwd=root_dir)
|
||||||
|
|
||||||
|
subprocess.call(['sed', '-i',
|
||||||
|
's@buildToolsVersion[ ]*["\'][0-9\.]*["\']@buildToolsVersion "'+build_tools+'"@g',
|
||||||
|
'build.gradle'], cwd=root_dir)
|
||||||
|
|
||||||
|
subprocess.call(['sed', '-i',
|
||||||
|
's@com.android.tools.build:gradle:[0-9\.\+]*@com.android.tools.build:gradle:'+gradle_plugin+'@g',
|
||||||
|
'build.gradle'], cwd=root_dir)
|
||||||
|
|
||||||
|
if install:
|
||||||
|
commands = [gradle, 'assemble'+flavour+'Debug', 'install'+flavour+'Debug']
|
||||||
|
else:
|
||||||
|
commands = [gradle, 'assemble'+flavour+'Release']
|
||||||
|
|
||||||
|
p = subprocess.Popen(commands, cwd=root_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
else:
|
else:
|
||||||
if install:
|
if install:
|
||||||
antcommands = ['debug','install']
|
antcommands = ['debug','install']
|
||||||
|
@ -464,6 +487,8 @@ def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_d
|
||||||
raise BuildException('Failed to find output')
|
raise BuildException('Failed to find output')
|
||||||
src = m.group(1)
|
src = m.group(1)
|
||||||
src = os.path.join(bindir, src) + '.apk'
|
src = os.path.join(bindir, src) + '.apk'
|
||||||
|
elif 'gradle' in thisbuild:
|
||||||
|
src = os.path.join(build_dir, 'build', 'apk', '-'.join([app['id'], flavour, 'release', 'unsigned'])+'.apk')
|
||||||
else:
|
else:
|
||||||
src = re.match(r".*^.*Creating (.+) for release.*$.*", output,
|
src = re.match(r".*^.*Creating (.+) for release.*$.*", output,
|
||||||
re.S|re.M).group(1)
|
re.S|re.M).group(1)
|
||||||
|
@ -478,14 +503,8 @@ def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_d
|
||||||
print "Checking " + src
|
print "Checking " + src
|
||||||
if not os.path.exists(src):
|
if not os.path.exists(src):
|
||||||
raise BuildException("Unsigned apk is not at expected location of " + src)
|
raise BuildException("Unsigned apk is not at expected location of " + src)
|
||||||
if ('aapt_path' not in globals()):
|
|
||||||
# (re-)read configuration
|
|
||||||
execfile('config.py', globals())
|
|
||||||
if not os.path.exists(aapt_path):
|
|
||||||
print "Missing aapt - check aapt_path in your config"
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
p = subprocess.Popen([aapt_path,
|
p = subprocess.Popen([os.path.join(sdk_path, 'build-tools', build_tools, 'aapt'),
|
||||||
'dump', 'badging', src],
|
'dump', 'badging', src],
|
||||||
stdout=subprocess.PIPE)
|
stdout=subprocess.PIPE)
|
||||||
output = p.communicate()[0]
|
output = p.communicate()[0]
|
||||||
|
|
|
@ -60,9 +60,12 @@ def check_tags(app, sdk_path):
|
||||||
|
|
||||||
vcs.gotorevision(None)
|
vcs.gotorevision(None)
|
||||||
|
|
||||||
|
flavour = None
|
||||||
if len(app['builds']) > 0:
|
if len(app['builds']) > 0:
|
||||||
if 'subdir' in app['builds'][-1]:
|
if 'subdir' in app['builds'][-1]:
|
||||||
build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
|
build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
|
||||||
|
if 'gradle' in app['builds'][-1]:
|
||||||
|
flavour = app['builds'][-1]['gradle']
|
||||||
|
|
||||||
hver = None
|
hver = None
|
||||||
hcode = "0"
|
hcode = "0"
|
||||||
|
@ -71,10 +74,8 @@ def check_tags(app, sdk_path):
|
||||||
vcs.gotorevision(tag)
|
vcs.gotorevision(tag)
|
||||||
|
|
||||||
# Only process tags where the manifest exists...
|
# Only process tags where the manifest exists...
|
||||||
path = common.manifest_path(build_dir)
|
paths = common.manifest_paths(build_dir, flavour)
|
||||||
print "Trying manifest at %s" % path
|
version, vercode, package = common.parse_androidmanifests(paths)
|
||||||
if os.path.exists(path):
|
|
||||||
version, vercode, package = common.parse_androidmanifest(build_dir)
|
|
||||||
print "Manifest exists. Found version %s" % version
|
print "Manifest exists. Found version %s" % version
|
||||||
if package and package == app['id'] and version and vercode:
|
if package and package == app['id'] and version and vercode:
|
||||||
if int(vercode) > int(hcode):
|
if int(vercode) > int(hcode):
|
||||||
|
@ -138,20 +139,20 @@ def check_repomanifest(app, sdk_path, branch=None):
|
||||||
elif vcs.repotype() == 'bzr':
|
elif vcs.repotype() == 'bzr':
|
||||||
vcs.gotorevision(None)
|
vcs.gotorevision(None)
|
||||||
|
|
||||||
|
flavour = None
|
||||||
|
|
||||||
if len(app['builds']) > 0:
|
if len(app['builds']) > 0:
|
||||||
if 'subdir' in app['builds'][-1]:
|
if 'subdir' in app['builds'][-1]:
|
||||||
build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
|
build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
|
||||||
|
if 'gradle' in app['builds'][-1]:
|
||||||
|
flavour = app['builds'][-1]['gradle']
|
||||||
|
|
||||||
if not os.path.isdir(build_dir):
|
if not os.path.isdir(build_dir):
|
||||||
return (None, "Subdir '" + app['builds'][-1]['subdir'] + "'is not a valid directory")
|
return (None, "Subdir '" + app['builds'][-1]['subdir'] + "'is not a valid directory")
|
||||||
|
|
||||||
if os.path.exists(os.path.join(build_dir, 'AndroidManifest.xml')):
|
paths = common.manifest_paths(build_dir, flavour)
|
||||||
version, vercode, package = common.parse_androidmanifest(build_dir)
|
|
||||||
elif os.path.exists(os.path.join(build_dir, 'src', 'main', 'AndroidManifest.xml')):
|
version, vercode, package = common.parse_androidmanifests(paths)
|
||||||
# Alternate location for simple gradle locations...
|
|
||||||
version, vercode, package = common.parse_androidmanifest(os.path.join(build_dir, 'src', 'main'))
|
|
||||||
else:
|
|
||||||
return (None, "AndroidManifest.xml not found")
|
|
||||||
if not package:
|
if not package:
|
||||||
return (None, "Couldn't find package ID")
|
return (None, "Couldn't find package ID")
|
||||||
if package != app['id']:
|
if package != app['id']:
|
||||||
|
@ -219,6 +220,7 @@ def check_market(app):
|
||||||
def main():
|
def main():
|
||||||
|
|
||||||
#Read configuration...
|
#Read configuration...
|
||||||
|
globals()['gradle'] = "gradle"
|
||||||
execfile('config.py', globals())
|
execfile('config.py', globals())
|
||||||
|
|
||||||
# Parse command line...
|
# Parse command line...
|
||||||
|
@ -302,15 +304,18 @@ def main():
|
||||||
vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir, sdk_path)
|
vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir, sdk_path)
|
||||||
vcs.gotorevision(None)
|
vcs.gotorevision(None)
|
||||||
|
|
||||||
|
flavour = None
|
||||||
if len(app['builds']) > 0:
|
if len(app['builds']) > 0:
|
||||||
if 'subdir' in app['builds'][-1]:
|
if 'subdir' in app['builds'][-1]:
|
||||||
app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
|
app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
|
||||||
|
if 'gradle' in app['builds'][-1]:
|
||||||
|
flavour = app['builds'][-1]['gradle']
|
||||||
|
|
||||||
new_name = common.fetch_real_name(app_dir)
|
new_name = common.fetch_real_name(app_dir, flavour)
|
||||||
if new_name != app['Auto Name']:
|
if new_name != app['Auto Name']:
|
||||||
app['Auto Name'] = new_name
|
app['Auto Name'] = new_name
|
||||||
if not writeit:
|
|
||||||
writeit = True
|
writeit = True
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = "Auto Name failed for %s due to exception: %s" % (app['id'], traceback.format_exc())
|
msg = "Auto Name failed for %s due to exception: %s" % (app['id'], traceback.format_exc())
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ import subprocess
|
||||||
import time
|
import time
|
||||||
import operator
|
import operator
|
||||||
import cgi
|
import cgi
|
||||||
|
import fileinput
|
||||||
|
|
||||||
def getvcs(vcstype, remote, local, sdk_path):
|
def getvcs(vcstype, remote, local, sdk_path):
|
||||||
if vcstype == 'git':
|
if vcstype == 'git':
|
||||||
|
@ -861,77 +862,109 @@ def description_html(lines,linkres):
|
||||||
ps.end()
|
ps.end()
|
||||||
return ps.text_html
|
return ps.text_html
|
||||||
|
|
||||||
def retrieve_string(app_dir, string_id):
|
def retrieve_string(xml_dir, string):
|
||||||
string_search = re.compile(r'.*"'+string_id+'".*>([^<]+?)<.*').search
|
if not string.startswith('@string/'):
|
||||||
for xmlfile in glob.glob(os.path.join(
|
return string.replace("\\'","'")
|
||||||
app_dir, 'res', 'values', '*.xml')):
|
string_search = re.compile(r'.*"'+string[8:]+'".*>([^<]+?)<.*').search
|
||||||
|
for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
|
||||||
for line in file(xmlfile):
|
for line in file(xmlfile):
|
||||||
matches = string_search(line)
|
matches = string_search(line)
|
||||||
if matches:
|
if matches:
|
||||||
s = matches.group(1)
|
return retrieve_string(xml_dir, matches.group(1))
|
||||||
if s.startswith('@string/'):
|
|
||||||
return retrieve_string(app_dir, s[8:]);
|
|
||||||
return s.replace("\\'","'")
|
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
# Find the AM.xml - try the new gradle method first.
|
# Return list of existing files that will be used to find the highest vercode
|
||||||
def manifest_path(app_dir):
|
def manifest_paths(app_dir, flavour):
|
||||||
gradlepath = os.path.join(app_dir, 'source', 'main', 'AndroidManifest.xml')
|
|
||||||
if os.path.exists(gradlepath):
|
|
||||||
return gradlepath
|
|
||||||
rootpath = os.path.join(app_dir, 'AndroidManifest.xml')
|
|
||||||
return rootpath
|
|
||||||
|
|
||||||
|
possible_manifests = [ os.path.join(app_dir, 'AndroidManifest.xml'),
|
||||||
|
os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
|
||||||
|
os.path.join(app_dir, 'build.gradle') ]
|
||||||
|
|
||||||
|
if flavour is not None:
|
||||||
|
possible_manifests.append(
|
||||||
|
os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
|
||||||
|
|
||||||
|
return [path for path in possible_manifests if os.path.isfile(path)]
|
||||||
|
|
||||||
# Retrieve the package name
|
# Retrieve the package name
|
||||||
def fetch_real_name(app_dir):
|
def fetch_real_name(app_dir, flavour):
|
||||||
app_search = re.compile(r'.*<application.*').search
|
app_search = re.compile(r'.*<application.*').search
|
||||||
name_search = re.compile(r'.*android:label="([^"]+)".*').search
|
name_search = re.compile(r'.*android:label="([^"]+)".*').search
|
||||||
app_found = False
|
app_found = False
|
||||||
name = None
|
name = None
|
||||||
for line in file(manifest_path(app_dir)):
|
for f in manifest_paths(app_dir, flavour):
|
||||||
|
if not f.endswith(".xml"):
|
||||||
|
continue
|
||||||
|
xml_dir = os.path.join(f[:-19], 'res', 'values')
|
||||||
|
for line in file(f):
|
||||||
if not app_found:
|
if not app_found:
|
||||||
if app_search(line):
|
if app_search(line):
|
||||||
app_found = True
|
app_found = True
|
||||||
if app_found:
|
if app_found:
|
||||||
if name is not None:
|
|
||||||
break
|
|
||||||
matches = name_search(line)
|
matches = name_search(line)
|
||||||
if matches:
|
if matches:
|
||||||
name = matches.group(1)
|
return retrieve_string(xml_dir, matches.group(1))
|
||||||
|
|
||||||
if name.startswith('@string/'):
|
return ''
|
||||||
return retrieve_string(app_dir, name[8:])
|
|
||||||
return name
|
|
||||||
|
|
||||||
# Extract some information from the AndroidManifest.xml at the given path.
|
# Extract some information from the AndroidManifest.xml at the given path.
|
||||||
# Returns (version, vercode, package), any or all of which might be None.
|
# Returns (version, vercode, package), any or all of which might be None.
|
||||||
# All values returned are strings.
|
# All values returned are strings.
|
||||||
def parse_androidmanifest(app_dir):
|
def parse_androidmanifests(paths):
|
||||||
|
|
||||||
vcsearch = re.compile(r'.*android:versionCode="([0-9]+?)".*').search
|
vcsearch = re.compile(r'.*android:versionCode="([0-9]+?)".*').search
|
||||||
vnsearch = re.compile(r'.*android:versionName="([^"]+?)".*').search
|
vnsearch = re.compile(r'.*android:versionName="([^"]+?)".*').search
|
||||||
psearch = re.compile(r'.*package="([^"]+)".*').search
|
psearch = re.compile(r'.*package="([^"]+)".*').search
|
||||||
vnsearch_xml = re.compile(r'.*"(app_|)version">([^<]+?)<.*').search
|
|
||||||
|
vcsearch_g = re.compile(r'.*versionCode[ ]+?([0-9]+?).*').search
|
||||||
|
vnsearch_g = re.compile(r'.*versionName[ ]+?"([^"]+?)".*').search
|
||||||
|
psearch_g = re.compile(r'.*packageName[ ]+?"([^"]+)".*').search
|
||||||
|
|
||||||
|
max_version = None
|
||||||
|
max_vercode = None
|
||||||
|
max_package = None
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
|
||||||
|
gradle = path.endswith("gradle")
|
||||||
version = None
|
version = None
|
||||||
vercode = None
|
vercode = None
|
||||||
package = None
|
# Remember package name, may be defined separately from version+vercode
|
||||||
for line in file(manifest_path(app_dir)):
|
package = max_package
|
||||||
|
|
||||||
|
for line in file(path):
|
||||||
if not package:
|
if not package:
|
||||||
|
if gradle:
|
||||||
|
matches = psearch_g(line)
|
||||||
|
else:
|
||||||
matches = psearch(line)
|
matches = psearch(line)
|
||||||
if matches:
|
if matches:
|
||||||
package = matches.group(1)
|
package = matches.group(1)
|
||||||
if not version:
|
if not version:
|
||||||
|
if gradle:
|
||||||
|
matches = vnsearch_g(line)
|
||||||
|
else:
|
||||||
matches = vnsearch(line)
|
matches = vnsearch(line)
|
||||||
if matches:
|
if matches:
|
||||||
version = matches.group(1)
|
version = matches.group(1)
|
||||||
if not vercode:
|
if not vercode:
|
||||||
|
if gradle:
|
||||||
|
matches = vcsearch_g(line)
|
||||||
|
else:
|
||||||
matches = vcsearch(line)
|
matches = vcsearch(line)
|
||||||
if matches:
|
if matches:
|
||||||
vercode = matches.group(1)
|
vercode = matches.group(1)
|
||||||
if version.startswith('@string/'):
|
|
||||||
version = retrieve_string(app_dir, version[8:])
|
# Better some package name than nothing
|
||||||
return (version, vercode, package)
|
if max_package is None:
|
||||||
|
max_package = package
|
||||||
|
|
||||||
|
if max_vercode is None or (vercode is not None and vercode > max_vercode):
|
||||||
|
max_version = version
|
||||||
|
max_vercode = vercode
|
||||||
|
max_package = package
|
||||||
|
|
||||||
|
return (max_version, max_vercode, max_package)
|
||||||
|
|
||||||
class BuildException(Exception):
|
class BuildException(Exception):
|
||||||
def __init__(self, value, stdout = None, stderr = None):
|
def __init__(self, value, stdout = None, stderr = None):
|
||||||
|
@ -1134,7 +1167,7 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, sdk_path,
|
||||||
# Generate (or update) the ant build file, build.xml...
|
# Generate (or update) the ant build file, build.xml...
|
||||||
updatemode = build.get('update', '.')
|
updatemode = build.get('update', '.')
|
||||||
if (updatemode != 'no' and
|
if (updatemode != 'no' and
|
||||||
'maven' not in build):
|
'maven' not in build and 'gradle' not in build):
|
||||||
parms = [os.path.join(sdk_path, 'tools', 'android'),
|
parms = [os.path.join(sdk_path, 'tools', 'android'),
|
||||||
'update', 'project', '-p', '.']
|
'update', 'project', '-p', '.']
|
||||||
parms.append('--subprojects')
|
parms.append('--subprojects')
|
||||||
|
@ -1575,14 +1608,9 @@ def isApkDebuggable(apkfile):
|
||||||
|
|
||||||
:param apkfile: full path to the apk to check"""
|
:param apkfile: full path to the apk to check"""
|
||||||
|
|
||||||
if ('aapt_path' not in globals()):
|
|
||||||
# (re-)read configuration
|
|
||||||
execfile('config.py', globals())
|
execfile('config.py', globals())
|
||||||
if not os.path.exists(aapt_path):
|
|
||||||
print "Missing aapt - check aapt_path in your config"
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
p = subprocess.Popen([aapt_path,
|
p = subprocess.Popen([os.path.join(sdk_path, 'build-tools', build_tools, 'aapt'),
|
||||||
'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
|
'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
|
||||||
stdout=subprocess.PIPE)
|
stdout=subprocess.PIPE)
|
||||||
output = p.communicate()[0]
|
output = p.communicate()[0]
|
||||||
|
|
|
@ -267,10 +267,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
|
||||||
thisinfo['size'] = os.path.getsize(apkfile)
|
thisinfo['size'] = os.path.getsize(apkfile)
|
||||||
thisinfo['permissions'] = []
|
thisinfo['permissions'] = []
|
||||||
thisinfo['features'] = []
|
thisinfo['features'] = []
|
||||||
if not os.path.exists(aapt_path):
|
p = subprocess.Popen([os.path.join(sdk_path, 'build-tools', build_tools, 'aapt'),
|
||||||
print "Missing aapt - check aapt_path in your config"
|
|
||||||
sys.exit(1)
|
|
||||||
p = subprocess.Popen([aapt_path,
|
|
||||||
'dump', 'badging', apkfile],
|
'dump', 'badging', apkfile],
|
||||||
stdout=subprocess.PIPE)
|
stdout=subprocess.PIPE)
|
||||||
output = p.communicate()[0]
|
output = p.communicate()[0]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue